From b1003be1df40f35fbf77fafd1c2ccf0816230995 Mon Sep 17 00:00:00 2001 From: Simon Warta Date: Tue, 2 Jun 2020 12:29:38 +0200 Subject: [PATCH] Create package @cosmwasm/sdk38 --- packages/sdk38/.eslintignore | 1 + packages/sdk38/.gitignore | 3 + packages/sdk38/README.md | 12 + packages/sdk38/jasmine-testrunner.js | 26 + packages/sdk38/karma.conf.js | 54 ++ packages/sdk38/nonces/README.txt | 1 + packages/sdk38/package.json | 48 + packages/sdk38/src/address.spec.ts | 27 + packages/sdk38/src/address.ts | 42 + packages/sdk38/src/coins.ts | 14 + .../sdk38/src/cosmosclient.searchtx.spec.ts | 366 +++++++ packages/sdk38/src/cosmosclient.spec.ts | 296 ++++++ packages/sdk38/src/cosmosclient.ts | 439 +++++++++ packages/sdk38/src/decoding.ts | 11 + packages/sdk38/src/encoding.spec.ts | 1 + packages/sdk38/src/encoding.ts | 59 ++ packages/sdk38/src/index.ts | 12 + packages/sdk38/src/logs.spec.ts | 165 ++++ packages/sdk38/src/logs.ts | 86 ++ packages/sdk38/src/pen.spec.ts | 56 ++ packages/sdk38/src/pen.ts | 92 ++ packages/sdk38/src/pubkey.spec.ts | 57 ++ packages/sdk38/src/pubkey.ts | 71 ++ packages/sdk38/src/restclient.spec.ts | 897 ++++++++++++++++++ packages/sdk38/src/restclient.ts | 457 +++++++++ packages/sdk38/src/sequence.spec.ts | 30 + packages/sdk38/src/sequence.ts | 46 + packages/sdk38/src/signature.spec.ts | 69 ++ packages/sdk38/src/signature.ts | 39 + .../sdk38/src/signingcosmosclient.spec.ts | 78 ++ packages/sdk38/src/signingcosmosclient.ts | 145 +++ packages/sdk38/src/testdata/cosmoshub.json | 44 + packages/sdk38/src/testdata/txresponse1.json | 57 ++ packages/sdk38/src/testdata/txresponse2.json | 57 ++ packages/sdk38/src/testdata/txresponse3.json | 57 ++ packages/sdk38/src/testutils.spec.ts | 58 ++ packages/sdk38/src/types.ts | 105 ++ packages/sdk38/tsconfig.json | 12 + packages/sdk38/typedoc.js | 14 + packages/sdk38/types/address.d.ts | 3 + packages/sdk38/types/coins.d.ts | 8 + packages/sdk38/types/cosmosclient.d.ts | 175 ++++ packages/sdk38/types/decoding.d.ts | 2 + packages/sdk38/types/encoding.d.ts | 10 + packages/sdk38/types/index.d.ts | 11 + packages/sdk38/types/logs.d.ts | 28 + packages/sdk38/types/pen.d.ts | 33 + packages/sdk38/types/pubkey.d.ts | 4 + packages/sdk38/types/restclient.d.ts | 231 +++++ packages/sdk38/types/sequence.d.ts | 19 + packages/sdk38/types/signature.d.ts | 14 + packages/sdk38/types/signingcosmosclient.d.ts | 74 ++ packages/sdk38/types/types.d.ts | 66 ++ packages/sdk38/webpack.web.config.js | 19 + 54 files changed, 4801 insertions(+) create mode 120000 packages/sdk38/.eslintignore create mode 100644 packages/sdk38/.gitignore create mode 100644 packages/sdk38/README.md create mode 100755 packages/sdk38/jasmine-testrunner.js create mode 100644 packages/sdk38/karma.conf.js create mode 100644 packages/sdk38/nonces/README.txt create mode 100644 packages/sdk38/package.json create mode 100644 packages/sdk38/src/address.spec.ts create mode 100644 packages/sdk38/src/address.ts create mode 100644 packages/sdk38/src/coins.ts create mode 100644 packages/sdk38/src/cosmosclient.searchtx.spec.ts create mode 100644 packages/sdk38/src/cosmosclient.spec.ts create mode 100644 packages/sdk38/src/cosmosclient.ts create mode 100644 packages/sdk38/src/decoding.ts create mode 100644 packages/sdk38/src/encoding.spec.ts create mode 100644 packages/sdk38/src/encoding.ts create mode 100644 packages/sdk38/src/index.ts create mode 100644 packages/sdk38/src/logs.spec.ts create mode 100644 packages/sdk38/src/logs.ts create mode 100644 packages/sdk38/src/pen.spec.ts create mode 100644 packages/sdk38/src/pen.ts create mode 100644 packages/sdk38/src/pubkey.spec.ts create mode 100644 packages/sdk38/src/pubkey.ts create mode 100644 packages/sdk38/src/restclient.spec.ts create mode 100644 packages/sdk38/src/restclient.ts create mode 100644 packages/sdk38/src/sequence.spec.ts create mode 100644 packages/sdk38/src/sequence.ts create mode 100644 packages/sdk38/src/signature.spec.ts create mode 100644 packages/sdk38/src/signature.ts create mode 100644 packages/sdk38/src/signingcosmosclient.spec.ts create mode 100644 packages/sdk38/src/signingcosmosclient.ts create mode 100644 packages/sdk38/src/testdata/cosmoshub.json create mode 100644 packages/sdk38/src/testdata/txresponse1.json create mode 100644 packages/sdk38/src/testdata/txresponse2.json create mode 100644 packages/sdk38/src/testdata/txresponse3.json create mode 100644 packages/sdk38/src/testutils.spec.ts create mode 100644 packages/sdk38/src/types.ts create mode 100644 packages/sdk38/tsconfig.json create mode 100644 packages/sdk38/typedoc.js create mode 100644 packages/sdk38/types/address.d.ts create mode 100644 packages/sdk38/types/coins.d.ts create mode 100644 packages/sdk38/types/cosmosclient.d.ts create mode 100644 packages/sdk38/types/decoding.d.ts create mode 100644 packages/sdk38/types/encoding.d.ts create mode 100644 packages/sdk38/types/index.d.ts create mode 100644 packages/sdk38/types/logs.d.ts create mode 100644 packages/sdk38/types/pen.d.ts create mode 100644 packages/sdk38/types/pubkey.d.ts create mode 100644 packages/sdk38/types/restclient.d.ts create mode 100644 packages/sdk38/types/sequence.d.ts create mode 100644 packages/sdk38/types/signature.d.ts create mode 100644 packages/sdk38/types/signingcosmosclient.d.ts create mode 100644 packages/sdk38/types/types.d.ts create mode 100644 packages/sdk38/webpack.web.config.js diff --git a/packages/sdk38/.eslintignore b/packages/sdk38/.eslintignore new file mode 120000 index 00000000..86039baf --- /dev/null +++ b/packages/sdk38/.eslintignore @@ -0,0 +1 @@ +../../.eslintignore \ No newline at end of file diff --git a/packages/sdk38/.gitignore b/packages/sdk38/.gitignore new file mode 100644 index 00000000..68bf3735 --- /dev/null +++ b/packages/sdk38/.gitignore @@ -0,0 +1,3 @@ +build/ +dist/ +docs/ diff --git a/packages/sdk38/README.md b/packages/sdk38/README.md new file mode 100644 index 00000000..3a0e693e --- /dev/null +++ b/packages/sdk38/README.md @@ -0,0 +1,12 @@ +# @cosmwasm/sdk38 + +[![npm version](https://img.shields.io/npm/v/@cosmwasm/sdk38.svg)](https://www.npmjs.com/package/@cosmwasm/sdk38) + +A client library for the Cosmos SDK 0.38. + +## License + +This package is part of the cosmwasm-js repository, licensed under the Apache +License 2.0 (see +[NOTICE](https://github.com/confio/cosmwasm-js/blob/master/NOTICE) and +[LICENSE](https://github.com/confio/cosmwasm-js/blob/master/LICENSE)). diff --git a/packages/sdk38/jasmine-testrunner.js b/packages/sdk38/jasmine-testrunner.js new file mode 100755 index 00000000..9fada59b --- /dev/null +++ b/packages/sdk38/jasmine-testrunner.js @@ -0,0 +1,26 @@ +#!/usr/bin/env node + +require("source-map-support").install(); +const defaultSpecReporterConfig = require("../../jasmine-spec-reporter.config.json"); + +// setup Jasmine +const Jasmine = require("jasmine"); +const jasmine = new Jasmine(); +jasmine.loadConfig({ + spec_dir: "build", + spec_files: ["**/*.spec.js"], + helpers: [], + random: false, + seed: null, + stopSpecOnExpectationFailure: false, +}); +jasmine.jasmine.DEFAULT_TIMEOUT_INTERVAL = 15 * 1000; + +// setup reporter +const { SpecReporter } = require("jasmine-spec-reporter"); +const reporter = new SpecReporter({ ...defaultSpecReporterConfig }); + +// initialize and execute +jasmine.env.clearReporters(); +jasmine.addReporter(reporter); +jasmine.execute(); diff --git a/packages/sdk38/karma.conf.js b/packages/sdk38/karma.conf.js new file mode 100644 index 00000000..e68db403 --- /dev/null +++ b/packages/sdk38/karma.conf.js @@ -0,0 +1,54 @@ +module.exports = function (config) { + config.set({ + // base path that will be used to resolve all patterns (eg. files, exclude) + basePath: ".", + + // frameworks to use + // available frameworks: https://npmjs.org/browse/keyword/karma-adapter + frameworks: ["jasmine"], + + // list of files / patterns to load in the browser + files: ["dist/web/tests.js"], + + client: { + jasmine: { + random: false, + timeoutInterval: 15000, + }, + }, + + // test results reporter to use + // possible values: 'dots', 'progress' + // available reporters: https://npmjs.org/browse/keyword/karma-reporter + reporters: ["progress", "kjhtml"], + + // web server port + port: 9876, + + // enable / disable colors in the output (reporters and logs) + colors: true, + + // level of logging + // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG + logLevel: config.LOG_INFO, + + // enable / disable watching file and executing tests whenever any file changes + autoWatch: false, + + // start these browsers + // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher + browsers: ["Firefox"], + + browserNoActivityTimeout: 90000, + + // Keep brower open for debugging. This is overridden by yarn scripts + singleRun: false, + + customLaunchers: { + ChromeHeadlessInsecure: { + base: "ChromeHeadless", + flags: ["--disable-web-security"], + }, + }, + }); +}; diff --git a/packages/sdk38/nonces/README.txt b/packages/sdk38/nonces/README.txt new file mode 100644 index 00000000..092fe732 --- /dev/null +++ b/packages/sdk38/nonces/README.txt @@ -0,0 +1 @@ +Directory used to trigger lerna package updates for all packages diff --git a/packages/sdk38/package.json b/packages/sdk38/package.json new file mode 100644 index 00000000..9b91856d --- /dev/null +++ b/packages/sdk38/package.json @@ -0,0 +1,48 @@ +{ + "name": "@cosmwasm/sdk38", + "version": "0.8.0", + "description": "Utilities for Cosmos SDK 0.38", + "author": "Ethan Frey ", + "license": "Apache-2.0", + "main": "build/index.js", + "types": "types/index.d.ts", + "files": [ + "build/", + "types/", + "*.md", + "!*.spec.*", + "!**/testdata/" + ], + "repository": { + "type": "git", + "url": "https://github.com/CosmWasm/cosmwasm-js/tree/master/packages/sdk38" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "docs": "shx rm -rf docs && typedoc --options typedoc.js", + "format": "prettier --write --loglevel warn \"./src/**/*.ts\"", + "lint": "eslint --max-warnings 0 \"**/*.{js,ts}\"", + "lint-fix": "eslint --max-warnings 0 \"**/*.{js,ts}\" --fix", + "move-types": "shx rm -rf ./types/* && shx mv build/types/* ./types && rm -rf ./types/testdata && shx rm -f ./types/*.spec.d.ts", + "format-types": "prettier --write --loglevel warn \"./types/**/*.d.ts\"", + "build": "shx rm -rf ./build && tsc && yarn move-types && yarn format-types", + "build-or-skip": "[ -n \"$SKIP_BUILD\" ] || yarn build", + "test-node": "node jasmine-testrunner.js", + "test-firefox": "yarn pack-web && karma start --single-run --browsers Firefox", + "test-chrome": "yarn pack-web && karma start --single-run --browsers ChromeHeadlessInsecure", + "test": "yarn build-or-skip && yarn test-node", + "pack-web": "yarn build-or-skip && webpack --mode development --config webpack.web.config.js" + }, + "dependencies": { + "@iov/crypto": "^2.1.0", + "@iov/encoding": "^2.1.0", + "@iov/utils": "^2.0.2", + "axios": "^0.19.0", + "fast-deep-equal": "^3.1.1" + }, + "devDependencies": { + "readonly-date": "^1.0.0" + } +} diff --git a/packages/sdk38/src/address.spec.ts b/packages/sdk38/src/address.spec.ts new file mode 100644 index 00000000..713ffd03 --- /dev/null +++ b/packages/sdk38/src/address.spec.ts @@ -0,0 +1,27 @@ +import { Encoding } from "@iov/encoding"; + +import { pubkeyToAddress } from "./address"; + +const { toBase64, fromHex } = Encoding; + +describe("address", () => { + describe("pubkeyToAddress", () => { + it("works for Secp256k1 compressed", () => { + const prefix = "cosmos"; + const pubkey = { + type: "tendermint/PubKeySecp256k1", + value: "AtQaCqFnshaZQp6rIkvAPyzThvCvXSDO+9AzbxVErqJP", + }; + expect(pubkeyToAddress(pubkey, prefix)).toEqual("cosmos1h806c7khnvmjlywdrkdgk2vrayy2mmvf9rxk2r"); + }); + + it("works for Ed25519", () => { + const prefix = "cosmos"; + const pubkey = { + type: "tendermint/PubKeyEd25519", + value: toBase64(fromHex("12ee6f581fe55673a1e9e1382a0829e32075a0aa4763c968bc526e1852e78c95")), + }; + expect(pubkeyToAddress(pubkey, prefix)).toEqual("cosmos1pfq05em6sfkls66ut4m2257p7qwlk448h8mysz"); + }); + }); +}); diff --git a/packages/sdk38/src/address.ts b/packages/sdk38/src/address.ts new file mode 100644 index 00000000..126d8fb8 --- /dev/null +++ b/packages/sdk38/src/address.ts @@ -0,0 +1,42 @@ +import { Ripemd160, Sha256 } from "@iov/crypto"; +import { Bech32, Encoding } from "@iov/encoding"; + +import { PubKey, pubkeyType } from "./types"; + +const { fromBase64 } = Encoding; + +export function rawSecp256k1PubkeyToAddress(pubkeyRaw: Uint8Array, prefix: string): string { + if (pubkeyRaw.length !== 33) { + throw new Error(`Invalid Secp256k1 pubkey length (compressed): ${pubkeyRaw.length}`); + } + const hash1 = new Sha256(pubkeyRaw).digest(); + const hash2 = new Ripemd160(hash1).digest(); + return Bech32.encode(prefix, hash2); +} + +// See https://github.com/tendermint/tendermint/blob/f2ada0a604b4c0763bda2f64fac53d506d3beca7/docs/spec/blockchain/encoding.md#public-key-cryptography +// This assumes we already have a cosmos-compressed pubkey +export function pubkeyToAddress(pubkey: PubKey, prefix: string): string { + const pubkeyBytes = fromBase64(pubkey.value); + switch (pubkey.type) { + case pubkeyType.secp256k1: { + return rawSecp256k1PubkeyToAddress(pubkeyBytes, prefix); + } + case pubkeyType.ed25519: { + if (pubkeyBytes.length !== 32) { + throw new Error(`Invalid Ed25519 pubkey length: ${pubkeyBytes.length}`); + } + const hash = new Sha256(pubkeyBytes).digest(); + return Bech32.encode(prefix, hash.slice(0, 20)); + } + case pubkeyType.sr25519: { + if (pubkeyBytes.length !== 32) { + throw new Error(`Invalid Sr25519 pubkey length: ${pubkeyBytes.length}`); + } + const hash = new Sha256(pubkeyBytes).digest(); + return Bech32.encode(prefix, hash.slice(0, 20)); + } + default: + throw new Error("Unrecognized public key algorithm"); + } +} diff --git a/packages/sdk38/src/coins.ts b/packages/sdk38/src/coins.ts new file mode 100644 index 00000000..62f5f9ce --- /dev/null +++ b/packages/sdk38/src/coins.ts @@ -0,0 +1,14 @@ +export interface Coin { + readonly denom: string; + readonly amount: string; +} + +/** Creates a coin */ +export function coin(amount: number, denom: string): Coin { + return { amount: amount.toString(), denom: denom }; +} + +/** Creates a list of coins with one element */ +export function coins(amount: number, denom: string): Coin[] { + return [coin(amount, denom)]; +} diff --git a/packages/sdk38/src/cosmosclient.searchtx.spec.ts b/packages/sdk38/src/cosmosclient.searchtx.spec.ts new file mode 100644 index 00000000..96fe3040 --- /dev/null +++ b/packages/sdk38/src/cosmosclient.searchtx.spec.ts @@ -0,0 +1,366 @@ +/* eslint-disable @typescript-eslint/camelcase */ +import { assert, sleep } from "@iov/utils"; + +import { Coin } from "./coins"; +import { CosmosClient } from "./cosmosclient"; +import { makeSignBytes } from "./encoding"; +import { Secp256k1Pen } from "./pen"; +import { RestClient } from "./restclient"; +import { SigningCosmosClient } from "./signingcosmosclient"; +import { + faucet, + fromOneElementArray, + makeRandomAddress, + pendingWithoutWasmd, + wasmd, + wasmdEnabled, +} from "./testutils.spec"; +import { CosmosSdkTx, isMsgSend, MsgSend } from "./types"; + +describe("CosmosClient.searchTx", () => { + let sendSuccessful: + | { + readonly sender: string; + readonly recipient: string; + readonly hash: string; + readonly height: number; + readonly tx: CosmosSdkTx; + } + | undefined; + let sendUnsuccessful: + | { + readonly sender: string; + readonly recipient: string; + readonly hash: string; + readonly height: number; + readonly tx: CosmosSdkTx; + } + | undefined; + + beforeAll(async () => { + if (wasmdEnabled()) { + const pen = await Secp256k1Pen.fromMnemonic(faucet.mnemonic); + const client = new SigningCosmosClient(wasmd.endpoint, faucet.address, (signBytes) => + pen.sign(signBytes), + ); + + { + const recipient = makeRandomAddress(); + const transferAmount: Coin = { + denom: "ucosm", + amount: "1234567", + }; + const result = await client.sendTokens(recipient, [transferAmount]); + await sleep(50); // wait until tx is indexed + const txDetails = await new RestClient(wasmd.endpoint).txById(result.transactionHash); + sendSuccessful = { + sender: faucet.address, + recipient: recipient, + hash: result.transactionHash, + height: Number.parseInt(txDetails.height, 10), + tx: txDetails.tx, + }; + } + + { + const memo = "Sending more than I can afford"; + const recipient = makeRandomAddress(); + const transferAmount = [ + { + denom: "ucosm", + amount: "123456700000000", + }, + ]; + 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: recipient, + amount: transferAmount, + }, + }; + const fee = { + amount: [ + { + denom: "ucosm", + amount: "2000", + }, + ], + gas: "80000", // 80k + }; + const { accountNumber, sequence } = await client.getNonce(); + const chainId = await client.getChainId(); + const signBytes = makeSignBytes([sendMsg], fee, chainId, memo, accountNumber, sequence); + const signature = await pen.sign(signBytes); + const tx: CosmosSdkTx = { + type: "cosmos-sdk/StdTx", + value: { + msg: [sendMsg], + fee: fee, + memo: memo, + signatures: [signature], + }, + }; + const transactionId = await client.getIdentifier(tx); + const heightBeforeThis = await client.getHeight(); + try { + await client.postTx(tx.value); + } catch (error) { + // postTx() throws on execution failures, which is a questionable design. Ignore for now. + // console.log(error); + } + sendUnsuccessful = { + sender: faucet.address, + recipient: recipient, + hash: transactionId, + height: heightBeforeThis + 1, + tx: tx, + }; + } + } + }); + + describe("with SearchByIdQuery", () => { + it("can search successful tx by ID", async () => { + pendingWithoutWasmd(); + assert(sendSuccessful, "value must be set in beforeAll()"); + const client = new CosmosClient(wasmd.endpoint); + 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 () => { + pendingWithoutWasmd(); + assert(sendUnsuccessful, "value must be set in beforeAll()"); + const client = new CosmosClient(wasmd.endpoint); + 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 () => { + pendingWithoutWasmd(); + const client = new CosmosClient(wasmd.endpoint); + 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 () => { + pendingWithoutWasmd(); + assert(sendSuccessful); + const client = new CosmosClient(wasmd.endpoint); + 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 () => { + pendingWithoutWasmd(); + assert(sendSuccessful, "value must be set in beforeAll()"); + const client = new CosmosClient(wasmd.endpoint); + const result = await client.searchTx({ height: sendSuccessful.height }); + 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 height", async () => { + pendingWithoutWasmd(); + assert(sendUnsuccessful, "value must be set in beforeAll()"); + const client = new CosmosClient(wasmd.endpoint); + const result = await client.searchTx({ height: sendUnsuccessful.height }); + expect(result.length).toEqual(1); + expect(result[0]).toEqual( + jasmine.objectContaining({ + height: sendUnsuccessful.height, + hash: sendUnsuccessful.hash, + code: 5, + tx: sendUnsuccessful.tx, + }), + ); + }); + }); + + describe("with SearchBySentFromOrToQuery", () => { + it("can search by sender", async () => { + pendingWithoutWasmd(); + assert(sendSuccessful, "value must be set in beforeAll()"); + const client = new CosmosClient(wasmd.endpoint); + const results = await client.searchTx({ sentFromOrTo: sendSuccessful.sender }); + expect(results.length).toBeGreaterThanOrEqual(1); + + // Check basic structure of all results + for (const result of results) { + const containsMsgWithSender = !!result.tx.value.msg.find( + (msg) => isMsgSend(msg) && msg.value.from_address == sendSuccessful!.sender, + ); + const containsMsgWithRecipient = !!result.tx.value.msg.find( + (msg) => isMsgSend(msg) && msg.value.to_address === sendSuccessful!.sender, + ); + expect(containsMsgWithSender || containsMsgWithRecipient).toEqual(true); + } + + // Check details of most recent result + expect(results[results.length - 1]).toEqual( + jasmine.objectContaining({ + height: sendSuccessful.height, + hash: sendSuccessful.hash, + tx: sendSuccessful.tx, + }), + ); + }); + + it("can search by recipient", async () => { + pendingWithoutWasmd(); + assert(sendSuccessful, "value must be set in beforeAll()"); + const client = new CosmosClient(wasmd.endpoint); + const results = await client.searchTx({ sentFromOrTo: sendSuccessful.recipient }); + expect(results.length).toBeGreaterThanOrEqual(1); + + // Check basic structure of all results + for (const result of results) { + const msg = fromOneElementArray(result.tx.value.msg); + assert(isMsgSend(msg), `${result.hash} (height ${result.height}) is not a bank send transaction`); + expect( + msg.value.to_address === sendSuccessful.recipient || + msg.value.from_address == sendSuccessful.recipient, + ).toEqual(true); + } + + // Check details of most recent result + expect(results[results.length - 1]).toEqual( + jasmine.objectContaining({ + height: sendSuccessful.height, + hash: sendSuccessful.hash, + tx: sendSuccessful.tx, + }), + ); + }); + + it("can search by recipient and filter by minHeight", async () => { + pendingWithoutWasmd(); + assert(sendSuccessful); + const client = new CosmosClient(wasmd.endpoint); + const query = { sentFromOrTo: sendSuccessful.recipient }; + + { + 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); + } + }); + + it("can search by recipient and filter by maxHeight", async () => { + pendingWithoutWasmd(); + assert(sendSuccessful); + const client = new CosmosClient(wasmd.endpoint); + const query = { sentFromOrTo: sendSuccessful.recipient }; + + { + const result = await client.searchTx(query, { maxHeight: 9999999999999 }); + expect(result.length).toEqual(1); + } + + { + const result = await client.searchTx(query, { maxHeight: sendSuccessful.height + 1 }); + expect(result.length).toEqual(1); + } + + { + const result = await client.searchTx(query, { maxHeight: sendSuccessful.height }); + expect(result.length).toEqual(1); + } + + { + const result = await client.searchTx(query, { maxHeight: sendSuccessful.height - 1 }); + expect(result.length).toEqual(0); + } + }); + }); + + describe("with SearchByTagsQuery", () => { + it("can search by transfer.recipient", async () => { + pendingWithoutWasmd(); + assert(sendSuccessful, "value must be set in beforeAll()"); + const client = new CosmosClient(wasmd.endpoint); + const results = await client.searchTx({ + tags: [{ key: "transfer.recipient", value: sendSuccessful.recipient }], + }); + expect(results.length).toBeGreaterThanOrEqual(1); + + // Check basic structure of all results + for (const result of results) { + const msg = fromOneElementArray(result.tx.value.msg); + assert(isMsgSend(msg), `${result.hash} (height ${result.height}) is not a bank send transaction`); + expect(msg.value.to_address).toEqual(sendSuccessful.recipient); + } + + // Check details of most recent result + expect(results[results.length - 1]).toEqual( + jasmine.objectContaining({ + height: sendSuccessful.height, + hash: sendSuccessful.hash, + tx: sendSuccessful.tx, + }), + ); + }); + }); +}); diff --git a/packages/sdk38/src/cosmosclient.spec.ts b/packages/sdk38/src/cosmosclient.spec.ts new file mode 100644 index 00000000..28d57a6a --- /dev/null +++ b/packages/sdk38/src/cosmosclient.spec.ts @@ -0,0 +1,296 @@ +/* eslint-disable @typescript-eslint/camelcase */ +import { sleep } from "@iov/utils"; +import { ReadonlyDate } from "readonly-date"; + +import { CosmosClient, PrivateCosmWasmClient } from "./cosmosclient"; +import { makeSignBytes } from "./encoding"; +import { findAttribute } from "./logs"; +import { Secp256k1Pen } from "./pen"; +import cosmoshub from "./testdata/cosmoshub.json"; +import { + faucet, + makeRandomAddress, + pendingWithoutWasmd, + tendermintIdMatcher, + unused, + wasmd, +} from "./testutils.spec"; +import { MsgSend, StdFee } from "./types"; + +const guest = { + address: "cosmos17d0jcz59jf68g52vq38tuuncmwwjk42u6mcxej", +}; + +describe("CosmosClient", () => { + describe("constructor", () => { + it("can be constructed", () => { + const client = new CosmosClient(wasmd.endpoint); + expect(client).toBeTruthy(); + }); + }); + + describe("getChainId", () => { + it("works", async () => { + pendingWithoutWasmd(); + const client = new CosmosClient(wasmd.endpoint); + expect(await client.getChainId()).toEqual(wasmd.chainId); + }); + + it("caches chain ID", async () => { + pendingWithoutWasmd(); + const client = new CosmosClient(wasmd.endpoint); + const openedClient = (client as unknown) as PrivateCosmWasmClient; + const getCodeSpy = spyOn(openedClient.restClient, "nodeInfo").and.callThrough(); + + expect(await client.getChainId()).toEqual(wasmd.chainId); // from network + expect(await client.getChainId()).toEqual(wasmd.chainId); // from cache + + expect(getCodeSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe("getHeight", () => { + it("gets height via last block", async () => { + pendingWithoutWasmd(); + const client = new CosmosClient(wasmd.endpoint); + const openedClient = (client as unknown) as PrivateCosmWasmClient; + const blockLatestSpy = spyOn(openedClient.restClient, "blocksLatest").and.callThrough(); + + const height1 = await client.getHeight(); + expect(height1).toBeGreaterThan(0); + await sleep(1_000); + const height2 = await client.getHeight(); + expect(height2).toEqual(height1 + 1); + + expect(blockLatestSpy).toHaveBeenCalledTimes(2); + }); + + it("gets height via authAccount once an address is known", async () => { + pendingWithoutWasmd(); + const client = new CosmosClient(wasmd.endpoint); + + const openedClient = (client as unknown) as PrivateCosmWasmClient; + const blockLatestSpy = spyOn(openedClient.restClient, "blocksLatest").and.callThrough(); + const authAccountsSpy = spyOn(openedClient.restClient, "authAccounts").and.callThrough(); + + const height1 = await client.getHeight(); + expect(height1).toBeGreaterThan(0); + + await client.getCodes(); // warm up the client + + const height2 = await client.getHeight(); + expect(height2).toBeGreaterThan(0); + await sleep(1_000); + const height3 = await client.getHeight(); + expect(height3).toEqual(height2 + 1); + + expect(blockLatestSpy).toHaveBeenCalledTimes(1); + expect(authAccountsSpy).toHaveBeenCalledTimes(2); + }); + }); + + describe("getNonce", () => { + it("works", async () => { + pendingWithoutWasmd(); + const client = new CosmosClient(wasmd.endpoint); + expect(await client.getNonce(unused.address)).toEqual({ + accountNumber: unused.accountNumber, + sequence: unused.sequence, + }); + }); + + it("throws for missing accounts", async () => { + pendingWithoutWasmd(); + const client = new CosmosClient(wasmd.endpoint); + const missing = makeRandomAddress(); + await client.getNonce(missing).then( + () => fail("this must not succeed"), + (error) => expect(error).toMatch(/account does not exist on chain/i), + ); + }); + }); + + describe("getAccount", () => { + it("works", async () => { + pendingWithoutWasmd(); + const client = new CosmosClient(wasmd.endpoint); + expect(await client.getAccount(unused.address)).toEqual({ + address: unused.address, + accountNumber: unused.accountNumber, + sequence: unused.sequence, + pubkey: undefined, + balance: [ + { denom: "ucosm", amount: "1000000000" }, + { denom: "ustake", amount: "1000000000" }, + ], + }); + }); + + it("returns undefined for missing accounts", async () => { + pendingWithoutWasmd(); + const client = new CosmosClient(wasmd.endpoint); + const missing = makeRandomAddress(); + expect(await client.getAccount(missing)).toBeUndefined(); + }); + }); + + describe("getBlock", () => { + it("works for latest block", async () => { + pendingWithoutWasmd(); + const client = new CosmosClient(wasmd.endpoint); + const response = await client.getBlock(); + + // id + expect(response.id).toMatch(tendermintIdMatcher); + + // header + expect(response.header.height).toBeGreaterThanOrEqual(1); + expect(response.header.chainId).toEqual(await client.getChainId()); + expect(new ReadonlyDate(response.header.time).getTime()).toBeLessThan(ReadonlyDate.now()); + expect(new ReadonlyDate(response.header.time).getTime()).toBeGreaterThanOrEqual( + ReadonlyDate.now() - 5_000, + ); + + // txs + expect(Array.isArray(response.txs)).toEqual(true); + }); + + it("works for block by height", async () => { + pendingWithoutWasmd(); + const client = new CosmosClient(wasmd.endpoint); + const height = (await client.getBlock()).header.height; + const response = await client.getBlock(height - 1); + + // id + expect(response.id).toMatch(tendermintIdMatcher); + + // header + expect(response.header.height).toEqual(height - 1); + expect(response.header.chainId).toEqual(await client.getChainId()); + expect(new ReadonlyDate(response.header.time).getTime()).toBeLessThan(ReadonlyDate.now()); + expect(new ReadonlyDate(response.header.time).getTime()).toBeGreaterThanOrEqual( + ReadonlyDate.now() - 5_000, + ); + + // txs + expect(Array.isArray(response.txs)).toEqual(true); + }); + }); + + describe("getIdentifier", () => { + it("works", async () => { + pendingWithoutWasmd(); + const client = new CosmosClient(wasmd.endpoint); + expect(await client.getIdentifier(cosmoshub.tx)).toEqual(cosmoshub.id); + }); + }); + + describe("postTx", () => { + it("works", async () => { + pendingWithoutWasmd(); + const pen = await Secp256k1Pen.fromMnemonic(faucet.mnemonic); + const client = new CosmosClient(wasmd.endpoint); + + const memo = "My first contract on chain"; + const sendMsg: MsgSend = { + type: "cosmos-sdk/MsgSend", + value: { + from_address: faucet.address, + to_address: makeRandomAddress(), + amount: [ + { + denom: "ucosm", + amount: "1234567", + }, + ], + }, + }; + + const fee: StdFee = { + amount: [ + { + amount: "5000", + denom: "ucosm", + }, + ], + gas: "890000", + }; + + const chainId = await client.getChainId(); + 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 { logs, transactionHash } = await client.postTx(signedTx); + const amountAttr = findAttribute(logs, "transfer", "amount"); + expect(amountAttr.value).toEqual("1234567ucosm"); + expect(transactionHash).toMatch(/^[0-9A-F]{64}$/); + }); + }); + + describe("getContracts", () => { + it("works", async () => { + pendingWithoutWasmd(); + const client = new CosmosClient(wasmd.endpoint); + const result = await client.getContracts(1); + expect(result.length).toBeGreaterThanOrEqual(3); + const [hash, isa, jade] = result; + expect(hash).toEqual({ + address: "cosmos18vd8fpwxzck93qlwghaj6arh4p7c5n89uzcee5", + codeId: 1, + creator: faucet.address, + label: "HASH", + }); + expect(isa).toEqual({ + address: "cosmos1hqrdl6wstt8qzshwc6mrumpjk9338k0lr4dqxd", + codeId: 1, + creator: faucet.address, + label: "ISA", + }); + expect(jade).toEqual({ + address: "cosmos18r5szma8hm93pvx6lwpjwyxruw27e0k5uw835c", + codeId: 1, + creator: faucet.address, + label: "JADE", + }); + }); + }); + + describe("getContract", () => { + it("works for HASH instance", async () => { + pendingWithoutWasmd(); + const client = new CosmosClient(wasmd.endpoint); + const hash = await client.getContract("cosmos18vd8fpwxzck93qlwghaj6arh4p7c5n89uzcee5"); + expect(hash).toEqual({ + address: "cosmos18vd8fpwxzck93qlwghaj6arh4p7c5n89uzcee5", + codeId: 1, + creator: faucet.address, + label: "HASH", + initMsg: { + decimals: 5, + name: "Hash token", + symbol: "HASH", + initial_balances: jasmine.arrayContaining([ + { + address: faucet.address, + amount: "11", + }, + { + address: unused.address, + amount: "12812345", + }, + { + address: guest.address, + amount: "22004000000", + }, + ]), + }, + }); + }); + }); +}); diff --git a/packages/sdk38/src/cosmosclient.ts b/packages/sdk38/src/cosmosclient.ts new file mode 100644 index 00000000..7960c82d --- /dev/null +++ b/packages/sdk38/src/cosmosclient.ts @@ -0,0 +1,439 @@ +import { Sha256 } from "@iov/crypto"; +import { Encoding } from "@iov/encoding"; + +import { Coin } from "./coins"; +import { Log, parseLogs } from "./logs"; +import { decodeBech32Pubkey } from "./pubkey"; +import { BroadcastMode, RestClient } from "./restclient"; +import { CosmosSdkTx, JsonObject, PubKey, StdTx } from "./types"; + +export interface GetNonceResult { + readonly accountNumber: number; + readonly sequence: number; +} + +export interface Account { + /** Bech32 account address */ + readonly address: string; + readonly balance: ReadonlyArray; + readonly pubkey: PubKey | undefined; + readonly accountNumber: number; + readonly sequence: number; +} + +export interface PostTxResult { + readonly logs: readonly Log[]; + readonly rawLog: string; + /** Transaction hash (might be used as transaction ID). Guaranteed to be non-empty upper-case hex */ + readonly transactionHash: string; +} + +export interface SearchByIdQuery { + readonly id: string; +} + +export interface SearchByHeightQuery { + readonly height: number; +} + +export interface SearchBySentFromOrToQuery { + readonly sentFromOrTo: string; +} + +/** + * This query type allows you to pass arbitrary key/value pairs to the backend. It is + * more powerful and slightly lower level than the other search options. + */ +export interface SearchByTagsQuery { + readonly tags: readonly { readonly key: string; readonly value: string }[]; +} + +export type SearchTxQuery = + | SearchByIdQuery + | SearchByHeightQuery + | SearchBySentFromOrToQuery + | SearchByTagsQuery; + +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; +} + +function isSearchByTagsQuery(query: SearchTxQuery): query is SearchByTagsQuery { + return (query as SearchByTagsQuery).tags !== undefined; +} + +export interface SearchTxFilter { + readonly minHeight?: number; + readonly maxHeight?: number; +} + +export interface Code { + readonly id: number; + /** Bech32 account address */ + readonly creator: string; + /** Hex-encoded sha256 hash of the code stored here */ + readonly checksum: string; + readonly source?: string; + readonly builder?: string; +} + +export interface CodeDetails extends Code { + /** The original wasm bytes */ + readonly data: Uint8Array; +} + +export interface Contract { + readonly address: string; + readonly codeId: number; + /** Bech32 account address */ + readonly creator: string; + readonly label: string; +} + +export interface ContractDetails extends Contract { + /** Argument passed on initialization of the contract */ + readonly initMsg: object; +} + +/** 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 logs: readonly Log[]; + readonly tx: CosmosSdkTx; + /** The gas limit as set by the user */ + readonly gasWanted?: number; + /** The gas used by the execution */ + readonly gasUsed?: number; + /** An RFC 3339 time string like e.g. '2020-02-15T10:39:10.4696305Z' */ + readonly timestamp: string; +} + +export interface BlockHeader { + readonly version: { + readonly block: string; + readonly app: string; + }; + readonly height: number; + readonly chainId: string; + /** An RFC 3339 time string like e.g. '2020-02-15T10:39:10.4696305Z' */ + readonly time: string; +} + +export interface Block { + /** The ID is a hash of the block header (uppercase hex) */ + readonly id: string; + readonly header: BlockHeader; + /** Array of raw transactions */ + readonly txs: ReadonlyArray; +} + +/** Use for testing only */ +export interface PrivateCosmWasmClient { + readonly restClient: RestClient; +} + +export class CosmosClient { + protected readonly restClient: RestClient; + /** Any address the chain considers valid (valid bech32 with proper prefix) */ + protected anyValidAddress: string | undefined; + + private readonly codesCache = new Map(); + private chainId: string | undefined; + + /** + * Creates a new client to interact with a CosmWasm blockchain. + * + * This instance does a lot of caching. In order to benefit from that you should try to use one instance + * for the lifetime of your application. When switching backends, a new instance must be created. + * + * @param apiUrl The URL of a Cosmos SDK light client daemon API (sometimes called REST server or REST API) + * @param broadcastMode Defines at which point of the transaction processing the postTx method (i.e. transaction broadcasting) returns + */ + public constructor(apiUrl: string, broadcastMode = BroadcastMode.Block) { + this.restClient = new RestClient(apiUrl, broadcastMode); + } + + public async getChainId(): Promise { + if (!this.chainId) { + const response = await this.restClient.nodeInfo(); + const chainId = response.node_info.network; + if (!chainId) throw new Error("Chain ID must not be empty"); + this.chainId = chainId; + } + + return this.chainId; + } + + public async getHeight(): Promise { + if (this.anyValidAddress) { + const { height } = await this.restClient.authAccounts(this.anyValidAddress); + return parseInt(height, 10); + } else { + // Note: this gets inefficient when blocks contain a lot of transactions since it + // requires downloading and deserializing all transactions in the block. + const latest = await this.restClient.blocksLatest(); + return parseInt(latest.block.header.height, 10); + } + } + + /** + * Returns a 32 byte upper-case hex transaction hash (typically used as the transaction ID) + */ + public async getIdentifier(tx: CosmosSdkTx): Promise { + // We consult the REST API because we don't have a local amino encoder + const bytes = await this.restClient.encodeTx(tx); + const hash = new Sha256(bytes).digest(); + return Encoding.toHex(hash).toUpperCase(); + } + + /** + * Returns account number and sequence. + * + * Throws if the account does not exist on chain. + * + * @param address returns data for this address. When unset, the client's sender adddress is used. + */ + public async getNonce(address: string): Promise { + const account = await this.getAccount(address); + if (!account) { + throw new Error( + "Account does not exist on chain. Send some tokens there before trying to query nonces.", + ); + } + return { + accountNumber: account.accountNumber, + sequence: account.sequence, + }; + } + + public async getAccount(address: string): Promise { + const account = await this.restClient.authAccounts(address); + const value = account.result.value; + if (value.address === "") { + return undefined; + } else { + this.anyValidAddress = value.address; + return { + address: value.address, + balance: value.coins, + pubkey: value.public_key ? decodeBech32Pubkey(value.public_key) : undefined, + accountNumber: value.account_number, + sequence: value.sequence, + }; + } + } + + /** + * Gets block header and meta + * + * @param height The height of the block. If undefined, the latest height is used. + */ + public async getBlock(height?: number): Promise { + const response = + height !== undefined ? await this.restClient.blocks(height) : await this.restClient.blocksLatest(); + + return { + id: response.block_id.hash, + header: { + version: response.block.header.version, + time: response.block.header.time, + height: parseInt(response.block.header.height, 10), + chainId: response.block.header.chain_id, + }, + txs: (response.block.data.txs || []).map((encoded) => Encoding.fromBase64(encoded)), + }; + } + + 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 + + function withFilters(originalQuery: string): string { + return `${originalQuery}&tx.minheight=${minHeight}&tx.maxheight=${maxHeight}`; + } + + let txs: readonly IndexedTx[]; + if (isSearchByIdQuery(query)) { + txs = await this.txsQuery(`tx.hash=${query.id}`); + } else if (isSearchByHeightQuery(query)) { + // optional optimization to avoid network request + if (query.height < minHeight || query.height > maxHeight) { + txs = []; + } else { + txs = await this.txsQuery(`tx.height=${query.height}`); + } + } else if (isSearchBySentFromOrToQuery(query)) { + // We cannot get both in one request (see https://github.com/cosmos/gaia/issues/75) + const sentQuery = withFilters(`message.module=bank&message.sender=${query.sentFromOrTo}`); + const receivedQuery = withFilters(`message.module=bank&transfer.recipient=${query.sentFromOrTo}`); + const sent = await this.txsQuery(sentQuery); + const received = await this.txsQuery(receivedQuery); + + const sentHashes = sent.map((t) => t.hash); + txs = [...sent, ...received.filter((t) => !sentHashes.includes(t.hash))]; + } else if (isSearchByTagsQuery(query)) { + const rawQuery = withFilters(query.tags.map((t) => `${t.key}=${t.value}`).join("&")); + txs = await this.txsQuery(rawQuery); + } else { + throw new Error("Unknown query type"); + } + + // backend sometimes messes up with min/max height filtering + const filtered = txs.filter((tx) => tx.height >= minHeight && tx.height <= maxHeight); + + return filtered; + } + + public async postTx(tx: StdTx): Promise { + const result = await this.restClient.postTx(tx); + if (!result.txhash.match(/^([0-9A-F][0-9A-F])+$/)) { + throw new Error("Received ill-formatted txhash. Must be non-empty upper-case hex"); + } + + if (result.code) { + throw new Error( + `Error when posting tx ${result.txhash}. Code: ${result.code}; Raw log: ${result.raw_log}`, + ); + } + + return { + logs: result.logs ? parseLogs(result.logs) : [], + rawLog: result.raw_log || "", + transactionHash: result.txhash, + }; + } + + public async getCodes(): Promise { + const result = await this.restClient.listCodeInfo(); + return result.map( + (entry): Code => { + this.anyValidAddress = entry.creator; + return { + id: entry.id, + creator: entry.creator, + checksum: Encoding.toHex(Encoding.fromHex(entry.data_hash)), + source: entry.source || undefined, + builder: entry.builder || undefined, + }; + }, + ); + } + + public async getCodeDetails(codeId: number): Promise { + const cached = this.codesCache.get(codeId); + if (cached) return cached; + + const getCodeResult = await this.restClient.getCode(codeId); + const codeDetails: CodeDetails = { + id: getCodeResult.id, + creator: getCodeResult.creator, + checksum: Encoding.toHex(Encoding.fromHex(getCodeResult.data_hash)), + source: getCodeResult.source || undefined, + builder: getCodeResult.builder || undefined, + data: Encoding.fromBase64(getCodeResult.data), + }; + this.codesCache.set(codeId, codeDetails); + return codeDetails; + } + + public async getContracts(codeId: number): Promise { + const result = await this.restClient.listContractsByCodeId(codeId); + return result.map( + (entry): Contract => ({ + address: entry.address, + codeId: entry.code_id, + creator: entry.creator, + label: entry.label, + }), + ); + } + + /** + * Throws an error if no contract was found at the address + */ + public async getContract(address: string): Promise { + const result = await this.restClient.getContractInfo(address); + if (!result) throw new Error(`No contract found at address "${address}"`); + return { + address: result.address, + codeId: result.code_id, + creator: result.creator, + label: result.label, + initMsg: result.init_msg, + }; + } + + /** + * Returns the data at the key if present (raw contract dependent storage data) + * or null if no data at this key. + * + * Promise is rejected when contract does not exist. + */ + public async queryContractRaw(address: string, key: Uint8Array): Promise { + // just test contract existence + const _info = await this.getContract(address); + + return this.restClient.queryContractRaw(address, key); + } + + /** + * Makes a smart query on the contract, returns the parsed JSON document. + * + * Promise is rejected when contract does not exist. + * Promise is rejected for invalid query format. + * Promise is rejected for invalid response format. + */ + public async queryContractSmart(address: string, queryMsg: object): Promise { + try { + return await this.restClient.queryContractSmart(address, queryMsg); + } catch (error) { + if (error instanceof Error) { + if (error.message.startsWith("not found: contract")) { + throw new Error(`No contract found at address "${address}"`); + } else { + throw error; + } + } else { + throw error; + } + } + } + + private async txsQuery(query: string): Promise { + // TODO: we need proper pagination support + const limit = 100; + const result = await this.restClient.txsQuery(`${query}&limit=${limit}`); + const pages = parseInt(result.page_total, 10); + if (pages > 1) { + throw new Error( + `Found more results on the backend than we can process currently. Results: ${result.total_count}, supported: ${limit}`, + ); + } + return result.txs.map( + (restItem): IndexedTx => ({ + height: parseInt(restItem.height, 10), + hash: restItem.txhash, + code: restItem.code || 0, + rawLog: restItem.raw_log, + logs: parseLogs(restItem.logs || []), + tx: restItem.tx, + timestamp: restItem.timestamp, + }), + ); + } +} diff --git a/packages/sdk38/src/decoding.ts b/packages/sdk38/src/decoding.ts new file mode 100644 index 00000000..78286018 --- /dev/null +++ b/packages/sdk38/src/decoding.ts @@ -0,0 +1,11 @@ +import { Encoding } from "@iov/encoding"; + +import { isStdTx, StdTx } from "./types"; + +export function unmarshalTx(data: Uint8Array): StdTx { + const decoded = JSON.parse(Encoding.fromUtf8(data)); + if (!isStdTx(decoded)) { + throw new Error("Must be json encoded StdTx"); + } + return decoded; +} diff --git a/packages/sdk38/src/encoding.spec.ts b/packages/sdk38/src/encoding.spec.ts new file mode 100644 index 00000000..ccdb8a4a --- /dev/null +++ b/packages/sdk38/src/encoding.spec.ts @@ -0,0 +1 @@ +describe("encoding", () => {}); diff --git a/packages/sdk38/src/encoding.ts b/packages/sdk38/src/encoding.ts new file mode 100644 index 00000000..8a782944 --- /dev/null +++ b/packages/sdk38/src/encoding.ts @@ -0,0 +1,59 @@ +import { Encoding } from "@iov/encoding"; + +import { Msg, StdFee, StdTx } from "./types"; + +const { toUtf8 } = Encoding; + +function sortJson(json: any): any { + if (typeof json !== "object" || json === null) { + return json; + } + if (Array.isArray(json)) { + return json.map(sortJson); + } + const sortedKeys = Object.keys(json).sort(); + const result = sortedKeys.reduce( + (accumulator, key) => ({ + ...accumulator, + [key]: sortJson(json[key]), + }), + {}, + ); + return result; +} + +export function marshalTx(tx: StdTx): Uint8Array { + const json = JSON.stringify(tx); + return Encoding.toUtf8(json); +} + +interface SignJson { + readonly account_number: string; + readonly chain_id: string; + readonly fee: StdFee; + readonly memo: string; + readonly msgs: readonly Msg[]; + readonly sequence: string; +} + +export function makeSignBytes( + msgs: readonly Msg[], + fee: StdFee, + chainId: string, + memo: string, + accountNumber: number, + sequence: number, +): Uint8Array { + const signJson: SignJson = { + // eslint-disable-next-line @typescript-eslint/camelcase + account_number: accountNumber.toString(), + // eslint-disable-next-line @typescript-eslint/camelcase + chain_id: chainId, + fee: fee, + memo: memo, + msgs: msgs, + sequence: sequence.toString(), + }; + const signMsg = sortJson(signJson); + return toUtf8(JSON.stringify(signMsg)); +} diff --git a/packages/sdk38/src/index.ts b/packages/sdk38/src/index.ts new file mode 100644 index 00000000..b17e620d --- /dev/null +++ b/packages/sdk38/src/index.ts @@ -0,0 +1,12 @@ +import * as logs from "./logs"; +import * as types from "./types"; +export { logs, types }; + +export { pubkeyToAddress } from "./address"; +export { Coin, coin, coins } from "./coins"; +export { unmarshalTx } from "./decoding"; +export { makeSignBytes, marshalTx } from "./encoding"; +export { BroadcastMode, RestClient, TxsResponse } from "./restclient"; +export { decodeBech32Pubkey, encodeBech32Pubkey, encodeSecp256k1Pubkey } from "./pubkey"; +export { findSequenceForSignedTx } from "./sequence"; +export { encodeSecp256k1Signature, decodeSignature } from "./signature"; diff --git a/packages/sdk38/src/logs.spec.ts b/packages/sdk38/src/logs.spec.ts new file mode 100644 index 00000000..584d9ef2 --- /dev/null +++ b/packages/sdk38/src/logs.spec.ts @@ -0,0 +1,165 @@ +/* eslint-disable @typescript-eslint/camelcase */ +import { parseAttribute, parseEvent, parseLog, parseLogs } from "./logs"; + +describe("logs", () => { + describe("parseAttribute", () => { + it("works", () => { + const attr = parseAttribute({ key: "a", value: "b" }); + expect(attr).toEqual({ key: "a", value: "b" }); + }); + + it("works for empty value", () => { + const attr = parseAttribute({ key: "foobar", value: "" }); + expect(attr).toEqual({ key: "foobar", value: "" }); + }); + + it("normalized unset value to empty string", () => { + const attr = parseAttribute({ key: "amount" }); + expect(attr).toEqual({ key: "amount", value: "" }); + }); + }); + + describe("parseEvent", () => { + it("works", () => { + const original = { + type: "message", + attributes: [ + { + key: "action", + value: "store-code", + }, + { + key: "module", + value: "wasm", + }, + { + key: "action", + value: "store-code", + }, + { + key: "sender", + value: "cosmos1pkptre7fdkl6gfrzlesjjvhxhlc3r4gmmk8rs6", + }, + { + key: "code_id", + value: "1", + }, + ], + } as const; + + const event = parseEvent(original); + expect(event).toEqual(original); + }); + + it("works for transfer event", () => { + const original = { + type: "transfer", + attributes: [ + { + key: "recipient", + value: "cosmos18vd8fpwxzck93qlwghaj6arh4p7c5n89uzcee5", + }, + { + key: "amount", + }, + ], + } as const; + const expected = { + type: "transfer", + attributes: [ + { + key: "recipient", + value: "cosmos18vd8fpwxzck93qlwghaj6arh4p7c5n89uzcee5", + }, + { + key: "amount", + value: "", + }, + ], + } as const; + + const event = parseEvent(original); + expect(event).toEqual(expected); + }); + }); + + describe("parseLog", () => { + it("works", () => { + const original = { + msg_index: 0, + log: "", + events: [ + { + type: "message", + attributes: [ + { + key: "action", + value: "store-code", + }, + { + key: "module", + value: "wasm", + }, + { + key: "action", + value: "store-code", + }, + { + key: "sender", + value: "cosmos1pkptre7fdkl6gfrzlesjjvhxhlc3r4gmmk8rs6", + }, + { + key: "code_id", + value: "1", + }, + ], + }, + ], + } as const; + + const log = parseLog(original); + expect(log).toEqual(original); + }); + }); + + describe("parseLogs", () => { + it("works", () => { + const original = [ + { + msg_index: 0, + log: "", + events: [ + { + type: "message", + attributes: [ + { + key: "action", + value: "store-code", + }, + { + key: "module", + value: "wasm", + }, + { + key: "action", + value: "store-code", + }, + { + key: "sender", + value: "cosmos1pkptre7fdkl6gfrzlesjjvhxhlc3r4gmmk8rs6", + }, + { + key: "code_id", + value: "1", + }, + ], + }, + ], + }, + ] as const; + + const logs = parseLogs(original); + expect(logs).toEqual(original); + }); + }); +}); diff --git a/packages/sdk38/src/logs.ts b/packages/sdk38/src/logs.ts new file mode 100644 index 00000000..e1eaa1cb --- /dev/null +++ b/packages/sdk38/src/logs.ts @@ -0,0 +1,86 @@ +/* eslint-disable @typescript-eslint/camelcase */ +import { isNonNullObject } from "@iov/encoding"; + +export interface Attribute { + readonly key: string; + readonly value: string; +} + +export interface Event { + readonly type: string; + readonly attributes: readonly Attribute[]; +} + +export interface Log { + readonly msg_index: number; + readonly log: string; + readonly events: readonly Event[]; +} + +export function parseAttribute(input: unknown): Attribute { + if (!isNonNullObject(input)) throw new Error("Attribute must be a non-null object"); + const { key, value } = input as any; + if (typeof key !== "string" || !key) throw new Error("Attribute's key must be a non-empty string"); + if (typeof value !== "string" && typeof value !== "undefined") { + throw new Error("Attribute's value must be a string or unset"); + } + + return { + key: key, + value: value || "", + }; +} + +export function parseEvent(input: unknown): Event { + if (!isNonNullObject(input)) throw new Error("Event must be a non-null object"); + const { type, attributes } = input as any; + if (typeof type !== "string" || type === "") { + throw new Error(`Event type must be a non-empty string`); + } + if (!Array.isArray(attributes)) throw new Error("Event's attributes must be an array"); + return { + type: type, + attributes: attributes.map(parseAttribute), + }; +} + +export function parseLog(input: unknown): Log { + if (!isNonNullObject(input)) throw new Error("Log must be a non-null object"); + const { msg_index, log, events } = input as any; + if (typeof msg_index !== "number") throw new Error("Log's msg_index must be a number"); + if (typeof log !== "string") throw new Error("Log's log must be a string"); + if (!Array.isArray(events)) throw new Error("Log's events must be an array"); + return { + msg_index: msg_index, + log: log, + events: events.map(parseEvent), + }; +} + +export function parseLogs(input: unknown): readonly Log[] { + if (!Array.isArray(input)) throw new Error("Logs must be an array"); + return input.map(parseLog); +} + +/** + * Searches in logs for the first event of the given event type and in that event + * for the first first attribute with the given attribute key. + * + * Throws if the attribute was not found. + */ +export function findAttribute( + logs: readonly Log[], + eventType: "message" | "transfer", + attrKey: string, +): Attribute { + const firstLogs = logs.find(() => true); + const out = firstLogs?.events + .find((event) => event.type === eventType) + ?.attributes.find((attr) => attr.key === attrKey); + if (!out) { + throw new Error( + `Could not find attribute '${attrKey}' in first event of type '${eventType}' in first log.`, + ); + } + return out; +} diff --git a/packages/sdk38/src/pen.spec.ts b/packages/sdk38/src/pen.spec.ts new file mode 100644 index 00000000..549177a8 --- /dev/null +++ b/packages/sdk38/src/pen.spec.ts @@ -0,0 +1,56 @@ +import { Secp256k1, Secp256k1Signature, Sha256 } from "@iov/crypto"; +import { Encoding } from "@iov/encoding"; + +import { Secp256k1Pen } from "./pen"; +import { decodeSignature } from "./signature"; + +const { fromHex } = Encoding; + +describe("Sec256k1Pen", () => { + it("can be constructed", async () => { + const pen = await Secp256k1Pen.fromMnemonic( + "zebra slush diet army arrest purpose hawk source west glimpse custom record", + ); + expect(pen).toBeTruthy(); + }); + + describe("pubkey", () => { + it("returns compressed pubkey", async () => { + // special sign fit simple patrol salute grocery chicken wheat radar tonight ceiling + // m/44'/118'/0'/0/0 + // pubkey: 02baa4ef93f2ce84592a49b1d729c074eab640112522a7a89f7d03ebab21ded7b6 + const pen = await Secp256k1Pen.fromMnemonic( + "special sign fit simple patrol salute grocery chicken wheat radar tonight ceiling", + ); + expect(pen.pubkey).toEqual( + fromHex("02baa4ef93f2ce84592a49b1d729c074eab640112522a7a89f7d03ebab21ded7b6"), + ); + }); + }); + + describe("sign", () => { + it("creates correct signatures", async () => { + const pen = await Secp256k1Pen.fromMnemonic( + "special sign fit simple patrol salute grocery chicken wheat radar tonight ceiling", + ); + const data = Encoding.toAscii("foo bar"); + const { pubkey, signature } = decodeSignature(await pen.sign(data)); + + const valid = await Secp256k1.verifySignature( + Secp256k1Signature.fromFixedLength(signature), + new Sha256(data).digest(), + pubkey, + ); + expect(valid).toEqual(true); + }); + }); + + describe("address", () => { + it("creates same address as Go imlementation", async () => { + const pen = await Secp256k1Pen.fromMnemonic( + "oyster design unusual machine spread century engine gravity focus cave carry slot", + ); + expect(pen.address("cosmos")).toEqual("cosmos1cjsxept9rkggzxztslae9ndgpdyt2408lk850u"); + }); + }); +}); diff --git a/packages/sdk38/src/pen.ts b/packages/sdk38/src/pen.ts new file mode 100644 index 00000000..936abc0e --- /dev/null +++ b/packages/sdk38/src/pen.ts @@ -0,0 +1,92 @@ +import { + Bip39, + EnglishMnemonic, + Secp256k1, + Sha256, + Sha512, + Slip10, + Slip10Curve, + Slip10RawIndex, +} from "@iov/crypto"; + +import { rawSecp256k1PubkeyToAddress } from "./address"; +import { encodeSecp256k1Signature } from "./signature"; +import { StdSignature } from "./types"; + +export type PrehashType = "sha256" | "sha512" | null; + +/** + * A pen is the most basic tool you can think of for signing. It works + * everywhere and can be used intuitively by everyone. However, it does not + * come with a great amount of features. End of semi suitable metaphor. + * + * This wraps a single keypair and allows for signing. + * + * Non-goals of this types are: multi account support, persistency, data migrations, + * obfuscation of sensitive data. + */ +export interface Pen { + readonly pubkey: Uint8Array; + readonly sign: (signBytes: Uint8Array, prehashType?: PrehashType) => Promise; +} + +function prehash(bytes: Uint8Array, type: PrehashType): Uint8Array { + switch (type) { + case null: + return new Uint8Array([...bytes]); + case "sha256": + return new Sha256(bytes).digest(); + case "sha512": + return new Sha512(bytes).digest(); + default: + throw new Error("Unknown prehash type"); + } +} + +/** + * The Cosmoshub derivation path in the form `m/44'/118'/0'/0/a` + * with 0-based account index `a`. + */ +export function makeCosmoshubPath(a: number): readonly Slip10RawIndex[] { + return [ + Slip10RawIndex.hardened(44), + Slip10RawIndex.hardened(118), + Slip10RawIndex.hardened(0), + Slip10RawIndex.normal(0), + Slip10RawIndex.normal(a), + ]; +} + +export class Secp256k1Pen implements Pen { + public static async fromMnemonic( + mnemonic: string, + hdPath: readonly Slip10RawIndex[] = makeCosmoshubPath(0), + ): Promise { + const seed = await Bip39.mnemonicToSeed(new EnglishMnemonic(mnemonic)); + const { privkey } = Slip10.derivePath(Slip10Curve.Secp256k1, seed, hdPath); + const uncompressed = (await Secp256k1.makeKeypair(privkey)).pubkey; + return new Secp256k1Pen(privkey, Secp256k1.compressPubkey(uncompressed)); + } + + public readonly pubkey: Uint8Array; + private readonly privkey: Uint8Array; + + private constructor(privkey: Uint8Array, pubkey: Uint8Array) { + this.privkey = privkey; + this.pubkey = pubkey; + } + + /** + * Creates and returns a signature + */ + public async sign(signBytes: Uint8Array, prehashType: PrehashType = "sha256"): Promise { + const message = prehash(signBytes, prehashType); + const signature = await Secp256k1.createSignature(message, this.privkey); + const fixedLengthSignature = new Uint8Array([...signature.r(32), ...signature.s(32)]); + return encodeSecp256k1Signature(this.pubkey, fixedLengthSignature); + } + + public address(prefix: string): string { + return rawSecp256k1PubkeyToAddress(this.pubkey, prefix); + } +} diff --git a/packages/sdk38/src/pubkey.spec.ts b/packages/sdk38/src/pubkey.spec.ts new file mode 100644 index 00000000..cb344f7b --- /dev/null +++ b/packages/sdk38/src/pubkey.spec.ts @@ -0,0 +1,57 @@ +import { Encoding } from "@iov/encoding"; + +import { decodeBech32Pubkey, encodeBech32Pubkey, encodeSecp256k1Pubkey } from "./pubkey"; +import { PubKey } from "./types"; + +const { fromBase64 } = Encoding; + +describe("pubkey", () => { + describe("encodeSecp256k1Pubkey", () => { + it("encodes a compresed pubkey", () => { + const pubkey = fromBase64("AtQaCqFnshaZQp6rIkvAPyzThvCvXSDO+9AzbxVErqJP"); + expect(encodeSecp256k1Pubkey(pubkey)).toEqual({ + type: "tendermint/PubKeySecp256k1", + value: "AtQaCqFnshaZQp6rIkvAPyzThvCvXSDO+9AzbxVErqJP", + }); + }); + + it("throws for uncompressed public keys", () => { + const pubkey = fromBase64( + "BE8EGB7ro1ORuFhjOnZcSgwYlpe0DSFjVNUIkNNQxwKQE7WHpoHoNswYeoFkuYpYSKK4mzFzMV/dB0DVAy4lnNU=", + ); + expect(() => encodeSecp256k1Pubkey(pubkey)).toThrowError(/public key must be compressed secp256k1/i); + }); + }); + + describe("decodeBech32Pubkey", () => { + it("works", () => { + expect( + decodeBech32Pubkey("cosmospub1addwnpepqd8sgxq7aw348ydctp3n5ajufgxp395hksxjzc6565yfp56scupfqhlgyg5"), + ).toEqual({ + type: "tendermint/PubKeySecp256k1", + value: "A08EGB7ro1ORuFhjOnZcSgwYlpe0DSFjVNUIkNNQxwKQ", + }); + }); + + it("works for enigma pubkey", () => { + expect( + decodeBech32Pubkey("enigmapub1addwnpepqw5k9p439nw0zpg2aundx4umwx4nw233z5prpjqjv5anl5grmnchzp2xwvv"), + ).toEqual({ + type: "tendermint/PubKeySecp256k1", + value: "A6lihrEs3PEFCu8m01ebcas3KjEVAjDIEmU7P9ED3PFx", + }); + }); + }); + + describe("encodeBech32Pubkey", () => { + it("works for secp256k1", () => { + const pubkey: PubKey = { + type: "tendermint/PubKeySecp256k1", + value: "A08EGB7ro1ORuFhjOnZcSgwYlpe0DSFjVNUIkNNQxwKQ", + }; + expect(encodeBech32Pubkey(pubkey, "cosmospub")).toEqual( + "cosmospub1addwnpepqd8sgxq7aw348ydctp3n5ajufgxp395hksxjzc6565yfp56scupfqhlgyg5", + ); + }); + }); +}); diff --git a/packages/sdk38/src/pubkey.ts b/packages/sdk38/src/pubkey.ts new file mode 100644 index 00000000..8835b97a --- /dev/null +++ b/packages/sdk38/src/pubkey.ts @@ -0,0 +1,71 @@ +import { Bech32, Encoding } from "@iov/encoding"; +import equal from "fast-deep-equal"; + +import { PubKey, pubkeyType } from "./types"; + +export function encodeSecp256k1Pubkey(pubkey: Uint8Array): PubKey { + if (pubkey.length !== 33 || (pubkey[0] !== 0x02 && pubkey[0] !== 0x03)) { + throw new Error("Public key must be compressed secp256k1, i.e. 33 bytes starting with 0x02 or 0x03"); + } + return { + type: pubkeyType.secp256k1, + value: Encoding.toBase64(pubkey), + }; +} + +// As discussed in https://github.com/binance-chain/javascript-sdk/issues/163 +// Prefixes listed here: https://github.com/tendermint/tendermint/blob/d419fffe18531317c28c29a292ad7d253f6cafdf/docs/spec/blockchain/encoding.md#public-key-cryptography +// Last bytes is varint-encoded length prefix +const pubkeyAminoPrefixSecp256k1 = Encoding.fromHex("eb5ae98721"); +const pubkeyAminoPrefixEd25519 = Encoding.fromHex("1624de6420"); +const pubkeyAminoPrefixSr25519 = Encoding.fromHex("0dfb1005"); +const pubkeyAminoPrefixLength = pubkeyAminoPrefixSecp256k1.length; + +export function decodeBech32Pubkey(bechEncoded: string): PubKey { + const { data } = Bech32.decode(bechEncoded); + + const aminoPrefix = data.slice(0, pubkeyAminoPrefixLength); + const rest = data.slice(pubkeyAminoPrefixLength); + if (equal(aminoPrefix, pubkeyAminoPrefixSecp256k1)) { + if (rest.length !== 33) { + throw new Error("Invalid rest data length. Expected 33 bytes (compressed secp256k1 pubkey)."); + } + return { + type: pubkeyType.secp256k1, + value: Encoding.toBase64(rest), + }; + } else if (equal(aminoPrefix, pubkeyAminoPrefixEd25519)) { + if (rest.length !== 32) { + throw new Error("Invalid rest data length. Expected 32 bytes (Ed25519 pubkey)."); + } + return { + type: pubkeyType.ed25519, + value: Encoding.toBase64(rest), + }; + } else if (equal(aminoPrefix, pubkeyAminoPrefixSr25519)) { + if (rest.length !== 32) { + throw new Error("Invalid rest data length. Expected 32 bytes (Sr25519 pubkey)."); + } + return { + type: pubkeyType.sr25519, + value: Encoding.toBase64(rest), + }; + } else { + throw new Error("Unsupported Pubkey type. Amino prefix: " + Encoding.toHex(aminoPrefix)); + } +} + +export function encodeBech32Pubkey(pubkey: PubKey, prefix: string): string { + let aminoPrefix: Uint8Array; + switch (pubkey.type) { + // Note: please don't add cases here without writing additional unit tests + case pubkeyType.secp256k1: + aminoPrefix = pubkeyAminoPrefixSecp256k1; + break; + default: + throw new Error("Unsupported pubkey type"); + } + + const data = new Uint8Array([...aminoPrefix, ...Encoding.fromBase64(pubkey.value)]); + return Bech32.encode(prefix, data); +} diff --git a/packages/sdk38/src/restclient.spec.ts b/packages/sdk38/src/restclient.spec.ts new file mode 100644 index 00000000..7dc391a4 --- /dev/null +++ b/packages/sdk38/src/restclient.spec.ts @@ -0,0 +1,897 @@ +/* eslint-disable @typescript-eslint/camelcase */ +import { Encoding } from "@iov/encoding"; +import { assert, sleep } from "@iov/utils"; +import { ReadonlyDate } from "readonly-date"; + +import { rawSecp256k1PubkeyToAddress } from "./address"; +import { makeSignBytes } from "./encoding"; +import { parseLogs } from "./logs"; +import { makeCosmoshubPath, Secp256k1Pen } from "./pen"; +import { encodeBech32Pubkey } from "./pubkey"; +import { RestClient, TxsResponse } from "./restclient"; +import { SigningCosmosClient } from "./signingcosmosclient"; +import cosmoshub from "./testdata/cosmoshub.json"; +import { + faucet, + makeRandomAddress, + nonNegativeIntegerMatcher, + pendingWithoutWasmd, + semverMatcher, + tendermintAddressMatcher, + tendermintIdMatcher, + tendermintOptionalIdMatcher, + tendermintShortHashMatcher, + unused, + wasmd, + wasmdEnabled, +} from "./testutils.spec"; +import { Msg, MsgSend, StdFee, StdSignature, StdTx } from "./types"; + +const { fromBase64 } = Encoding; + +const emptyAddress = "cosmos1ltkhnmdcqemmd2tkhnx7qx66tq7e0wykw2j85k"; + +function makeSignedTx(firstMsg: Msg, fee: StdFee, memo: string, firstSignature: StdSignature): StdTx { + return { + msg: [firstMsg], + fee: fee, + memo: memo, + signatures: [firstSignature], + }; +} + +describe("RestClient", () => { + it("can be constructed", () => { + const client = new RestClient(wasmd.endpoint); + expect(client).toBeTruthy(); + }); + + // The /auth endpoints + + describe("authAccounts", () => { + it("works for unused account without pubkey", async () => { + pendingWithoutWasmd(); + const client = new RestClient(wasmd.endpoint); + const { height, result } = await client.authAccounts(unused.address); + expect(height).toMatch(nonNegativeIntegerMatcher); + expect(result).toEqual({ + type: "cosmos-sdk/Account", + value: { + address: unused.address, + public_key: "", // not known to the chain + coins: [ + { + amount: "1000000000", + denom: "ucosm", + }, + { + amount: "1000000000", + denom: "ustake", + }, + ], + account_number: unused.accountNumber, + sequence: 0, + }, + }); + }); + + // This fails in the first test run if you forget to run `./scripts/wasmd/init.sh` + it("has correct pubkey for faucet", async () => { + pendingWithoutWasmd(); + const client = new RestClient(wasmd.endpoint); + const { result } = await client.authAccounts(faucet.address); + expect(result.value).toEqual( + jasmine.objectContaining({ + public_key: encodeBech32Pubkey(faucet.pubkey, "cosmospub"), + }), + ); + }); + + // This property is used by CosmWasmClient.getAccount + it("returns empty address for non-existent account", async () => { + pendingWithoutWasmd(); + const client = new RestClient(wasmd.endpoint); + const nonExistentAccount = makeRandomAddress(); + const { result } = await client.authAccounts(nonExistentAccount); + expect(result).toEqual({ + type: "cosmos-sdk/Account", + value: jasmine.objectContaining({ address: "" }), + }); + }); + }); + + // The /blocks endpoints + + describe("blocksLatest", () => { + it("works", async () => { + pendingWithoutWasmd(); + const client = new RestClient(wasmd.endpoint); + const response = await client.blocksLatest(); + + // id + expect(response.block_id.hash).toMatch(tendermintIdMatcher); + + // header + expect(response.block.header.version).toEqual({ block: "10", app: "0" }); + expect(parseInt(response.block.header.height, 10)).toBeGreaterThanOrEqual(1); + expect(response.block.header.chain_id).toEqual(wasmd.chainId); + expect(new ReadonlyDate(response.block.header.time).getTime()).toBeLessThan(ReadonlyDate.now()); + expect(new ReadonlyDate(response.block.header.time).getTime()).toBeGreaterThanOrEqual( + ReadonlyDate.now() - 5_000, + ); + expect(response.block.header.last_commit_hash).toMatch(tendermintIdMatcher); + expect(response.block.header.last_block_id.hash).toMatch(tendermintIdMatcher); + expect(response.block.header.data_hash).toMatch(tendermintOptionalIdMatcher); + expect(response.block.header.validators_hash).toMatch(tendermintIdMatcher); + expect(response.block.header.next_validators_hash).toMatch(tendermintIdMatcher); + expect(response.block.header.consensus_hash).toMatch(tendermintIdMatcher); + expect(response.block.header.app_hash).toMatch(tendermintIdMatcher); + expect(response.block.header.last_results_hash).toMatch(tendermintOptionalIdMatcher); + expect(response.block.header.evidence_hash).toMatch(tendermintOptionalIdMatcher); + expect(response.block.header.proposer_address).toMatch(tendermintAddressMatcher); + + // data + expect(response.block.data.txs === null || Array.isArray(response.block.data.txs)).toEqual(true); + }); + }); + + describe("blocks", () => { + it("works for block by height", async () => { + pendingWithoutWasmd(); + const client = new RestClient(wasmd.endpoint); + const height = parseInt((await client.blocksLatest()).block.header.height, 10); + const response = await client.blocks(height - 1); + + // id + expect(response.block_id.hash).toMatch(tendermintIdMatcher); + + // header + expect(response.block.header.version).toEqual({ block: "10", app: "0" }); + expect(response.block.header.height).toEqual(`${height - 1}`); + expect(response.block.header.chain_id).toEqual(wasmd.chainId); + expect(new ReadonlyDate(response.block.header.time).getTime()).toBeLessThan(ReadonlyDate.now()); + expect(new ReadonlyDate(response.block.header.time).getTime()).toBeGreaterThanOrEqual( + ReadonlyDate.now() - 5_000, + ); + expect(response.block.header.last_commit_hash).toMatch(tendermintIdMatcher); + expect(response.block.header.last_block_id.hash).toMatch(tendermintIdMatcher); + expect(response.block.header.data_hash).toMatch(tendermintOptionalIdMatcher); + expect(response.block.header.validators_hash).toMatch(tendermintIdMatcher); + expect(response.block.header.next_validators_hash).toMatch(tendermintIdMatcher); + expect(response.block.header.consensus_hash).toMatch(tendermintIdMatcher); + expect(response.block.header.app_hash).toMatch(tendermintIdMatcher); + expect(response.block.header.last_results_hash).toMatch(tendermintOptionalIdMatcher); + expect(response.block.header.evidence_hash).toMatch(tendermintOptionalIdMatcher); + expect(response.block.header.proposer_address).toMatch(tendermintAddressMatcher); + + // data + expect(response.block.data.txs === null || Array.isArray(response.block.data.txs)).toEqual(true); + }); + }); + + // The /node_info endpoint + + describe("nodeInfo", () => { + it("works", async () => { + pendingWithoutWasmd(); + const client = new RestClient(wasmd.endpoint); + const { node_info, application_version } = await client.nodeInfo(); + + expect(node_info).toEqual({ + protocol_version: { p2p: "7", block: "10", app: "0" }, + id: jasmine.stringMatching(tendermintShortHashMatcher), + listen_addr: "tcp://0.0.0.0:26656", + network: wasmd.chainId, + version: jasmine.stringMatching(/^0\.33\.[0-9]+$/), + channels: "4020212223303800", + moniker: wasmd.chainId, + other: { tx_index: "on", rpc_address: "tcp://0.0.0.0:26657" }, + }); + expect(application_version).toEqual({ + name: "wasm", + server_name: "wasmd", + client_name: "wasmcli", + version: jasmine.stringMatching(semverMatcher), + commit: jasmine.stringMatching(tendermintShortHashMatcher), + build_tags: "netgo,ledger", + go: jasmine.stringMatching(/^go version go1\.[0-9]+\.[0-9]+ linux\/amd64$/), + }); + }); + }); + + // The /txs endpoints + + describe("txById", () => { + let successful: + | { + readonly sender: string; + readonly recipient: string; + readonly hash: string; + } + | undefined; + let unsuccessful: + | { + readonly sender: string; + readonly recipient: string; + readonly hash: string; + } + | undefined; + + beforeAll(async () => { + if (wasmdEnabled()) { + const pen = await Secp256k1Pen.fromMnemonic(faucet.mnemonic); + const client = new SigningCosmosClient(wasmd.endpoint, faucet.address, (signBytes) => + pen.sign(signBytes), + ); + + { + const recipient = makeRandomAddress(); + const transferAmount = { + denom: "ucosm", + amount: "1234567", + }; + const result = await client.sendTokens(recipient, [transferAmount]); + successful = { + sender: faucet.address, + recipient: recipient, + hash: result.transactionHash, + }; + } + + { + const memo = "Sending more than I can afford"; + const recipient = makeRandomAddress(); + const transferAmount = [ + { + denom: "ucosm", + amount: "123456700000000", + }, + ]; + 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: recipient, + amount: transferAmount, + }, + }; + const fee = { + amount: [ + { + denom: "ucosm", + amount: "2000", + }, + ], + gas: "80000", // 80k + }; + const { accountNumber, sequence } = await client.getNonce(); + const chainId = await client.getChainId(); + 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 transactionId = await client.getIdentifier({ type: "cosmos-sdk/StdTx", value: signedTx }); + try { + await client.postTx(signedTx); + } catch (error) { + // postTx() throws on execution failures, which is a questionable design. Ignore for now. + // console.log(error); + } + unsuccessful = { + sender: faucet.address, + recipient: recipient, + hash: transactionId, + }; + } + + await sleep(50); // wait until transactions are indexed + } + }); + + it("works for successful transaction", async () => { + pendingWithoutWasmd(); + assert(successful); + const client = new RestClient(wasmd.endpoint); + const result = await client.txById(successful.hash); + expect(result.height).toBeGreaterThanOrEqual(1); + expect(result.txhash).toEqual(successful.hash); + expect(result.codespace).toBeUndefined(); + expect(result.code).toBeUndefined(); + const logs = parseLogs(result.logs); + expect(logs).toEqual([ + { + msg_index: 0, + log: "", + events: [ + { + type: "message", + attributes: [ + { key: "action", value: "send" }, + { key: "sender", value: successful.sender }, + { key: "module", value: "bank" }, + ], + }, + { + type: "transfer", + attributes: [ + { key: "recipient", value: successful.recipient }, + { key: "sender", value: successful.sender }, + { key: "amount", value: "1234567ucosm" }, + ], + }, + ], + }, + ]); + }); + + it("works for unsuccessful transaction", async () => { + pendingWithoutWasmd(); + assert(unsuccessful); + const client = new RestClient(wasmd.endpoint); + const result = await client.txById(unsuccessful.hash); + expect(result.height).toBeGreaterThanOrEqual(1); + expect(result.txhash).toEqual(unsuccessful.hash); + expect(result.codespace).toEqual("sdk"); + expect(result.code).toEqual(5); + expect(result.logs).toBeUndefined(); + expect(result.raw_log).toContain("insufficient funds"); + }); + }); + + describe("txsQuery", () => { + let posted: + | { + readonly sender: string; + readonly recipient: string; + readonly hash: string; + readonly height: number; + readonly tx: TxsResponse; + } + | undefined; + + beforeAll(async () => { + if (wasmdEnabled()) { + const pen = await Secp256k1Pen.fromMnemonic(faucet.mnemonic); + const client = new SigningCosmosClient(wasmd.endpoint, faucet.address, (signBytes) => + pen.sign(signBytes), + ); + + const recipient = makeRandomAddress(); + const transferAmount = [ + { + denom: "ucosm", + amount: "1234567", + }, + ]; + const result = await client.sendTokens(recipient, transferAmount); + + await sleep(50); // wait until tx is indexed + const txDetails = await new RestClient(wasmd.endpoint).txById(result.transactionHash); + posted = { + sender: faucet.address, + recipient: recipient, + hash: result.transactionHash, + height: Number.parseInt(txDetails.height, 10), + tx: txDetails, + }; + } + }); + + it("can query transactions by height", async () => { + pendingWithoutWasmd(); + assert(posted); + const client = new RestClient(wasmd.endpoint); + const result = await client.txsQuery(`tx.height=${posted.height}&limit=26`); + expect(result).toEqual({ + count: "1", + limit: "26", + page_number: "1", + page_total: "1", + total_count: "1", + txs: [posted.tx], + }); + }); + + it("can query transactions by ID", async () => { + pendingWithoutWasmd(); + assert(posted); + const client = new RestClient(wasmd.endpoint); + const result = await client.txsQuery(`tx.hash=${posted.hash}&limit=26`); + expect(result).toEqual({ + count: "1", + limit: "26", + page_number: "1", + page_total: "1", + total_count: "1", + txs: [posted.tx], + }); + }); + + it("can query transactions by sender", async () => { + pendingWithoutWasmd(); + assert(posted); + const client = new RestClient(wasmd.endpoint); + const result = await client.txsQuery(`message.sender=${posted.sender}&limit=200`); + expect(parseInt(result.count, 10)).toBeGreaterThanOrEqual(1); + expect(parseInt(result.limit, 10)).toEqual(200); + expect(parseInt(result.page_number, 10)).toEqual(1); + expect(parseInt(result.page_total, 10)).toEqual(1); + expect(parseInt(result.total_count, 10)).toBeGreaterThanOrEqual(1); + expect(result.txs.length).toBeGreaterThanOrEqual(1); + expect(result.txs[result.txs.length - 1]).toEqual(posted.tx); + }); + + it("can query transactions by recipient", async () => { + pendingWithoutWasmd(); + assert(posted); + const client = new RestClient(wasmd.endpoint); + const result = await client.txsQuery(`transfer.recipient=${posted.recipient}&limit=200`); + expect(parseInt(result.count, 10)).toEqual(1); + expect(parseInt(result.limit, 10)).toEqual(200); + expect(parseInt(result.page_number, 10)).toEqual(1); + expect(parseInt(result.page_total, 10)).toEqual(1); + expect(parseInt(result.total_count, 10)).toEqual(1); + expect(result.txs.length).toBeGreaterThanOrEqual(1); + expect(result.txs[result.txs.length - 1]).toEqual(posted.tx); + }); + + it("can filter by tx.hash and tx.minheight", async () => { + pending("This combination is broken 🤷‍♂️. Handle client-side at higher level."); + pendingWithoutWasmd(); + assert(posted); + const client = new RestClient(wasmd.endpoint); + const hashQuery = `tx.hash=${posted.hash}`; + + { + const { count } = await client.txsQuery(`${hashQuery}&tx.minheight=0`); + expect(count).toEqual("1"); + } + + { + const { count } = await client.txsQuery(`${hashQuery}&tx.minheight=${posted.height - 1}`); + expect(count).toEqual("1"); + } + + { + const { count } = await client.txsQuery(`${hashQuery}&tx.minheight=${posted.height}`); + expect(count).toEqual("1"); + } + + { + const { count } = await client.txsQuery(`${hashQuery}&tx.minheight=${posted.height + 1}`); + expect(count).toEqual("0"); + } + }); + + it("can filter by recipient and tx.minheight", async () => { + pendingWithoutWasmd(); + assert(posted); + const client = new RestClient(wasmd.endpoint); + const recipientQuery = `transfer.recipient=${posted.recipient}`; + + { + const { count } = await client.txsQuery(`${recipientQuery}&tx.minheight=0`); + expect(count).toEqual("1"); + } + + { + const { count } = await client.txsQuery(`${recipientQuery}&tx.minheight=${posted.height - 1}`); + expect(count).toEqual("1"); + } + + { + const { count } = await client.txsQuery(`${recipientQuery}&tx.minheight=${posted.height}`); + expect(count).toEqual("1"); + } + + { + const { count } = await client.txsQuery(`${recipientQuery}&tx.minheight=${posted.height + 1}`); + expect(count).toEqual("0"); + } + }); + + it("can filter by recipient and tx.maxheight", async () => { + pendingWithoutWasmd(); + assert(posted); + const client = new RestClient(wasmd.endpoint); + const recipientQuery = `transfer.recipient=${posted.recipient}`; + + { + const { count } = await client.txsQuery(`${recipientQuery}&tx.maxheight=9999999999999`); + expect(count).toEqual("1"); + } + + { + const { count } = await client.txsQuery(`${recipientQuery}&tx.maxheight=${posted.height + 1}`); + expect(count).toEqual("1"); + } + + { + const { count } = await client.txsQuery(`${recipientQuery}&tx.maxheight=${posted.height}`); + expect(count).toEqual("1"); + } + + { + const { count } = await client.txsQuery(`${recipientQuery}&tx.maxheight=${posted.height - 1}`); + expect(count).toEqual("0"); + } + }); + }); + + describe("encodeTx", () => { + it("works for cosmoshub example", async () => { + pendingWithoutWasmd(); + const client = new RestClient(wasmd.endpoint); + expect(await client.encodeTx(cosmoshub.tx)).toEqual(fromBase64(cosmoshub.tx_data)); + }); + }); + + describe("postTx", () => { + it("can send tokens", async () => { + pendingWithoutWasmd(); + const pen = await Secp256k1Pen.fromMnemonic(faucet.mnemonic); + + const memo = "My first contract on chain"; + const theMsg: MsgSend = { + type: "cosmos-sdk/MsgSend", + value: { + from_address: faucet.address, + to_address: emptyAddress, + amount: [ + { + denom: "ucosm", + amount: "1234567", + }, + ], + }, + }; + + const fee: StdFee = { + amount: [ + { + amount: "5000", + denom: "ucosm", + }, + ], + gas: "890000", + }; + + const client = new RestClient(wasmd.endpoint); + const { account_number, sequence } = (await client.authAccounts(faucet.address)).result.value; + + const signBytes = makeSignBytes([theMsg], fee, wasmd.chainId, memo, account_number, sequence); + const signature = await pen.sign(signBytes); + const signedTx = makeSignedTx(theMsg, fee, memo, signature); + const result = await client.postTx(signedTx); + expect(result.code).toBeUndefined(); + expect(result).toEqual({ + height: jasmine.stringMatching(nonNegativeIntegerMatcher), + txhash: jasmine.stringMatching(tendermintIdMatcher), + // code is not set + raw_log: jasmine.stringMatching(/^\[.+\]$/i), + logs: jasmine.any(Array), + gas_wanted: jasmine.stringMatching(nonNegativeIntegerMatcher), + gas_used: jasmine.stringMatching(nonNegativeIntegerMatcher), + }); + }); + + it("can't send transaction with additional signatures", async () => { + pendingWithoutWasmd(); + const account1 = await Secp256k1Pen.fromMnemonic(faucet.mnemonic, makeCosmoshubPath(0)); + const account2 = await Secp256k1Pen.fromMnemonic(faucet.mnemonic, makeCosmoshubPath(1)); + const account3 = await Secp256k1Pen.fromMnemonic(faucet.mnemonic, makeCosmoshubPath(2)); + const address1 = rawSecp256k1PubkeyToAddress(account1.pubkey, "cosmos"); + const address2 = rawSecp256k1PubkeyToAddress(account2.pubkey, "cosmos"); + const address3 = rawSecp256k1PubkeyToAddress(account3.pubkey, "cosmos"); + + const memo = "My first contract on chain"; + const theMsg: MsgSend = { + type: "cosmos-sdk/MsgSend", + value: { + from_address: address1, + to_address: emptyAddress, + amount: [ + { + denom: "ucosm", + amount: "1234567", + }, + ], + }, + }; + + const fee: StdFee = { + amount: [ + { + amount: "5000", + denom: "ucosm", + }, + ], + gas: "890000", + }; + + const client = new RestClient(wasmd.endpoint); + const { account_number: an1, sequence: sequence1 } = (await client.authAccounts(address1)).result.value; + const { account_number: an2, sequence: sequence2 } = (await client.authAccounts(address2)).result.value; + const { account_number: an3, sequence: sequence3 } = (await client.authAccounts(address3)).result.value; + + const signBytes1 = makeSignBytes([theMsg], fee, wasmd.chainId, memo, an1, sequence1); + const signBytes2 = makeSignBytes([theMsg], fee, wasmd.chainId, memo, an2, sequence2); + const signBytes3 = makeSignBytes([theMsg], fee, wasmd.chainId, memo, an3, sequence3); + const signature1 = await account1.sign(signBytes1); + const signature2 = await account2.sign(signBytes2); + const signature3 = await account3.sign(signBytes3); + const signedTx = { + msg: [theMsg], + fee: fee, + memo: memo, + signatures: [signature1, signature2, signature3], + }; + const postResult = await client.postTx(signedTx); + // console.log(postResult.raw_log); + expect(postResult.code).toEqual(4); + expect(postResult.raw_log).toContain("wrong number of signers"); + }); + + it("can send multiple messages with one signature", async () => { + pendingWithoutWasmd(); + const account1 = await Secp256k1Pen.fromMnemonic(faucet.mnemonic, makeCosmoshubPath(0)); + const address1 = rawSecp256k1PubkeyToAddress(account1.pubkey, "cosmos"); + + const memo = "My first contract on chain"; + const msg1: MsgSend = { + type: "cosmos-sdk/MsgSend", + value: { + from_address: address1, + to_address: emptyAddress, + amount: [ + { + denom: "ucosm", + amount: "1234567", + }, + ], + }, + }; + const msg2: MsgSend = { + type: "cosmos-sdk/MsgSend", + value: { + from_address: address1, + to_address: emptyAddress, + amount: [ + { + denom: "ucosm", + amount: "7654321", + }, + ], + }, + }; + + const fee: StdFee = { + amount: [ + { + amount: "5000", + denom: "ucosm", + }, + ], + gas: "890000", + }; + + const client = new RestClient(wasmd.endpoint); + const { account_number, sequence } = (await client.authAccounts(address1)).result.value; + + const signBytes = makeSignBytes([msg1, msg2], fee, wasmd.chainId, memo, account_number, sequence); + const signature1 = await account1.sign(signBytes); + const signedTx = { + msg: [msg1, msg2], + fee: fee, + memo: memo, + signatures: [signature1], + }; + const postResult = await client.postTx(signedTx); + // console.log(postResult.raw_log); + expect(postResult.code).toBeUndefined(); + }); + + it("can send multiple messages with multiple signatures", async () => { + pendingWithoutWasmd(); + const account1 = await Secp256k1Pen.fromMnemonic(faucet.mnemonic, makeCosmoshubPath(0)); + const account2 = await Secp256k1Pen.fromMnemonic(faucet.mnemonic, makeCosmoshubPath(1)); + const address1 = rawSecp256k1PubkeyToAddress(account1.pubkey, "cosmos"); + const address2 = rawSecp256k1PubkeyToAddress(account2.pubkey, "cosmos"); + + const memo = "My first contract on chain"; + const msg1: MsgSend = { + type: "cosmos-sdk/MsgSend", + value: { + from_address: address1, + to_address: emptyAddress, + amount: [ + { + denom: "ucosm", + amount: "1234567", + }, + ], + }, + }; + const msg2: MsgSend = { + type: "cosmos-sdk/MsgSend", + value: { + from_address: address2, + to_address: emptyAddress, + amount: [ + { + denom: "ucosm", + amount: "7654321", + }, + ], + }, + }; + + const fee: StdFee = { + amount: [ + { + amount: "5000", + denom: "ucosm", + }, + ], + gas: "890000", + }; + + const client = new RestClient(wasmd.endpoint); + const { account_number: an1, sequence: sequence1 } = (await client.authAccounts(address1)).result.value; + const { account_number: an2, sequence: sequence2 } = (await client.authAccounts(address2)).result.value; + + const signBytes1 = makeSignBytes([msg2, msg1], fee, wasmd.chainId, memo, an1, sequence1); + const signBytes2 = makeSignBytes([msg2, msg1], fee, wasmd.chainId, memo, an2, sequence2); + const signature1 = await account1.sign(signBytes1); + const signature2 = await account2.sign(signBytes2); + const signedTx = { + msg: [msg2, msg1], + fee: fee, + memo: memo, + signatures: [signature2, signature1], + }; + const postResult = await client.postTx(signedTx); + // console.log(postResult.raw_log); + expect(postResult.code).toBeUndefined(); + + await sleep(500); + const searched = await client.txsQuery(`tx.hash=${postResult.txhash}`); + expect(searched.txs.length).toEqual(1); + expect(searched.txs[0].tx.value.signatures).toEqual([signature2, signature1]); + }); + + it("can't send transaction with wrong signature order (1)", async () => { + pendingWithoutWasmd(); + const account1 = await Secp256k1Pen.fromMnemonic(faucet.mnemonic, makeCosmoshubPath(0)); + const account2 = await Secp256k1Pen.fromMnemonic(faucet.mnemonic, makeCosmoshubPath(1)); + const address1 = rawSecp256k1PubkeyToAddress(account1.pubkey, "cosmos"); + const address2 = rawSecp256k1PubkeyToAddress(account2.pubkey, "cosmos"); + + const memo = "My first contract on chain"; + const msg1: MsgSend = { + type: "cosmos-sdk/MsgSend", + value: { + from_address: address1, + to_address: emptyAddress, + amount: [ + { + denom: "ucosm", + amount: "1234567", + }, + ], + }, + }; + const msg2: MsgSend = { + type: "cosmos-sdk/MsgSend", + value: { + from_address: address2, + to_address: emptyAddress, + amount: [ + { + denom: "ucosm", + amount: "7654321", + }, + ], + }, + }; + + const fee: StdFee = { + amount: [ + { + amount: "5000", + denom: "ucosm", + }, + ], + gas: "890000", + }; + + const client = new RestClient(wasmd.endpoint); + const { account_number: an1, sequence: sequence1 } = (await client.authAccounts(address1)).result.value; + const { account_number: an2, sequence: sequence2 } = (await client.authAccounts(address2)).result.value; + + const signBytes1 = makeSignBytes([msg1, msg2], fee, wasmd.chainId, memo, an1, sequence1); + const signBytes2 = makeSignBytes([msg1, msg2], fee, wasmd.chainId, memo, an2, sequence2); + const signature1 = await account1.sign(signBytes1); + const signature2 = await account2.sign(signBytes2); + const signedTx = { + msg: [msg1, msg2], + fee: fee, + memo: memo, + signatures: [signature2, signature1], + }; + const postResult = await client.postTx(signedTx); + // console.log(postResult.raw_log); + expect(postResult.code).toEqual(8); + }); + + it("can't send transaction with wrong signature order (2)", async () => { + pendingWithoutWasmd(); + const account1 = await Secp256k1Pen.fromMnemonic(faucet.mnemonic, makeCosmoshubPath(0)); + const account2 = await Secp256k1Pen.fromMnemonic(faucet.mnemonic, makeCosmoshubPath(1)); + const address1 = rawSecp256k1PubkeyToAddress(account1.pubkey, "cosmos"); + const address2 = rawSecp256k1PubkeyToAddress(account2.pubkey, "cosmos"); + + const memo = "My first contract on chain"; + const msg1: MsgSend = { + type: "cosmos-sdk/MsgSend", + value: { + from_address: address1, + to_address: emptyAddress, + amount: [ + { + denom: "ucosm", + amount: "1234567", + }, + ], + }, + }; + const msg2: MsgSend = { + type: "cosmos-sdk/MsgSend", + value: { + from_address: address2, + to_address: emptyAddress, + amount: [ + { + denom: "ucosm", + amount: "7654321", + }, + ], + }, + }; + + const fee: StdFee = { + amount: [ + { + amount: "5000", + denom: "ucosm", + }, + ], + gas: "890000", + }; + + const client = new RestClient(wasmd.endpoint); + const { account_number: an1, sequence: sequence1 } = (await client.authAccounts(address1)).result.value; + const { account_number: an2, sequence: sequence2 } = (await client.authAccounts(address2)).result.value; + + const signBytes1 = makeSignBytes([msg2, msg1], fee, wasmd.chainId, memo, an1, sequence1); + const signBytes2 = makeSignBytes([msg2, msg1], fee, wasmd.chainId, memo, an2, sequence2); + const signature1 = await account1.sign(signBytes1); + const signature2 = await account2.sign(signBytes2); + const signedTx = { + msg: [msg2, msg1], + fee: fee, + memo: memo, + signatures: [signature1, signature2], + }; + const postResult = await client.postTx(signedTx); + // console.log(postResult.raw_log); + expect(postResult.code).toEqual(8); + }); + }); +}); diff --git a/packages/sdk38/src/restclient.ts b/packages/sdk38/src/restclient.ts new file mode 100644 index 00000000..cb6316e9 --- /dev/null +++ b/packages/sdk38/src/restclient.ts @@ -0,0 +1,457 @@ +import { Encoding, isNonNullObject } from "@iov/encoding"; +import axios, { AxiosError, AxiosInstance } from "axios"; + +import { Coin } from "./coins"; +import { CosmosSdkTx, JsonObject, Model, parseWasmData, StdTx, WasmData } from "./types"; + +const { fromBase64, fromUtf8, toHex, toUtf8 } = Encoding; + +export interface CosmosSdkAccount { + /** Bech32 account address */ + readonly address: string; + readonly coins: ReadonlyArray; + /** Bech32 encoded pubkey */ + readonly public_key: string; + readonly account_number: number; + readonly sequence: number; +} + +export interface NodeInfo { + readonly protocol_version: { + readonly p2p: string; + readonly block: string; + readonly app: string; + }; + readonly id: string; + readonly listen_addr: string; + readonly network: string; + readonly version: string; + readonly channels: string; + readonly moniker: string; + readonly other: { + readonly tx_index: string; + readonly rpc_address: string; + }; +} + +export interface ApplicationVersion { + readonly name: string; + readonly server_name: string; + readonly client_name: string; + readonly version: string; + readonly commit: string; + readonly build_tags: string; + readonly go: string; +} + +export interface NodeInfoResponse { + readonly node_info: NodeInfo; + readonly application_version: ApplicationVersion; +} + +export interface BlockId { + readonly hash: string; + // TODO: here we also have this + // parts: { + // total: '1', + // hash: '7AF200C78FBF9236944E1AB270F4045CD60972B7C265E3A9DA42973397572931' + // } +} + +export interface BlockHeader { + readonly version: { + readonly block: string; + readonly app: string; + }; + readonly height: string; + readonly chain_id: string; + /** An RFC 3339 time string like e.g. '2020-02-15T10:39:10.4696305Z' */ + readonly time: string; + readonly last_commit_hash: string; + readonly last_block_id: BlockId; + /** Can be empty */ + readonly data_hash: string; + readonly validators_hash: string; + readonly next_validators_hash: string; + readonly consensus_hash: string; + readonly app_hash: string; + /** Can be empty */ + readonly last_results_hash: string; + /** Can be empty */ + readonly evidence_hash: string; + readonly proposer_address: string; +} + +export interface Block { + readonly header: BlockHeader; + readonly data: { + /** Array of base64 encoded transactions */ + readonly txs: ReadonlyArray | null; + }; +} + +export interface BlockResponse { + readonly block_id: BlockId; + readonly block: Block; +} + +interface AuthAccountsResponse { + readonly height: string; + readonly result: { + readonly type: "cosmos-sdk/Account"; + readonly value: CosmosSdkAccount; + }; +} + +// Currently all wasm query responses return json-encoded strings... +// later deprecate this and use the specific types for result +// (assuming it is inlined, no second parse needed) +type WasmResponse = WasmSuccess | WasmError; + +interface WasmSuccess { + readonly height: string; + readonly result: T; +} + +interface WasmError { + readonly error: string; +} + +export interface TxsResponse { + readonly height: string; + readonly txhash: string; + /** 🤷‍♂️ */ + readonly codespace?: string; + /** Falsy when transaction execution succeeded. Contains error code on error. */ + readonly code?: number; + readonly raw_log: string; + readonly logs?: object; + readonly tx: CosmosSdkTx; + /** The gas limit as set by the user */ + readonly gas_wanted?: string; + /** The gas used by the execution */ + readonly gas_used?: string; + readonly timestamp: string; +} + +interface SearchTxsResponse { + readonly total_count: string; + readonly count: string; + readonly page_number: string; + readonly page_total: string; + readonly limit: string; + readonly txs: readonly TxsResponse[]; +} + +export interface PostTxsResponse { + readonly height: string; + readonly txhash: string; + readonly code?: number; + readonly raw_log?: string; + /** The same as `raw_log` but deserialized? */ + readonly logs?: object; + /** The gas limit as set by the user */ + readonly gas_wanted?: string; + /** The gas used by the execution */ + readonly gas_used?: string; +} + +interface EncodeTxResponse { + // base64-encoded amino-binary encoded representation + readonly tx: string; +} + +export interface CodeInfo { + readonly id: number; + /** Bech32 account address */ + readonly creator: string; + /** Hex-encoded sha256 hash of the code stored here */ + readonly data_hash: string; + // TODO: these are not supported in current wasmd + readonly source?: string; + readonly builder?: string; +} + +export interface CodeDetails extends CodeInfo { + /** Base64 encoded raw wasm data */ + readonly data: string; +} + +// This is list view, without contract info +export interface ContractInfo { + readonly address: string; + readonly code_id: number; + /** Bech32 account address */ + readonly creator: string; + readonly label: string; +} + +export interface ContractDetails extends ContractInfo { + /** Argument passed on initialization of the contract */ + readonly init_msg: object; +} + +interface SmartQueryResponse { + // base64 encoded response + readonly smart: string; +} + +type RestClientResponse = + | NodeInfoResponse + | BlockResponse + | AuthAccountsResponse + | TxsResponse + | SearchTxsResponse + | PostTxsResponse + | EncodeTxResponse + | WasmResponse + | WasmResponse + | WasmResponse + | WasmResponse + | WasmResponse; + +/** Unfortunately, Cosmos SDK encodes empty arrays as null */ +type CosmosSdkArray = ReadonlyArray | null; + +function normalizeArray(backend: CosmosSdkArray): ReadonlyArray { + return backend || []; +} + +/** + * 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; +} + +function unwrapWasmResponse(response: WasmResponse): T { + if (isWasmError(response)) { + throw new Error(response.error); + } + return response.result; +} + +// We want to get message data from 500 errors +// https://stackoverflow.com/questions/56577124/how-to-handle-500-error-message-with-axios +// this should be chained to catch one error and throw a more informative one +function parseAxiosError(err: AxiosError): never { + // use the error message sent from server, not default 500 msg + if (err.response?.data) { + let errorText: string; + const data = err.response.data; + // expect { error: string }, but otherwise dump + if (data.error && typeof data.error === "string") { + errorText = data.error; + } else if (typeof data === "string") { + errorText = data; + } else { + errorText = JSON.stringify(data); + } + throw new Error(`${errorText} (HTTP ${err.response.status})`); + } else { + throw err; + } +} + +export class RestClient { + private readonly client: AxiosInstance; + private readonly broadcastMode: BroadcastMode; + + /** + * Creates a new client to interact with a Cosmos SDK light client daemon. + * This class tries to be a direct mapping onto the API. Some basic decoding and normalizatin is done + * but things like caching are done at a higher level. + * + * When building apps, you should not need to use this class directly. If you do, this indicates a missing feature + * in higher level components. Feel free to raise an issue in this case. + * + * @param apiUrl The URL of a Cosmos SDK light client daemon API (sometimes called REST server or REST API) + * @param broadcastMode Defines at which point of the transaction processing the postTx method (i.e. transaction broadcasting) returns + */ + public constructor(apiUrl: string, broadcastMode = BroadcastMode.Block) { + const headers = { + post: { "Content-Type": "application/json" }, + }; + this.client = axios.create({ + baseURL: apiUrl, + headers: headers, + }); + this.broadcastMode = broadcastMode; + } + + public async get(path: string): Promise { + const { data } = await this.client.get(path).catch(parseAxiosError); + if (data === null) { + throw new Error("Received null response from server"); + } + return data; + } + + public async post(path: string, params: any): Promise { + if (!isNonNullObject(params)) throw new Error("Got unexpected type of params. Expected object."); + const { data } = await this.client.post(path, params).catch(parseAxiosError); + if (data === null) { + throw new Error("Received null response from server"); + } + return data; + } + + // The /auth endpoints + + public async authAccounts(address: string): Promise { + const path = `/auth/accounts/${address}`; + const responseData = await this.get(path); + if ((responseData as any).result.type !== "cosmos-sdk/Account") { + throw new Error("Unexpected response data format"); + } + return responseData as AuthAccountsResponse; + } + + // The /blocks endpoints + + public async blocksLatest(): Promise { + const responseData = await this.get("/blocks/latest"); + if (!(responseData as any).block) { + throw new Error("Unexpected response data format"); + } + return responseData as BlockResponse; + } + + public async blocks(height: number): Promise { + const responseData = await this.get(`/blocks/${height}`); + if (!(responseData as any).block) { + throw new Error("Unexpected response data format"); + } + return responseData as BlockResponse; + } + + // The /node_info endpoint + + public async nodeInfo(): Promise { + const responseData = await this.get("/node_info"); + if (!(responseData as any).node_info) { + throw new Error("Unexpected response data format"); + } + return responseData as NodeInfoResponse; + } + + // The /txs endpoints + + public async txById(id: string): Promise { + const responseData = await this.get(`/txs/${id}`); + if (!(responseData as any).tx) { + throw new Error("Unexpected response data format"); + } + return responseData as TxsResponse; + } + + public async txsQuery(query: string): Promise { + const responseData = await this.get(`/txs?${query}`); + if (!(responseData as any).txs) { + throw new Error("Unexpected response data format"); + } + return responseData as SearchTxsResponse; + } + + /** returns the amino-encoding of the transaction performed by the server */ + public async encodeTx(tx: CosmosSdkTx): Promise { + const responseData = await this.post("/txs/encode", tx); + if (!(responseData as any).tx) { + throw new Error("Unexpected response data format"); + } + return Encoding.fromBase64((responseData as EncodeTxResponse).tx); + } + + /** + * Broadcasts a signed transaction to into the transaction pool. + * Depending on the RestClient's broadcast mode, this might or might + * wait for checkTx or deliverTx to be executed before returning. + * + * @param tx a signed transaction as StdTx (i.e. not wrapped in type/value container) + */ + public async postTx(tx: StdTx): Promise { + const params = { + tx: tx, + mode: this.broadcastMode, + }; + const responseData = await this.post("/txs", params); + if (!(responseData as any).txhash) { + throw new Error("Unexpected response data format"); + } + return responseData as PostTxsResponse; + } + + // The /wasm endpoints + + // wasm rest queries are listed here: https://github.com/cosmwasm/wasmd/blob/master/x/wasm/client/rest/query.go#L19-L27 + public async listCodeInfo(): Promise { + const path = `/wasm/code`; + const responseData = (await this.get(path)) as WasmResponse>; + return normalizeArray(unwrapWasmResponse(responseData)); + } + + // this will download the original wasm bytecode by code id + // throws error if no code with this id + public async getCode(id: number): Promise { + const path = `/wasm/code/${id}`; + const responseData = (await this.get(path)) as WasmResponse; + return unwrapWasmResponse(responseData); + } + + public async listContractsByCodeId(id: number): Promise { + const path = `/wasm/code/${id}/contracts`; + const responseData = (await this.get(path)) as WasmResponse>; + return normalizeArray(unwrapWasmResponse(responseData)); + } + + /** + * Returns null when contract was not found at this address. + */ + public async getContractInfo(address: string): Promise { + const path = `/wasm/contract/${address}`; + const response = (await this.get(path)) as WasmResponse; + return unwrapWasmResponse(response); + } + + // Returns all contract state. + // This is an empty array if no such contract, or contract has no data. + public async getAllContractState(address: string): Promise { + const path = `/wasm/contract/${address}/state`; + const responseData = (await this.get(path)) as WasmResponse>; + return normalizeArray(unwrapWasmResponse(responseData)).map(parseWasmData); + } + + // Returns the data at the key if present (unknown decoded json), + // or null if no data at this (contract address, key) pair + public async queryContractRaw(address: string, key: Uint8Array): Promise { + const hexKey = toHex(key); + const path = `/wasm/contract/${address}/raw/${hexKey}?encoding=hex`; + const responseData = (await this.get(path)) as WasmResponse; + const data = unwrapWasmResponse(responseData); + return data.length === 0 ? null : fromBase64(data[0].val); + } + + /** + * Makes a smart query on the contract and parses the reponse as JSON. + * Throws error if no such contract exists, the query format is invalid or the response is invalid. + */ + public async queryContractSmart(address: string, query: object): Promise { + const encoded = toHex(toUtf8(JSON.stringify(query))); + const path = `/wasm/contract/${address}/smart/${encoded}?encoding=hex`; + const responseData = (await this.get(path)) as WasmResponse; + const result = unwrapWasmResponse(responseData); + // By convention, smart queries must return a valid JSON document (see https://github.com/CosmWasm/cosmwasm/issues/144) + return JSON.parse(fromUtf8(fromBase64(result.smart))); + } +} diff --git a/packages/sdk38/src/sequence.spec.ts b/packages/sdk38/src/sequence.spec.ts new file mode 100644 index 00000000..e5d8f4cd --- /dev/null +++ b/packages/sdk38/src/sequence.spec.ts @@ -0,0 +1,30 @@ +import { findSequenceForSignedTx } from "./sequence"; +import response1 from "./testdata/txresponse1.json"; +import response2 from "./testdata/txresponse2.json"; +import response3 from "./testdata/txresponse3.json"; + +// Those values must match ./testdata/txresponse*.json +const chainId = "testing"; +const accountNumber = 4; + +describe("sequence", () => { + describe("findSequenceForSignedTx", () => { + it("works", async () => { + const current = 100; // what we get from GET /auth/accounts/{address} + expect(await findSequenceForSignedTx(response1.tx, chainId, accountNumber, current)).toEqual(10); + // We know response3.height > response1.height, so the sequence must be at least 10+1 + expect(await findSequenceForSignedTx(response3.tx, chainId, accountNumber, current, 11)).toEqual(19); + // We know response3.height > response2.height > response1.height, so the sequence must be at least 10+1 and smaller than 19 + expect(await findSequenceForSignedTx(response2.tx, chainId, accountNumber, 19, 11)).toEqual(13); + }); + + it("returns undefined when sequence is not in range", async () => { + expect(await findSequenceForSignedTx(response1.tx, chainId, accountNumber, 5)).toBeUndefined(); + expect(await findSequenceForSignedTx(response1.tx, chainId, accountNumber, 20, 11)).toBeUndefined(); + expect(await findSequenceForSignedTx(response1.tx, chainId, accountNumber, 20, 50)).toBeUndefined(); + + // upper bound is not included in the possible results + expect(await findSequenceForSignedTx(response1.tx, chainId, accountNumber, 10)).toBeUndefined(); + }); + }); +}); diff --git a/packages/sdk38/src/sequence.ts b/packages/sdk38/src/sequence.ts new file mode 100644 index 00000000..5cf2a738 --- /dev/null +++ b/packages/sdk38/src/sequence.ts @@ -0,0 +1,46 @@ +import { Secp256k1, Secp256k1Signature, Sha256 } from "@iov/crypto"; + +import { makeSignBytes } from "./encoding"; +import { decodeSignature } from "./signature"; +import { CosmosSdkTx } from "./types"; + +/** + * Serach for sequence s with `min` <= `s` < `upperBound` to find the sequence that was used to sign the transaction + * + * @param tx The signed transaction + * @param chainId The chain ID for which this transaction was signed + * @param accountNumber The account number for which this transaction was signed + * @param upperBound The upper bound for the testing, i.e. sequence must be lower than this value + * @param min The lowest sequence that is tested + * + * @returns the sequence if a match was found and undefined otherwise + */ +export async function findSequenceForSignedTx( + tx: CosmosSdkTx, + chainId: string, + accountNumber: number, + upperBound: number, + min = 0, +): Promise { + const firstSignature = tx.value.signatures.find(() => true); + if (!firstSignature) throw new Error("Signature missing in tx"); + + const { pubkey, signature } = decodeSignature(firstSignature); + const secp256keSignature = Secp256k1Signature.fromFixedLength(signature); + + for (let s = min; s < upperBound; s++) { + // console.log(`Trying sequence ${s}`); + const signBytes = makeSignBytes( + tx.value.msg, + tx.value.fee, + chainId, + tx.value.memo || "", + accountNumber, + s, + ); + const prehashed = new Sha256(signBytes).digest(); + const valid = await Secp256k1.verifySignature(secp256keSignature, prehashed, pubkey); + if (valid) return s; + } + return undefined; +} diff --git a/packages/sdk38/src/signature.spec.ts b/packages/sdk38/src/signature.spec.ts new file mode 100644 index 00000000..b0e782c1 --- /dev/null +++ b/packages/sdk38/src/signature.spec.ts @@ -0,0 +1,69 @@ +import { Encoding } from "@iov/encoding"; + +import { decodeSignature, encodeSecp256k1Signature } from "./signature"; +import { StdSignature } from "./types"; + +const { fromBase64 } = Encoding; + +describe("signature", () => { + describe("encodeSecp256k1Signature", () => { + it("encodes a full signature", () => { + const pubkey = fromBase64("AtQaCqFnshaZQp6rIkvAPyzThvCvXSDO+9AzbxVErqJP"); + const signature = fromBase64( + "1nUcIH0CLT0/nQ0mBTDrT6kMG20NY/PsH7P2gc4bpYNGLEYjBmdWevXUJouSE/9A/60QG9cYeqyTe5kFDeIPxQ==", + ); + expect(encodeSecp256k1Signature(pubkey, signature)).toEqual({ + // eslint-disable-next-line @typescript-eslint/camelcase + pub_key: { + type: "tendermint/PubKeySecp256k1", + value: "AtQaCqFnshaZQp6rIkvAPyzThvCvXSDO+9AzbxVErqJP", + }, + signature: "1nUcIH0CLT0/nQ0mBTDrT6kMG20NY/PsH7P2gc4bpYNGLEYjBmdWevXUJouSE/9A/60QG9cYeqyTe5kFDeIPxQ==", + }); + }); + + it("throws when getting uncompressed public keys", () => { + const pubkey = fromBase64( + "BE8EGB7ro1ORuFhjOnZcSgwYlpe0DSFjVNUIkNNQxwKQE7WHpoHoNswYeoFkuYpYSKK4mzFzMV/dB0DVAy4lnNU=", + ); + const signature = fromBase64( + "1nUcIH0CLT0/nQ0mBTDrT6kMG20NY/PsH7P2gc4bpYNGLEYjBmdWevXUJouSE/9A/60QG9cYeqyTe5kFDeIPxQ==", + ); + expect(() => encodeSecp256k1Signature(pubkey, signature)).toThrowError( + /public key must be compressed secp256k1/i, + ); + }); + + it("throws if signature contains recovery byte", () => { + const pubkey = fromBase64("AtQaCqFnshaZQp6rIkvAPyzThvCvXSDO+9AzbxVErqJP"); + const signature = Uint8Array.from([ + ...fromBase64( + "1nUcIH0CLT0/nQ0mBTDrT6kMG20NY/PsH7P2gc4bpYNGLEYjBmdWevXUJouSE/9A/60QG9cYeqyTe5kFDeIPxQ==", + ), + 99, + ]); + expect(() => encodeSecp256k1Signature(pubkey, signature)).toThrowError( + /signature must be 64 bytes long/i, + ); + }); + }); + + describe("decodeSignature", () => { + it("works for secp256k1", () => { + const signature: StdSignature = { + // eslint-disable-next-line @typescript-eslint/camelcase + pub_key: { + type: "tendermint/PubKeySecp256k1", + value: "AtQaCqFnshaZQp6rIkvAPyzThvCvXSDO+9AzbxVErqJP", + }, + signature: "1nUcIH0CLT0/nQ0mBTDrT6kMG20NY/PsH7P2gc4bpYNGLEYjBmdWevXUJouSE/9A/60QG9cYeqyTe5kFDeIPxQ==", + }; + expect(decodeSignature(signature)).toEqual({ + pubkey: fromBase64("AtQaCqFnshaZQp6rIkvAPyzThvCvXSDO+9AzbxVErqJP"), + signature: fromBase64( + "1nUcIH0CLT0/nQ0mBTDrT6kMG20NY/PsH7P2gc4bpYNGLEYjBmdWevXUJouSE/9A/60QG9cYeqyTe5kFDeIPxQ==", + ), + }); + }); + }); +}); diff --git a/packages/sdk38/src/signature.ts b/packages/sdk38/src/signature.ts new file mode 100644 index 00000000..d31eb119 --- /dev/null +++ b/packages/sdk38/src/signature.ts @@ -0,0 +1,39 @@ +import { Encoding } from "@iov/encoding"; + +import { encodeSecp256k1Pubkey } from "./pubkey"; +import { pubkeyType, StdSignature } from "./types"; + +/** + * Takes a binary pubkey and signature to create a signature object + * + * @param pubkey a compressed secp256k1 public key + * @param signature a 64 byte fixed length representation of secp256k1 signature components r and s + */ +export function encodeSecp256k1Signature(pubkey: Uint8Array, signature: Uint8Array): StdSignature { + if (signature.length !== 64) { + throw new Error( + "Signature must be 64 bytes long. Cosmos SDK uses a 2x32 byte fixed length encoding for the secp256k1 signature integers r and s.", + ); + } + + return { + // eslint-disable-next-line @typescript-eslint/camelcase + pub_key: encodeSecp256k1Pubkey(pubkey), + signature: Encoding.toBase64(signature), + }; +} + +export function decodeSignature( + signature: StdSignature, +): { readonly pubkey: Uint8Array; readonly signature: Uint8Array } { + switch (signature.pub_key.type) { + // Note: please don't add cases here without writing additional unit tests + case pubkeyType.secp256k1: + return { + pubkey: Encoding.fromBase64(signature.pub_key.value), + signature: Encoding.fromBase64(signature.signature), + }; + default: + throw new Error("Unsupported pubkey type"); + } +} diff --git a/packages/sdk38/src/signingcosmosclient.spec.ts b/packages/sdk38/src/signingcosmosclient.spec.ts new file mode 100644 index 00000000..5054d925 --- /dev/null +++ b/packages/sdk38/src/signingcosmosclient.spec.ts @@ -0,0 +1,78 @@ +import { assert } from "@iov/utils"; + +import { Coin } from "./coins"; +import { PrivateCosmWasmClient } from "./cosmosclient"; +import { Secp256k1Pen } from "./pen"; +import { SigningCosmosClient } from "./signingcosmosclient"; +import { makeRandomAddress, pendingWithoutWasmd } from "./testutils.spec"; + +const httpUrl = "http://localhost:1317"; + +const faucet = { + mnemonic: + "economy stock theory fatal elder harbor betray wasp final emotion task crumble siren bottom lizard educate guess current outdoor pair theory focus wife stone", + pubkey: { + type: "tendermint/PubKeySecp256k1", + value: "A08EGB7ro1ORuFhjOnZcSgwYlpe0DSFjVNUIkNNQxwKQ", + }, + address: "cosmos1pkptre7fdkl6gfrzlesjjvhxhlc3r4gmmk8rs6", +}; + +describe("SigningCosmosClient", () => { + describe("makeReadOnly", () => { + it("can be constructed", async () => { + const pen = await Secp256k1Pen.fromMnemonic(faucet.mnemonic); + const client = new SigningCosmosClient(httpUrl, faucet.address, (signBytes) => pen.sign(signBytes)); + expect(client).toBeTruthy(); + }); + }); + + describe("getHeight", () => { + it("always uses authAccount implementation", async () => { + pendingWithoutWasmd(); + const pen = await Secp256k1Pen.fromMnemonic(faucet.mnemonic); + const client = new SigningCosmosClient(httpUrl, faucet.address, (signBytes) => pen.sign(signBytes)); + + const openedClient = (client as unknown) as PrivateCosmWasmClient; + const blockLatestSpy = spyOn(openedClient.restClient, "blocksLatest").and.callThrough(); + const authAccountsSpy = spyOn(openedClient.restClient, "authAccounts").and.callThrough(); + + const height = await client.getHeight(); + expect(height).toBeGreaterThan(0); + + expect(blockLatestSpy).toHaveBeenCalledTimes(0); + expect(authAccountsSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe("sendTokens", () => { + it("works", async () => { + pendingWithoutWasmd(); + const pen = await Secp256k1Pen.fromMnemonic(faucet.mnemonic); + const client = new SigningCosmosClient(httpUrl, faucet.address, (signBytes) => pen.sign(signBytes)); + + // instantiate + const transferAmount: readonly Coin[] = [ + { + amount: "7890", + denom: "ucosm", + }, + ]; + const beneficiaryAddress = makeRandomAddress(); + + // no tokens here + const before = await client.getAccount(beneficiaryAddress); + expect(before).toBeUndefined(); + + // send + const result = await client.sendTokens(beneficiaryAddress, transferAmount, "for dinner"); + const [firstLog] = result.logs; + expect(firstLog).toBeTruthy(); + + // got tokens + const after = await client.getAccount(beneficiaryAddress); + assert(after); + expect(after.balance).toEqual(transferAmount); + }); + }); +}); diff --git a/packages/sdk38/src/signingcosmosclient.ts b/packages/sdk38/src/signingcosmosclient.ts new file mode 100644 index 00000000..f20c7fef --- /dev/null +++ b/packages/sdk38/src/signingcosmosclient.ts @@ -0,0 +1,145 @@ +import { Coin, coins } from "./coins"; +import { Account, CosmosClient, GetNonceResult, PostTxResult } from "./cosmosclient"; +import { makeSignBytes } from "./encoding"; +import { Log } from "./logs"; +import { BroadcastMode } from "./restclient"; +import { MsgSend, StdFee, StdSignature } from "./types"; + +export interface SigningCallback { + (signBytes: Uint8Array): Promise; +} + +export interface FeeTable { + readonly upload: StdFee; + readonly init: StdFee; + readonly exec: StdFee; + readonly send: StdFee; +} + +const defaultFees: FeeTable = { + upload: { + amount: coins(25000, "ucosm"), + gas: "1000000", // one million + }, + init: { + amount: coins(12500, "ucosm"), + gas: "500000", // 500k + }, + exec: { + amount: coins(5000, "ucosm"), + gas: "200000", // 200k + }, + send: { + amount: coins(2000, "ucosm"), + gas: "80000", // 80k + }, +}; + +export interface UploadMeta { + /** The source URL */ + readonly source?: string; + /** The builder tag */ + readonly builder?: string; +} + +export interface UploadResult { + /** Size of the original wasm code in bytes */ + readonly originalSize: number; + /** A hex encoded sha256 checksum of the original wasm code (that is stored on chain) */ + readonly originalChecksum: string; + /** Size of the compressed wasm code in bytes */ + readonly compressedSize: number; + /** A hex encoded sha256 checksum of the compressed wasm code (that stored in the transaction) */ + readonly compressedChecksum: string; + /** The ID of the code asigned by the chain */ + readonly codeId: number; + readonly logs: readonly Log[]; + /** Transaction hash (might be used as transaction ID). Guaranteed to be non-empty upper-case hex */ + readonly transactionHash: string; +} + +export interface InstantiateResult { + /** The address of the newly instantiated contract */ + readonly contractAddress: string; + readonly logs: readonly Log[]; + /** Transaction hash (might be used as transaction ID). Guaranteed to be non-empty upper-case hex */ + readonly transactionHash: string; +} + +export interface ExecuteResult { + readonly logs: readonly Log[]; + /** Transaction hash (might be used as transaction ID). Guaranteed to be non-empty upper-case hex */ + readonly transactionHash: string; +} + +export class SigningCosmosClient extends CosmosClient { + public readonly senderAddress: string; + + private readonly signCallback: SigningCallback; + private readonly fees: FeeTable; + + /** + * Creates a new client with signing capability to interact with a CosmWasm blockchain. This is the bigger brother of CosmWasmClient. + * + * This instance does a lot of caching. In order to benefit from that you should try to use one instance + * for the lifetime of your application. When switching backends, a new instance must be created. + * + * @param apiUrl The URL of a Cosmos SDK light client daemon API (sometimes called REST server or REST API) + * @param senderAddress The address that will sign and send transactions using this instance + * @param signCallback An asynchonous callback to create a signature for a given transaction. This can be implemented using secure key stores that require user interaction. + * @param customFees The fees that are paid for transactions + * @param broadcastMode Defines at which point of the transaction processing the postTx method (i.e. transaction broadcasting) returns + */ + public constructor( + apiUrl: string, + senderAddress: string, + signCallback: SigningCallback, + customFees?: Partial, + broadcastMode = BroadcastMode.Block, + ) { + super(apiUrl, broadcastMode); + this.anyValidAddress = senderAddress; + + this.senderAddress = senderAddress; + this.signCallback = signCallback; + this.fees = { ...defaultFees, ...(customFees || {}) }; + } + + public async getNonce(address?: string): Promise { + return super.getNonce(address || this.senderAddress); + } + + public async getAccount(address?: string): Promise { + return super.getAccount(address || this.senderAddress); + } + + public async sendTokens( + recipientAddress: string, + transferAmount: readonly Coin[], + memo = "", + ): Promise { + const sendMsg: MsgSend = { + type: "cosmos-sdk/MsgSend", + value: { + // eslint-disable-next-line @typescript-eslint/camelcase + from_address: this.senderAddress, + // eslint-disable-next-line @typescript-eslint/camelcase + to_address: recipientAddress, + amount: transferAmount, + }, + }; + const fee = this.fees.send; + const { accountNumber, sequence } = await this.getNonce(); + const chainId = await this.getChainId(); + const signBytes = makeSignBytes([sendMsg], fee, chainId, memo, accountNumber, sequence); + const signature = await this.signCallback(signBytes); + const signedTx = { + msg: [sendMsg], + fee: fee, + memo: memo, + signatures: [signature], + }; + + return this.postTx(signedTx); + } +} diff --git a/packages/sdk38/src/testdata/cosmoshub.json b/packages/sdk38/src/testdata/cosmoshub.json new file mode 100644 index 00000000..cb33539c --- /dev/null +++ b/packages/sdk38/src/testdata/cosmoshub.json @@ -0,0 +1,44 @@ +{ + "//source": "https://hubble.figment.network/cosmos/chains/cosmoshub-3/blocks/415777/transactions/2BD600EA6090FC75FD844CA73542CC90A828770F4C01C5B483C3C1C43CCB65F4?format=json", + "tx": { + "type": "cosmos-sdk/StdTx", + "value": { + "msg": [ + { + "type": "cosmos-sdk/MsgSend", + "value": { + "from_address": "cosmos1txqfn5jmcts0x0q7krdxj8tgf98tj0965vqlmq", + "to_address": "cosmos1nynns8ex9fq6sjjfj8k79ymkdz4sqth06xexae", + "amount": [ + { + "denom": "uatom", + "amount": "35997500" + } + ] + } + } + ], + "fee": { + "amount": [ + { + "denom": "uatom", + "amount": "2500" + } + ], + "gas": "100000" + }, + "signatures": [ + { + "pub_key": { + "type": "tendermint/PubKeySecp256k1", + "value": "A5qFcJBJvEK/fOmEAY0DHNWwSRZ9TEfNZyH8VoVvDtAq" + }, + "signature": "NK1Oy4EUGAsoC03c1wi9GG03JC/39LEdautC5Jk643oIbEPqeXHMwaqbdvO/Jws0X/NAXaN8SAy2KNY5Qml+5Q==" + } + ], + "memo": "" + } + }, + "tx_data": "ygEoKBapCkOoo2GaChRZgJnSW8Lg8zwesNppHWhJTrk8uhIUmSc4HyYqQahKSZHt4pN2aKsALu8aEQoFdWF0b20SCDM1OTk3NTAwEhMKDQoFdWF0b20SBDI1MDAQoI0GGmoKJuta6YchA5qFcJBJvEK/fOmEAY0DHNWwSRZ9TEfNZyH8VoVvDtAqEkA0rU7LgRQYCygLTdzXCL0YbTckL/f0sR1q60LkmTrjeghsQ+p5cczBqpt2878nCzRf80Bdo3xIDLYo1jlCaX7l", + "id": "2BD600EA6090FC75FD844CA73542CC90A828770F4C01C5B483C3C1C43CCB65F4" +} diff --git a/packages/sdk38/src/testdata/txresponse1.json b/packages/sdk38/src/testdata/txresponse1.json new file mode 100644 index 00000000..703ea696 --- /dev/null +++ b/packages/sdk38/src/testdata/txresponse1.json @@ -0,0 +1,57 @@ +{ + "height": "15888", + "txhash": "672DEDE8EF4DE8B5818959F417CCA357079D4D7A19C4B65443C7FBF8176AABF9", + "raw_log": "[{\"msg_index\":0,\"log\":\"\",\"events\":[{\"type\":\"message\",\"attributes\":[{\"key\":\"action\",\"value\":\"send\"},{\"key\":\"sender\",\"value\":\"cosmos1pkptre7fdkl6gfrzlesjjvhxhlc3r4gmmk8rs6\"},{\"key\":\"module\",\"value\":\"bank\"}]},{\"type\":\"transfer\",\"attributes\":[{\"key\":\"recipient\",\"value\":\"cosmos1t70qnpr0az8tf7py83m4ue5y89w58lkjmx0yq2\"},{\"key\":\"amount\",\"value\":\"75000ucosm\"}]}]}]", + "logs": [ + { + "msg_index": 0, + "log": "", + "events": [ + { + "type": "message", + "attributes": [ + { "key": "action", "value": "send" }, + { "key": "sender", "value": "cosmos1pkptre7fdkl6gfrzlesjjvhxhlc3r4gmmk8rs6" }, + { "key": "module", "value": "bank" } + ] + }, + { + "type": "transfer", + "attributes": [ + { "key": "recipient", "value": "cosmos1t70qnpr0az8tf7py83m4ue5y89w58lkjmx0yq2" }, + { "key": "amount", "value": "75000ucosm" } + ] + } + ] + } + ], + "gas_wanted": "200000", + "gas_used": "65407", + "tx": { + "type": "cosmos-sdk/StdTx", + "value": { + "msg": [ + { + "type": "cosmos-sdk/MsgSend", + "value": { + "from_address": "cosmos1pkptre7fdkl6gfrzlesjjvhxhlc3r4gmmk8rs6", + "to_address": "cosmos1t70qnpr0az8tf7py83m4ue5y89w58lkjmx0yq2", + "amount": [{ "denom": "ucosm", "amount": "75000" }] + } + } + ], + "fee": { "amount": [{ "denom": "ucosm", "amount": "5000" }], "gas": "200000" }, + "signatures": [ + { + "pub_key": { + "type": "tendermint/PubKeySecp256k1", + "value": "A08EGB7ro1ORuFhjOnZcSgwYlpe0DSFjVNUIkNNQxwKQ" + }, + "signature": "US7oH8S/8TxVrtBQkOhHxAM+oDB2spNAEawgh6H8CCFLRMOJK+uvQZZ6ceUgUsvDbxwCz7re1RU272fymMYRZQ==" + } + ], + "memo": "My first payment" + } + }, + "timestamp": "2020-02-14T11:25:55Z" +} diff --git a/packages/sdk38/src/testdata/txresponse2.json b/packages/sdk38/src/testdata/txresponse2.json new file mode 100644 index 00000000..0d1a37f9 --- /dev/null +++ b/packages/sdk38/src/testdata/txresponse2.json @@ -0,0 +1,57 @@ +{ + "height": "16456", + "txhash": "7BFE4B93AF190F60132C62D08FDF50BE462FBCE374EB13D3FD0C32461E771EC0", + "raw_log": "[{\"msg_index\":0,\"log\":\"\",\"events\":[{\"type\":\"message\",\"attributes\":[{\"key\":\"action\",\"value\":\"send\"},{\"key\":\"sender\",\"value\":\"cosmos1pkptre7fdkl6gfrzlesjjvhxhlc3r4gmmk8rs6\"},{\"key\":\"module\",\"value\":\"bank\"}]},{\"type\":\"transfer\",\"attributes\":[{\"key\":\"recipient\",\"value\":\"cosmos1t70qnpr0az8tf7py83m4ue5y89w58lkjmx0yq2\"},{\"key\":\"amount\",\"value\":\"75000ucosm\"}]}]}]", + "logs": [ + { + "msg_index": 0, + "log": "", + "events": [ + { + "type": "message", + "attributes": [ + { "key": "action", "value": "send" }, + { "key": "sender", "value": "cosmos1pkptre7fdkl6gfrzlesjjvhxhlc3r4gmmk8rs6" }, + { "key": "module", "value": "bank" } + ] + }, + { + "type": "transfer", + "attributes": [ + { "key": "recipient", "value": "cosmos1t70qnpr0az8tf7py83m4ue5y89w58lkjmx0yq2" }, + { "key": "amount", "value": "75000ucosm" } + ] + } + ] + } + ], + "gas_wanted": "200000", + "gas_used": "65407", + "tx": { + "type": "cosmos-sdk/StdTx", + "value": { + "msg": [ + { + "type": "cosmos-sdk/MsgSend", + "value": { + "from_address": "cosmos1pkptre7fdkl6gfrzlesjjvhxhlc3r4gmmk8rs6", + "to_address": "cosmos1t70qnpr0az8tf7py83m4ue5y89w58lkjmx0yq2", + "amount": [{ "denom": "ucosm", "amount": "75000" }] + } + } + ], + "fee": { "amount": [{ "denom": "ucosm", "amount": "5000" }], "gas": "200000" }, + "signatures": [ + { + "pub_key": { + "type": "tendermint/PubKeySecp256k1", + "value": "A08EGB7ro1ORuFhjOnZcSgwYlpe0DSFjVNUIkNNQxwKQ" + }, + "signature": "ltvd9Rb3RF4zjbUVrpDpkok34g+py7XR8ZcM0tZUYRxxVdcMEin010x+ZFd/mOuutPj9fDmSENnienc/yi4msw==" + } + ], + "memo": "My first payment" + } + }, + "timestamp": "2020-02-14T11:35:41Z" +} diff --git a/packages/sdk38/src/testdata/txresponse3.json b/packages/sdk38/src/testdata/txresponse3.json new file mode 100644 index 00000000..8ffd0727 --- /dev/null +++ b/packages/sdk38/src/testdata/txresponse3.json @@ -0,0 +1,57 @@ +{ + "height": "20730", + "txhash": "625BC75E697F73DA037387C34002BB2F682E7ACDCC4E015D3E90420516C6D0C8", + "raw_log": "[{\"msg_index\":0,\"log\":\"\",\"events\":[{\"type\":\"message\",\"attributes\":[{\"key\":\"action\",\"value\":\"send\"},{\"key\":\"sender\",\"value\":\"cosmos1pkptre7fdkl6gfrzlesjjvhxhlc3r4gmmk8rs6\"},{\"key\":\"module\",\"value\":\"bank\"}]},{\"type\":\"transfer\",\"attributes\":[{\"key\":\"recipient\",\"value\":\"cosmos1t70qnpr0az8tf7py83m4ue5y89w58lkjmx0yq2\"},{\"key\":\"amount\",\"value\":\"75000ucosm\"}]}]}]", + "logs": [ + { + "msg_index": 0, + "log": "", + "events": [ + { + "type": "message", + "attributes": [ + { "key": "action", "value": "send" }, + { "key": "sender", "value": "cosmos1pkptre7fdkl6gfrzlesjjvhxhlc3r4gmmk8rs6" }, + { "key": "module", "value": "bank" } + ] + }, + { + "type": "transfer", + "attributes": [ + { "key": "recipient", "value": "cosmos1t70qnpr0az8tf7py83m4ue5y89w58lkjmx0yq2" }, + { "key": "amount", "value": "75000ucosm" } + ] + } + ] + } + ], + "gas_wanted": "200000", + "gas_used": "65407", + "tx": { + "type": "cosmos-sdk/StdTx", + "value": { + "msg": [ + { + "type": "cosmos-sdk/MsgSend", + "value": { + "from_address": "cosmos1pkptre7fdkl6gfrzlesjjvhxhlc3r4gmmk8rs6", + "to_address": "cosmos1t70qnpr0az8tf7py83m4ue5y89w58lkjmx0yq2", + "amount": [{ "denom": "ucosm", "amount": "75000" }] + } + } + ], + "fee": { "amount": [{ "denom": "ucosm", "amount": "5000" }], "gas": "200000" }, + "signatures": [ + { + "pub_key": { + "type": "tendermint/PubKeySecp256k1", + "value": "A08EGB7ro1ORuFhjOnZcSgwYlpe0DSFjVNUIkNNQxwKQ" + }, + "signature": "eOFGl1tIHDMv3JdCK9fRSikVbYUD8+B0ksb3dJFya8MPYgpEpdSA7zZc+5n/cW6LR/BJdib4nqmJQv1yD9lm3g==" + } + ], + "memo": "My first payment" + } + }, + "timestamp": "2020-02-14T12:48:56Z" +} diff --git a/packages/sdk38/src/testutils.spec.ts b/packages/sdk38/src/testutils.spec.ts new file mode 100644 index 00000000..7b8f2330 --- /dev/null +++ b/packages/sdk38/src/testutils.spec.ts @@ -0,0 +1,58 @@ +import { Random } from "@iov/crypto"; +import { Bech32 } from "@iov/encoding"; + +export function makeRandomAddress(): string { + return Bech32.encode("cosmos", Random.getBytes(20)); +} + +export const nonNegativeIntegerMatcher = /^[0-9]+$/; +export const tendermintIdMatcher = /^[0-9A-F]{64}$/; +export const tendermintOptionalIdMatcher = /^([0-9A-F]{64}|)$/; +export const tendermintAddressMatcher = /^[0-9A-F]{40}$/; +export const tendermintShortHashMatcher = /^[0-9a-f]{40}$/; +export const semverMatcher = /^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/; + +// https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki#bech32 +export const bech32AddressMatcher = /^[\x21-\x7e]{1,83}1[02-9ac-hj-np-z]{38}$/; + +export const wasmd = { + endpoint: "http://localhost:1317", + chainId: "testing", +}; + +export const faucet = { + mnemonic: + "economy stock theory fatal elder harbor betray wasp final emotion task crumble siren bottom lizard educate guess current outdoor pair theory focus wife stone", + pubkey: { + type: "tendermint/PubKeySecp256k1", + value: "A08EGB7ro1ORuFhjOnZcSgwYlpe0DSFjVNUIkNNQxwKQ", + }, + address: "cosmos1pkptre7fdkl6gfrzlesjjvhxhlc3r4gmmk8rs6", +}; + +/** Unused account */ +export const unused = { + pubkey: { + type: "tendermint/PubKeySecp256k1", + value: "ArkCaFUJ/IH+vKBmNRCdUVl3mCAhbopk9jjW4Ko4OfRQ", + }, + address: "cosmos1cjsxept9rkggzxztslae9ndgpdyt2408lk850u", + accountNumber: 9, + sequence: 0, +}; + +export function wasmdEnabled(): boolean { + return !!process.env.WASMD_ENABLED; +} + +export function pendingWithoutWasmd(): void { + if (!wasmdEnabled()) { + return pending("Set WASMD_ENABLED to enable Wasmd based tests"); + } +} + +/** Returns first element. Throws if array has a different length than 1. */ +export function fromOneElementArray(elements: ArrayLike): T { + if (elements.length !== 1) throw new Error(`Expected exactly one element but got ${elements.length}`); + return elements[0]; +} diff --git a/packages/sdk38/src/types.ts b/packages/sdk38/src/types.ts new file mode 100644 index 00000000..7d10fe3c --- /dev/null +++ b/packages/sdk38/src/types.ts @@ -0,0 +1,105 @@ +import { Encoding } from "@iov/encoding"; + +import { Coin } from "./coins"; + +const { fromBase64, fromHex } = Encoding; + +/** An Amino/Cosmos SDK StdTx */ +export interface StdTx { + readonly msg: ReadonlyArray; + readonly fee: StdFee; + readonly signatures: ReadonlyArray; + readonly memo: string | undefined; +} + +export function isStdTx(txValue: unknown): txValue is StdTx { + const { memo, msg, fee, signatures } = txValue as StdTx; + return ( + typeof memo === "string" && Array.isArray(msg) && typeof fee === "object" && Array.isArray(signatures) + ); +} + +export interface CosmosSdkTx { + readonly type: string; + readonly value: StdTx; +} + +interface MsgTemplate { + readonly type: string; + readonly value: any; +} + +/** A Cosmos SDK token transfer message */ +export interface MsgSend extends MsgTemplate { + readonly type: "cosmos-sdk/MsgSend"; + readonly value: { + /** Bech32 account address */ + readonly from_address: string; + /** Bech32 account address */ + readonly to_address: string; + readonly amount: ReadonlyArray; + }; +} + +export type Msg = MsgSend | MsgTemplate; + +export function isMsgSend(msg: Msg): msg is MsgSend { + return (msg as MsgSend).type === "cosmos-sdk/MsgSend"; +} + +export interface StdFee { + readonly amount: ReadonlyArray; + readonly gas: string; +} + +export interface StdSignature { + readonly pub_key: PubKey; + readonly signature: string; +} + +export interface PubKey { + // type is one of the strings defined in pubkeyTypes + // I don't use a string literal union here as that makes trouble with json test data: + // https://github.com/confio/cosmwasm-js/pull/44#pullrequestreview-353280504 + readonly type: string; + // Value field is base64-encoded in all cases + // Note: if type is Secp256k1, this must contain a COMPRESSED pubkey - to encode from bcp/keycontrol land, you must compress it first + readonly value: string; +} + +export const pubkeyType = { + /** @see https://github.com/tendermint/tendermint/blob/v0.33.0/crypto/ed25519/ed25519.go#L22 */ + secp256k1: "tendermint/PubKeySecp256k1" as const, + /** @see https://github.com/tendermint/tendermint/blob/v0.33.0/crypto/secp256k1/secp256k1.go#L23 */ + ed25519: "tendermint/PubKeyEd25519" as const, + /** @see https://github.com/tendermint/tendermint/blob/v0.33.0/crypto/sr25519/codec.go#L12 */ + sr25519: "tendermint/PubKeySr25519" as const, +}; + +export const pubkeyTypes: readonly string[] = [pubkeyType.secp256k1, pubkeyType.ed25519, pubkeyType.sr25519]; + +export interface WasmData { + // key is hex-encoded + readonly key: string; + // value is base64 encoded + readonly val: string; +} + +// Model is a parsed WasmData object +export interface Model { + readonly key: Uint8Array; + readonly val: Uint8Array; +} + +export function parseWasmData({ key, val }: WasmData): Model { + return { + key: fromHex(key), + val: fromBase64(val), + }; +} + +/** + * An object containing a parsed JSON document. The result of JSON.parse(). + * This doen't privide any type safety over `any` but expresses intent in the code. + */ +export type JsonObject = any; diff --git a/packages/sdk38/tsconfig.json b/packages/sdk38/tsconfig.json new file mode 100644 index 00000000..167e8c02 --- /dev/null +++ b/packages/sdk38/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "outDir": "build", + "declarationDir": "build/types", + "rootDir": "src" + }, + "include": [ + "src/**/*" + ] +} diff --git a/packages/sdk38/typedoc.js b/packages/sdk38/typedoc.js new file mode 100644 index 00000000..e2387c7d --- /dev/null +++ b/packages/sdk38/typedoc.js @@ -0,0 +1,14 @@ +const packageJson = require("./package.json"); + +module.exports = { + src: ["./src"], + out: "docs", + exclude: "**/*.spec.ts", + target: "es6", + name: `${packageJson.name} Documentation`, + readme: "README.md", + mode: "file", + excludeExternals: true, + excludeNotExported: true, + excludePrivate: true, +}; diff --git a/packages/sdk38/types/address.d.ts b/packages/sdk38/types/address.d.ts new file mode 100644 index 00000000..9eb5af82 --- /dev/null +++ b/packages/sdk38/types/address.d.ts @@ -0,0 +1,3 @@ +import { PubKey } from "./types"; +export declare function rawSecp256k1PubkeyToAddress(pubkeyRaw: Uint8Array, prefix: string): string; +export declare function pubkeyToAddress(pubkey: PubKey, prefix: string): string; diff --git a/packages/sdk38/types/coins.d.ts b/packages/sdk38/types/coins.d.ts new file mode 100644 index 00000000..9208f3e2 --- /dev/null +++ b/packages/sdk38/types/coins.d.ts @@ -0,0 +1,8 @@ +export interface Coin { + readonly denom: string; + readonly amount: string; +} +/** Creates a coin */ +export declare function coin(amount: number, denom: string): Coin; +/** Creates a list of coins with one element */ +export declare function coins(amount: number, denom: string): Coin[]; diff --git a/packages/sdk38/types/cosmosclient.d.ts b/packages/sdk38/types/cosmosclient.d.ts new file mode 100644 index 00000000..0dcc72bb --- /dev/null +++ b/packages/sdk38/types/cosmosclient.d.ts @@ -0,0 +1,175 @@ +import { Coin } from "./coins"; +import { Log } from "./logs"; +import { BroadcastMode, RestClient } from "./restclient"; +import { CosmosSdkTx, JsonObject, PubKey, StdTx } from "./types"; +export interface GetNonceResult { + readonly accountNumber: number; + readonly sequence: number; +} +export interface Account { + /** Bech32 account address */ + readonly address: string; + readonly balance: ReadonlyArray; + readonly pubkey: PubKey | undefined; + readonly accountNumber: number; + readonly sequence: number; +} +export interface PostTxResult { + readonly logs: readonly Log[]; + readonly rawLog: string; + /** Transaction hash (might be used as transaction ID). Guaranteed to be non-empty upper-case hex */ + readonly transactionHash: string; +} +export interface SearchByIdQuery { + readonly id: string; +} +export interface SearchByHeightQuery { + readonly height: number; +} +export interface SearchBySentFromOrToQuery { + readonly sentFromOrTo: string; +} +/** + * This query type allows you to pass arbitrary key/value pairs to the backend. It is + * more powerful and slightly lower level than the other search options. + */ +export interface SearchByTagsQuery { + readonly tags: readonly { + readonly key: string; + readonly value: string; + }[]; +} +export declare type SearchTxQuery = + | SearchByIdQuery + | SearchByHeightQuery + | SearchBySentFromOrToQuery + | SearchByTagsQuery; +export interface SearchTxFilter { + readonly minHeight?: number; + readonly maxHeight?: number; +} +export interface Code { + readonly id: number; + /** Bech32 account address */ + readonly creator: string; + /** Hex-encoded sha256 hash of the code stored here */ + readonly checksum: string; + readonly source?: string; + readonly builder?: string; +} +export interface CodeDetails extends Code { + /** The original wasm bytes */ + readonly data: Uint8Array; +} +export interface Contract { + readonly address: string; + readonly codeId: number; + /** Bech32 account address */ + readonly creator: string; + readonly label: string; +} +export interface ContractDetails extends Contract { + /** Argument passed on initialization of the contract */ + readonly initMsg: object; +} +/** 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 logs: readonly Log[]; + readonly tx: CosmosSdkTx; + /** The gas limit as set by the user */ + readonly gasWanted?: number; + /** The gas used by the execution */ + readonly gasUsed?: number; + /** An RFC 3339 time string like e.g. '2020-02-15T10:39:10.4696305Z' */ + readonly timestamp: string; +} +export interface BlockHeader { + readonly version: { + readonly block: string; + readonly app: string; + }; + readonly height: number; + readonly chainId: string; + /** An RFC 3339 time string like e.g. '2020-02-15T10:39:10.4696305Z' */ + readonly time: string; +} +export interface Block { + /** The ID is a hash of the block header (uppercase hex) */ + readonly id: string; + readonly header: BlockHeader; + /** Array of raw transactions */ + readonly txs: ReadonlyArray; +} +/** Use for testing only */ +export interface PrivateCosmWasmClient { + readonly restClient: RestClient; +} +export declare class CosmosClient { + protected readonly restClient: RestClient; + /** Any address the chain considers valid (valid bech32 with proper prefix) */ + protected anyValidAddress: string | undefined; + private readonly codesCache; + private chainId; + /** + * Creates a new client to interact with a CosmWasm blockchain. + * + * This instance does a lot of caching. In order to benefit from that you should try to use one instance + * for the lifetime of your application. When switching backends, a new instance must be created. + * + * @param apiUrl The URL of a Cosmos SDK light client daemon API (sometimes called REST server or REST API) + * @param broadcastMode Defines at which point of the transaction processing the postTx method (i.e. transaction broadcasting) returns + */ + constructor(apiUrl: string, broadcastMode?: BroadcastMode); + getChainId(): Promise; + getHeight(): Promise; + /** + * Returns a 32 byte upper-case hex transaction hash (typically used as the transaction ID) + */ + getIdentifier(tx: CosmosSdkTx): Promise; + /** + * Returns account number and sequence. + * + * Throws if the account does not exist on chain. + * + * @param address returns data for this address. When unset, the client's sender adddress is used. + */ + getNonce(address: string): Promise; + getAccount(address: string): Promise; + /** + * Gets block header and meta + * + * @param height The height of the block. If undefined, the latest height is used. + */ + getBlock(height?: number): Promise; + searchTx(query: SearchTxQuery, filter?: SearchTxFilter): Promise; + postTx(tx: StdTx): Promise; + getCodes(): Promise; + getCodeDetails(codeId: number): Promise; + getContracts(codeId: number): Promise; + /** + * Throws an error if no contract was found at the address + */ + getContract(address: string): Promise; + /** + * Returns the data at the key if present (raw contract dependent storage data) + * or null if no data at this key. + * + * Promise is rejected when contract does not exist. + */ + queryContractRaw(address: string, key: Uint8Array): Promise; + /** + * Makes a smart query on the contract, returns the parsed JSON document. + * + * Promise is rejected when contract does not exist. + * Promise is rejected for invalid query format. + * Promise is rejected for invalid response format. + */ + queryContractSmart(address: string, queryMsg: object): Promise; + private txsQuery; +} diff --git a/packages/sdk38/types/decoding.d.ts b/packages/sdk38/types/decoding.d.ts new file mode 100644 index 00000000..fd0c5746 --- /dev/null +++ b/packages/sdk38/types/decoding.d.ts @@ -0,0 +1,2 @@ +import { StdTx } from "./types"; +export declare function unmarshalTx(data: Uint8Array): StdTx; diff --git a/packages/sdk38/types/encoding.d.ts b/packages/sdk38/types/encoding.d.ts new file mode 100644 index 00000000..4d32b80b --- /dev/null +++ b/packages/sdk38/types/encoding.d.ts @@ -0,0 +1,10 @@ +import { Msg, StdFee, StdTx } from "./types"; +export declare function marshalTx(tx: StdTx): Uint8Array; +export declare function makeSignBytes( + msgs: readonly Msg[], + fee: StdFee, + chainId: string, + memo: string, + accountNumber: number, + sequence: number, +): Uint8Array; diff --git a/packages/sdk38/types/index.d.ts b/packages/sdk38/types/index.d.ts new file mode 100644 index 00000000..e42bf6c3 --- /dev/null +++ b/packages/sdk38/types/index.d.ts @@ -0,0 +1,11 @@ +import * as logs from "./logs"; +import * as types from "./types"; +export { logs, types }; +export { pubkeyToAddress } from "./address"; +export { Coin, coin, coins } from "./coins"; +export { unmarshalTx } from "./decoding"; +export { makeSignBytes, marshalTx } from "./encoding"; +export { BroadcastMode, RestClient, TxsResponse } from "./restclient"; +export { decodeBech32Pubkey, encodeBech32Pubkey, encodeSecp256k1Pubkey } from "./pubkey"; +export { findSequenceForSignedTx } from "./sequence"; +export { encodeSecp256k1Signature, decodeSignature } from "./signature"; diff --git a/packages/sdk38/types/logs.d.ts b/packages/sdk38/types/logs.d.ts new file mode 100644 index 00000000..2e1decb3 --- /dev/null +++ b/packages/sdk38/types/logs.d.ts @@ -0,0 +1,28 @@ +export interface Attribute { + readonly key: string; + readonly value: string; +} +export interface Event { + readonly type: string; + readonly attributes: readonly Attribute[]; +} +export interface Log { + readonly msg_index: number; + readonly log: string; + readonly events: readonly Event[]; +} +export declare function parseAttribute(input: unknown): Attribute; +export declare function parseEvent(input: unknown): Event; +export declare function parseLog(input: unknown): Log; +export declare function parseLogs(input: unknown): readonly Log[]; +/** + * Searches in logs for the first event of the given event type and in that event + * for the first first attribute with the given attribute key. + * + * Throws if the attribute was not found. + */ +export declare function findAttribute( + logs: readonly Log[], + eventType: "message" | "transfer", + attrKey: string, +): Attribute; diff --git a/packages/sdk38/types/pen.d.ts b/packages/sdk38/types/pen.d.ts new file mode 100644 index 00000000..068db3cb --- /dev/null +++ b/packages/sdk38/types/pen.d.ts @@ -0,0 +1,33 @@ +import { Slip10RawIndex } from "@iov/crypto"; +import { StdSignature } from "./types"; +export declare type PrehashType = "sha256" | "sha512" | null; +/** + * A pen is the most basic tool you can think of for signing. It works + * everywhere and can be used intuitively by everyone. However, it does not + * come with a great amount of features. End of semi suitable metaphor. + * + * This wraps a single keypair and allows for signing. + * + * Non-goals of this types are: multi account support, persistency, data migrations, + * obfuscation of sensitive data. + */ +export interface Pen { + readonly pubkey: Uint8Array; + readonly sign: (signBytes: Uint8Array, prehashType?: PrehashType) => Promise; +} +/** + * The Cosmoshub derivation path in the form `m/44'/118'/0'/0/a` + * with 0-based account index `a`. + */ +export declare function makeCosmoshubPath(a: number): readonly Slip10RawIndex[]; +export declare class Secp256k1Pen implements Pen { + static fromMnemonic(mnemonic: string, hdPath?: readonly Slip10RawIndex[]): Promise; + readonly pubkey: Uint8Array; + private readonly privkey; + private constructor(); + /** + * Creates and returns a signature + */ + sign(signBytes: Uint8Array, prehashType?: PrehashType): Promise; + address(prefix: string): string; +} diff --git a/packages/sdk38/types/pubkey.d.ts b/packages/sdk38/types/pubkey.d.ts new file mode 100644 index 00000000..b6ebdebc --- /dev/null +++ b/packages/sdk38/types/pubkey.d.ts @@ -0,0 +1,4 @@ +import { PubKey } from "./types"; +export declare function encodeSecp256k1Pubkey(pubkey: Uint8Array): PubKey; +export declare function decodeBech32Pubkey(bechEncoded: string): PubKey; +export declare function encodeBech32Pubkey(pubkey: PubKey, prefix: string): string; diff --git a/packages/sdk38/types/restclient.d.ts b/packages/sdk38/types/restclient.d.ts new file mode 100644 index 00000000..5d5deea4 --- /dev/null +++ b/packages/sdk38/types/restclient.d.ts @@ -0,0 +1,231 @@ +import { Coin } from "./coins"; +import { CosmosSdkTx, JsonObject, Model, StdTx } from "./types"; +export interface CosmosSdkAccount { + /** Bech32 account address */ + readonly address: string; + readonly coins: ReadonlyArray; + /** Bech32 encoded pubkey */ + readonly public_key: string; + readonly account_number: number; + readonly sequence: number; +} +export interface NodeInfo { + readonly protocol_version: { + readonly p2p: string; + readonly block: string; + readonly app: string; + }; + readonly id: string; + readonly listen_addr: string; + readonly network: string; + readonly version: string; + readonly channels: string; + readonly moniker: string; + readonly other: { + readonly tx_index: string; + readonly rpc_address: string; + }; +} +export interface ApplicationVersion { + readonly name: string; + readonly server_name: string; + readonly client_name: string; + readonly version: string; + readonly commit: string; + readonly build_tags: string; + readonly go: string; +} +export interface NodeInfoResponse { + readonly node_info: NodeInfo; + readonly application_version: ApplicationVersion; +} +export interface BlockId { + readonly hash: string; +} +export interface BlockHeader { + readonly version: { + readonly block: string; + readonly app: string; + }; + readonly height: string; + readonly chain_id: string; + /** An RFC 3339 time string like e.g. '2020-02-15T10:39:10.4696305Z' */ + readonly time: string; + readonly last_commit_hash: string; + readonly last_block_id: BlockId; + /** Can be empty */ + readonly data_hash: string; + readonly validators_hash: string; + readonly next_validators_hash: string; + readonly consensus_hash: string; + readonly app_hash: string; + /** Can be empty */ + readonly last_results_hash: string; + /** Can be empty */ + readonly evidence_hash: string; + readonly proposer_address: string; +} +export interface Block { + readonly header: BlockHeader; + readonly data: { + /** Array of base64 encoded transactions */ + readonly txs: ReadonlyArray | null; + }; +} +export interface BlockResponse { + readonly block_id: BlockId; + readonly block: Block; +} +interface AuthAccountsResponse { + readonly height: string; + readonly result: { + readonly type: "cosmos-sdk/Account"; + readonly value: CosmosSdkAccount; + }; +} +declare type WasmResponse = WasmSuccess | WasmError; +interface WasmSuccess { + readonly height: string; + readonly result: T; +} +interface WasmError { + readonly error: string; +} +export interface TxsResponse { + readonly height: string; + readonly txhash: string; + /** 🤷‍♂️ */ + readonly codespace?: string; + /** Falsy when transaction execution succeeded. Contains error code on error. */ + readonly code?: number; + readonly raw_log: string; + readonly logs?: object; + readonly tx: CosmosSdkTx; + /** The gas limit as set by the user */ + readonly gas_wanted?: string; + /** The gas used by the execution */ + readonly gas_used?: string; + readonly timestamp: string; +} +interface SearchTxsResponse { + readonly total_count: string; + readonly count: string; + readonly page_number: string; + readonly page_total: string; + readonly limit: string; + readonly txs: readonly TxsResponse[]; +} +export interface PostTxsResponse { + readonly height: string; + readonly txhash: string; + readonly code?: number; + readonly raw_log?: string; + /** The same as `raw_log` but deserialized? */ + readonly logs?: object; + /** The gas limit as set by the user */ + readonly gas_wanted?: string; + /** The gas used by the execution */ + readonly gas_used?: string; +} +interface EncodeTxResponse { + readonly tx: string; +} +export interface CodeInfo { + readonly id: number; + /** Bech32 account address */ + readonly creator: string; + /** Hex-encoded sha256 hash of the code stored here */ + readonly data_hash: string; + readonly source?: string; + readonly builder?: string; +} +export interface CodeDetails extends CodeInfo { + /** Base64 encoded raw wasm data */ + readonly data: string; +} +export interface ContractInfo { + readonly address: string; + readonly code_id: number; + /** Bech32 account address */ + readonly creator: string; + readonly label: string; +} +export interface ContractDetails extends ContractInfo { + /** Argument passed on initialization of the contract */ + readonly init_msg: object; +} +declare type RestClientResponse = + | NodeInfoResponse + | BlockResponse + | AuthAccountsResponse + | TxsResponse + | SearchTxsResponse + | PostTxsResponse + | EncodeTxResponse + | WasmResponse + | WasmResponse + | WasmResponse + | WasmResponse + | WasmResponse; +/** + * 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 broadcastMode; + /** + * Creates a new client to interact with a Cosmos SDK light client daemon. + * This class tries to be a direct mapping onto the API. Some basic decoding and normalizatin is done + * but things like caching are done at a higher level. + * + * When building apps, you should not need to use this class directly. If you do, this indicates a missing feature + * in higher level components. Feel free to raise an issue in this case. + * + * @param apiUrl The URL of a Cosmos SDK light client daemon API (sometimes called REST server or REST API) + * @param broadcastMode Defines at which point of the transaction processing the postTx method (i.e. transaction broadcasting) returns + */ + constructor(apiUrl: string, broadcastMode?: BroadcastMode); + get(path: string): Promise; + post(path: string, params: any): Promise; + authAccounts(address: string): Promise; + blocksLatest(): Promise; + blocks(height: number): Promise; + nodeInfo(): Promise; + txById(id: string): Promise; + txsQuery(query: string): Promise; + /** returns the amino-encoding of the transaction performed by the server */ + encodeTx(tx: CosmosSdkTx): Promise; + /** + * Broadcasts a signed transaction to into the transaction pool. + * Depending on the RestClient's broadcast mode, this might or might + * wait for checkTx or deliverTx to be executed before returning. + * + * @param tx a signed transaction as StdTx (i.e. not wrapped in type/value container) + */ + postTx(tx: StdTx): Promise; + listCodeInfo(): Promise; + getCode(id: number): Promise; + listContractsByCodeId(id: number): Promise; + /** + * Returns null when contract was not found at this address. + */ + getContractInfo(address: string): Promise; + getAllContractState(address: string): Promise; + queryContractRaw(address: string, key: Uint8Array): Promise; + /** + * Makes a smart query on the contract and parses the reponse as JSON. + * Throws error if no such contract exists, the query format is invalid or the response is invalid. + */ + queryContractSmart(address: string, query: object): Promise; +} +export {}; diff --git a/packages/sdk38/types/sequence.d.ts b/packages/sdk38/types/sequence.d.ts new file mode 100644 index 00000000..70d38469 --- /dev/null +++ b/packages/sdk38/types/sequence.d.ts @@ -0,0 +1,19 @@ +import { CosmosSdkTx } from "./types"; +/** + * Serach for sequence s with `min` <= `s` < `upperBound` to find the sequence that was used to sign the transaction + * + * @param tx The signed transaction + * @param chainId The chain ID for which this transaction was signed + * @param accountNumber The account number for which this transaction was signed + * @param upperBound The upper bound for the testing, i.e. sequence must be lower than this value + * @param min The lowest sequence that is tested + * + * @returns the sequence if a match was found and undefined otherwise + */ +export declare function findSequenceForSignedTx( + tx: CosmosSdkTx, + chainId: string, + accountNumber: number, + upperBound: number, + min?: number, +): Promise; diff --git a/packages/sdk38/types/signature.d.ts b/packages/sdk38/types/signature.d.ts new file mode 100644 index 00000000..c23e7167 --- /dev/null +++ b/packages/sdk38/types/signature.d.ts @@ -0,0 +1,14 @@ +import { StdSignature } from "./types"; +/** + * Takes a binary pubkey and signature to create a signature object + * + * @param pubkey a compressed secp256k1 public key + * @param signature a 64 byte fixed length representation of secp256k1 signature components r and s + */ +export declare function encodeSecp256k1Signature(pubkey: Uint8Array, signature: Uint8Array): StdSignature; +export declare function decodeSignature( + signature: StdSignature, +): { + readonly pubkey: Uint8Array; + readonly signature: Uint8Array; +}; diff --git a/packages/sdk38/types/signingcosmosclient.d.ts b/packages/sdk38/types/signingcosmosclient.d.ts new file mode 100644 index 00000000..00202b7c --- /dev/null +++ b/packages/sdk38/types/signingcosmosclient.d.ts @@ -0,0 +1,74 @@ +import { Coin } from "./coins"; +import { Account, CosmosClient, GetNonceResult, PostTxResult } from "./cosmosclient"; +import { Log } from "./logs"; +import { BroadcastMode } from "./restclient"; +import { StdFee, StdSignature } from "./types"; +export interface SigningCallback { + (signBytes: Uint8Array): Promise; +} +export interface FeeTable { + readonly upload: StdFee; + readonly init: StdFee; + readonly exec: StdFee; + readonly send: StdFee; +} +export interface UploadMeta { + /** The source URL */ + readonly source?: string; + /** The builder tag */ + readonly builder?: string; +} +export interface UploadResult { + /** Size of the original wasm code in bytes */ + readonly originalSize: number; + /** A hex encoded sha256 checksum of the original wasm code (that is stored on chain) */ + readonly originalChecksum: string; + /** Size of the compressed wasm code in bytes */ + readonly compressedSize: number; + /** A hex encoded sha256 checksum of the compressed wasm code (that stored in the transaction) */ + readonly compressedChecksum: string; + /** The ID of the code asigned by the chain */ + readonly codeId: number; + readonly logs: readonly Log[]; + /** Transaction hash (might be used as transaction ID). Guaranteed to be non-empty upper-case hex */ + readonly transactionHash: string; +} +export interface InstantiateResult { + /** The address of the newly instantiated contract */ + readonly contractAddress: string; + readonly logs: readonly Log[]; + /** Transaction hash (might be used as transaction ID). Guaranteed to be non-empty upper-case hex */ + readonly transactionHash: string; +} +export interface ExecuteResult { + readonly logs: readonly Log[]; + /** Transaction hash (might be used as transaction ID). Guaranteed to be non-empty upper-case hex */ + readonly transactionHash: string; +} +export declare class SigningCosmosClient extends CosmosClient { + readonly senderAddress: string; + private readonly signCallback; + private readonly fees; + /** + * Creates a new client with signing capability to interact with a CosmWasm blockchain. This is the bigger brother of CosmWasmClient. + * + * This instance does a lot of caching. In order to benefit from that you should try to use one instance + * for the lifetime of your application. When switching backends, a new instance must be created. + * + * @param apiUrl The URL of a Cosmos SDK light client daemon API (sometimes called REST server or REST API) + * @param senderAddress The address that will sign and send transactions using this instance + * @param signCallback An asynchonous callback to create a signature for a given transaction. This can be implemented using secure key stores that require user interaction. + * @param customFees The fees that are paid for transactions + * @param broadcastMode Defines at which point of the transaction processing the postTx method (i.e. transaction broadcasting) returns + */ + constructor( + apiUrl: string, + senderAddress: string, + signCallback: SigningCallback, + customFees?: Partial, + broadcastMode?: BroadcastMode, + ); + getNonce(address?: string): Promise; + getAccount(address?: string): Promise; + sendTokens(recipientAddress: string, transferAmount: readonly Coin[], memo?: string): Promise; +} diff --git a/packages/sdk38/types/types.d.ts b/packages/sdk38/types/types.d.ts new file mode 100644 index 00000000..a38796a2 --- /dev/null +++ b/packages/sdk38/types/types.d.ts @@ -0,0 +1,66 @@ +import { Coin } from "./coins"; +/** An Amino/Cosmos SDK StdTx */ +export interface StdTx { + readonly msg: ReadonlyArray; + readonly fee: StdFee; + readonly signatures: ReadonlyArray; + readonly memo: string | undefined; +} +export declare function isStdTx(txValue: unknown): txValue is StdTx; +export interface CosmosSdkTx { + readonly type: string; + readonly value: StdTx; +} +interface MsgTemplate { + readonly type: string; + readonly value: any; +} +/** A Cosmos SDK token transfer message */ +export interface MsgSend extends MsgTemplate { + readonly type: "cosmos-sdk/MsgSend"; + readonly value: { + /** Bech32 account address */ + readonly from_address: string; + /** Bech32 account address */ + readonly to_address: string; + readonly amount: ReadonlyArray; + }; +} +export declare type Msg = MsgSend | MsgTemplate; +export declare function isMsgSend(msg: Msg): msg is MsgSend; +export interface StdFee { + readonly amount: ReadonlyArray; + readonly gas: string; +} +export interface StdSignature { + readonly pub_key: PubKey; + readonly signature: string; +} +export interface PubKey { + readonly type: string; + readonly value: string; +} +export declare const pubkeyType: { + /** @see https://github.com/tendermint/tendermint/blob/v0.33.0/crypto/ed25519/ed25519.go#L22 */ + secp256k1: "tendermint/PubKeySecp256k1"; + /** @see https://github.com/tendermint/tendermint/blob/v0.33.0/crypto/secp256k1/secp256k1.go#L23 */ + ed25519: "tendermint/PubKeyEd25519"; + /** @see https://github.com/tendermint/tendermint/blob/v0.33.0/crypto/sr25519/codec.go#L12 */ + sr25519: "tendermint/PubKeySr25519"; +}; +export declare const pubkeyTypes: readonly string[]; +export interface WasmData { + readonly key: string; + readonly val: string; +} +export interface Model { + readonly key: Uint8Array; + readonly val: Uint8Array; +} +export declare function parseWasmData({ key, val }: WasmData): Model; +/** + * An object containing a parsed JSON document. The result of JSON.parse(). + * This doen't privide any type safety over `any` but expresses intent in the code. + */ +export declare type JsonObject = any; +export {}; diff --git a/packages/sdk38/webpack.web.config.js b/packages/sdk38/webpack.web.config.js new file mode 100644 index 00000000..7373cace --- /dev/null +++ b/packages/sdk38/webpack.web.config.js @@ -0,0 +1,19 @@ +const glob = require("glob"); +const path = require("path"); +const webpack = require("webpack"); + +const target = "web"; +const distdir = path.join(__dirname, "dist", "web"); + +module.exports = [ + { + // bundle used for Karma tests + target: target, + entry: glob.sync("./build/**/*.spec.js"), + output: { + path: distdir, + filename: "tests.js", + }, + plugins: [new webpack.EnvironmentPlugin(["WASMD_ENABLED"])], + }, +];