commit 0fc62d4aa87b7aaa2e14df08eb40b12232254782 Author: Ethan Frey Date: Wed Jan 22 09:41:48 2020 +0100 Import from @iov/cosmos diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 00000000..f373a53f --- /dev/null +++ b/.eslintignore @@ -0,0 +1,8 @@ +node_modules/ + +build/ +custom_types/ +dist/ +docs/ +generated/ +types/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..07ca4b31 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Dependency directories +node_modules/ + +# Output of 'npm pack' +*.tgz + +# OS-specific files +.DS_Store + +# IDE-specific +.vscode/ + +# Release dir +tmp/ diff --git a/README.md b/README.md new file mode 100644 index 00000000..4e2ff8e6 --- /dev/null +++ b/README.md @@ -0,0 +1,8 @@ +# @iov/cosmos + +[![npm version](https://img.shields.io/npm/v/@iov/cosmos.svg)](https://www.npmjs.com/package/@iov/cosmos) + +## License + +This package is part of the IOV-Core repository, licensed under the Apache License 2.0 +(see [NOTICE](https://github.com/iov-one/iov-core/blob/master/NOTICE) and [LICENSE](https://github.com/iov-one/iov-core/blob/master/LICENSE)). diff --git a/jasmine-testrunner.js b/jasmine-testrunner.js new file mode 100755 index 00000000..9fada59b --- /dev/null +++ b/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/karma.conf.js b/karma.conf.js new file mode 100644 index 00000000..ff71fce8 --- /dev/null +++ b/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/nonces/1570527883 b/nonces/1570527883 new file mode 100644 index 00000000..e69de29b diff --git a/nonces/1573026590 b/nonces/1573026590 new file mode 100644 index 00000000..e69de29b diff --git a/nonces/1574869843 b/nonces/1574869843 new file mode 100644 index 00000000..e69de29b diff --git a/nonces/1576569788 b/nonces/1576569788 new file mode 100644 index 00000000..e69de29b diff --git a/nonces/1576595306 b/nonces/1576595306 new file mode 100644 index 00000000..e69de29b diff --git a/nonces/1576678551 b/nonces/1576678551 new file mode 100644 index 00000000..e69de29b diff --git a/nonces/1576746493 b/nonces/1576746493 new file mode 100644 index 00000000..e69de29b diff --git a/nonces/1576760285 b/nonces/1576760285 new file mode 100644 index 00000000..e69de29b diff --git a/nonces/1576767119 b/nonces/1576767119 new file mode 100644 index 00000000..e69de29b diff --git a/nonces/1579019908 b/nonces/1579019908 new file mode 100644 index 00000000..e69de29b diff --git a/nonces/README.txt b/nonces/README.txt new file mode 100644 index 00000000..092fe732 --- /dev/null +++ b/nonces/README.txt @@ -0,0 +1 @@ +Directory used to trigger lerna package updates for all packages diff --git a/package.json b/package.json new file mode 100644 index 00000000..6db02352 --- /dev/null +++ b/package.json @@ -0,0 +1,53 @@ +{ + "name": "@iov/cosmos", + "version": "2.0.0-alpha.7", + "description": "Transaction codec and client to communicate with the Cosmos Hub blockchain", + "author": "IOV SAS ", + "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/iov-one/iov-core/tree/master/packages/iov-cosmos" + }, + "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}\" && tslint -t verbose --project .", + "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-edge": "yarn pack-web && karma start --single-run --browsers Edge", + "test-firefox": "yarn pack-web && karma start --single-run --browsers Firefox", + "test-chrome": "yarn pack-web && karma start --single-run --browsers ChromeHeadlessInsecure", + "test-safari": "yarn pack-web && karma start --single-run --browsers Safari", + "test": "yarn build-or-skip && yarn test-node", + "pack-web": "yarn build-or-skip && webpack --mode development --config webpack.web.config.js" + }, + "dependencies": { + "@iov/bcp": "^2.0.0-alpha.7", + "@iov/crypto": "^2.0.0-alpha.7", + "@iov/encoding": "^2.0.0-alpha.7", + "@iov/stream": "^2.0.0-alpha.7", + "@tendermint/amino-js": "^0.7.0-alpha.1", + "axios": "^0.19.0", + "fast-deep-equal": "^3.1.1", + "readonly-date": "^1.0.0", + "xstream": "^11.11.0" + }, + "devDependencies": { + "@iov/keycontrol": "^2.0.0-alpha.7" + } +} diff --git a/src/address.spec.ts b/src/address.spec.ts new file mode 100644 index 00000000..f0a97792 --- /dev/null +++ b/src/address.spec.ts @@ -0,0 +1,78 @@ +import { Address, Algorithm, PubkeyBytes } from "@iov/bcp"; +import { Encoding } from "@iov/encoding"; + +import { decodeCosmosAddress, isValidAddress, pubkeyToAddress } from "./address"; + +const { fromBase64, fromHex } = Encoding; + +describe("address", () => { + // Bech32 encoding/decoding data generated using https://github.com/bitcoinjs/bech32 + describe("decodeCosmosAddress", () => { + it("throws for invalid prefix", () => { + expect(() => + decodeCosmosAddress("cosmot10q82zkzzmaku5lazhsvxv7hsg4ntpuhd8j5266" as Address), + ).toThrowError(/invalid bech32 prefix/i); + }); + + it("throws for invalid length", () => { + expect(() => + decodeCosmosAddress("cosmos1alcmj76e030g83fedrnx8lvsythhg70zlct4cwx3" as Address), + ).toThrowError(/invalid data length/i); + }); + + it("decodes valid addresses", () => { + expect(decodeCosmosAddress("cosmos1pkptre7fdkl6gfrzlesjjvhxhlc3r4gmmk8rs6" as Address)).toEqual({ + prefix: "cosmos", + data: fromHex("0d82b1e7c96dbfa42462fe612932e6bff111d51b"), + }); + }); + }); + + describe("isValidAddress", () => { + it("accepts valid addresses", () => { + expect(isValidAddress("cosmos1pkptre7fdkl6gfrzlesjjvhxhlc3r4gmmk8rs6")).toEqual(true); + expect(isValidAddress("cosmosvalcons10q82zkzzmaku5lazhsvxv7hsg4ntpuhdwadmss")).toEqual(true); + expect(isValidAddress("cosmosvaloper17mggn4znyeyg25wd7498qxl7r2jhgue8u4qjcq")).toEqual(true); + }); + + it("rejects invalid addresses", () => { + // Bad size + expect(isValidAddress("cosmos10q82zkzzmaku5lazhsvxv7hsg4ntpuhh8289f")).toEqual(false); + // Bad checksum + expect(isValidAddress("cosmos1pkptre7fdkl6gfrzlesjjvhxhlc3r4gmmk8rs7")).toEqual(false); + // Bad prefix + expect(isValidAddress("cosmot10q82zkzzmaku5lazhsvxv7hsg4ntpuhd8j5266")).toEqual(false); + }); + }); + + describe("pubkeyToAddress", () => { + it("works for Secp256k1 compressed", () => { + const prefix = "cosmos"; + const pubkey = { + algo: Algorithm.Secp256k1, + data: fromBase64("AtQaCqFnshaZQp6rIkvAPyzThvCvXSDO+9AzbxVErqJP") as PubkeyBytes, + }; + expect(pubkeyToAddress(pubkey, prefix)).toEqual("cosmos1h806c7khnvmjlywdrkdgk2vrayy2mmvf9rxk2r"); + }); + + it("works for Secp256k1 uncompressed", () => { + const prefix = "cosmos"; + const pubkey = { + algo: Algorithm.Secp256k1, + data: fromBase64( + "BE8EGB7ro1ORuFhjOnZcSgwYlpe0DSFjVNUIkNNQxwKQE7WHpoHoNswYeoFkuYpYSKK4mzFzMV/dB0DVAy4lnNU=", + ) as PubkeyBytes, + }; + expect(pubkeyToAddress(pubkey, prefix)).toEqual("cosmos1pkptre7fdkl6gfrzlesjjvhxhlc3r4gmmk8rs6"); + }); + + it("works for Ed25519", () => { + const prefix = "cosmos"; + const pubkey = { + algo: Algorithm.Ed25519, + data: fromHex("12ee6f581fe55673a1e9e1382a0829e32075a0aa4763c968bc526e1852e78c95") as PubkeyBytes, + }; + expect(pubkeyToAddress(pubkey, prefix)).toEqual("cosmos1pfq05em6sfkls66ut4m2257p7qwlk448h8mysz"); + }); + }); +}); diff --git a/src/address.ts b/src/address.ts new file mode 100644 index 00000000..853e24af --- /dev/null +++ b/src/address.ts @@ -0,0 +1,52 @@ +import { Address, Algorithm, PubkeyBundle } from "@iov/bcp"; +import { Ripemd160, Secp256k1, Sha256 } from "@iov/crypto"; +import { Bech32 } from "@iov/encoding"; + +export type CosmosAddressBech32Prefix = "cosmos" | "cosmosvalcons" | "cosmosvaloper"; +export type CosmosPubkeyBech32Prefix = "cosmospub" | "cosmosvalconspub" | "cosmosvaloperpub"; +export type CosmosBech32Prefix = CosmosAddressBech32Prefix | CosmosPubkeyBech32Prefix; + +function isCosmosAddressBech32Prefix(prefix: string): prefix is CosmosAddressBech32Prefix { + return ["cosmos", "cosmosvalcons", "cosmosvaloper"].includes(prefix); +} + +export function decodeCosmosAddress( + address: Address, +): { readonly prefix: CosmosAddressBech32Prefix; readonly data: Uint8Array } { + const { prefix, data } = Bech32.decode(address); + if (!isCosmosAddressBech32Prefix(prefix)) { + throw new Error(`Invalid bech32 prefix. Must be one of cosmos, cosmosvalcons, or cosmosvaloper.`); + } + if (data.length !== 20) { + throw new Error("Invalid data length. Expected 20 bytes."); + } + return { prefix: prefix, data: data }; +} + +export function isValidAddress(address: string): boolean { + try { + decodeCosmosAddress(address as Address); + return true; + } catch { + return false; + } +} + +// See https://github.com/tendermint/tendermint/blob/f2ada0a604b4c0763bda2f64fac53d506d3beca7/docs/spec/blockchain/encoding.md#public-key-cryptography +export function pubkeyToAddress(pubkey: PubkeyBundle, prefix: CosmosBech32Prefix): Address { + const pubkeyData = + pubkey.algo === Algorithm.Secp256k1 ? Secp256k1.compressPubkey(pubkey.data) : pubkey.data; + switch (pubkey.algo) { + case Algorithm.Secp256k1: { + const hash1 = new Sha256(pubkeyData).digest(); + const hash2 = new Ripemd160(hash1).digest(); + return Bech32.encode(prefix, hash2) as Address; + } + case Algorithm.Ed25519: { + const hash = new Sha256(pubkeyData).digest(); + return Bech32.encode(prefix, hash.slice(0, 20)) as Address; + } + default: + throw new Error("Unrecognized public key algorithm"); + } +} diff --git a/src/caip5.spec.ts b/src/caip5.spec.ts new file mode 100644 index 00000000..c47bb7cc --- /dev/null +++ b/src/caip5.spec.ts @@ -0,0 +1,79 @@ +import { ChainId } from "@iov/bcp"; + +import { Caip5 } from "./caip5"; + +describe("Caip5", () => { + describe("encode", () => { + it("works for direct format", () => { + expect(Caip5.encode("foo")).toEqual("cosmos:foo"); + expect(Caip5.encode("aA1-")).toEqual("cosmos:aA1-"); + expect(Caip5.encode("12345678901234567890123456789012345678901234567")).toEqual( + "cosmos:12345678901234567890123456789012345678901234567", + ); + + // Test vectors from CAIP-5 + expect(Caip5.encode("cosmoshub-3")).toEqual("cosmos:cosmoshub-3"); + expect(Caip5.encode("Binance-Chain-Tigris")).toEqual("cosmos:Binance-Chain-Tigris"); + expect(Caip5.encode("x")).toEqual("cosmos:x"); + expect(Caip5.encode("hash-")).toEqual("cosmos:hash-"); + expect(Caip5.encode("hashed")).toEqual("cosmos:hashed"); + }); + + it("works for hashed format", () => { + // Test vectors from CAIP-5 + expect(Caip5.encode("hashed-")).toEqual("cosmos:hashed-c904589232422def"); + expect(Caip5.encode("hashed-123")).toEqual("cosmos:hashed-99df5cd68192b33e"); + expect(Caip5.encode("123456789012345678901234567890123456789012345678")).toEqual( + "cosmos:hashed-0204c92a0388779d", + ); + expect(Caip5.encode(" ")).toEqual("cosmos:hashed-36a9e7f1c95b82ff"); + expect(Caip5.encode("wonderland🧝‍♂️")).toEqual("cosmos:hashed-843d2fc87f40eeb9"); + }); + + it("throws for empty input", () => { + expect(() => Caip5.encode("")).toThrowError(/must not be empty/i); + }); + }); + + describe("decode", () => { + it("works for valid format", () => { + expect(Caip5.decode("cosmos:x" as ChainId)).toEqual("x"); + expect(Caip5.decode("cosmos:foo" as ChainId)).toEqual("foo"); + expect(Caip5.decode("cosmos:aA1-" as ChainId)).toEqual("aA1-"); + expect(Caip5.decode("cosmos:12345678901234567890123456789012345678901234567" as ChainId)).toEqual( + "12345678901234567890123456789012345678901234567", + ); + }); + + it("throws for invalid format", () => { + // wrong namespace + expect(() => Caip5.decode(":foobar" as ChainId)).toThrowError(/not compatible with CAIP-5/i); + expect(() => Caip5.decode("xyz:foobar" as ChainId)).toThrowError(/not compatible with CAIP-5/i); + expect(() => Caip5.decode("cosmos-hash:foobar" as ChainId)).toThrowError(/not compatible with CAIP-5/i); + + // reference too short + expect(() => Caip5.decode("cosmos:" as ChainId)).toThrowError(/not compatible with CAIP-5/i); + + // reference too long + expect(() => + Caip5.decode("cosmos:123456789012345678901234567890123456789012345678" as ChainId), + ).toThrowError(/not compatible with CAIP-5/i); + + // invalid chars + expect(() => Caip5.decode("cosmos:foo bar" as ChainId)).toThrowError(/not compatible with CAIP-5/i); + expect(() => Caip5.decode("cosmos:wonder🧝‍♂️" as ChainId)).toThrowError(/not compatible with CAIP-5/i); + }); + + it("throws for hashed chain IDs", () => { + expect(() => Caip5.decode("cosmos:hashed-" as ChainId)).toThrowError( + /hashed chain IDs cannot be decoded/i, + ); + expect(() => Caip5.decode("cosmos:hashed-abab" as ChainId)).toThrowError( + /hashed chain IDs cannot be decoded/i, + ); + expect(() => Caip5.decode("cosmos:hashed-6abb36860ec76c5a" as ChainId)).toThrowError( + /hashed chain IDs cannot be decoded/i, + ); + }); + }); +}); diff --git a/src/caip5.ts b/src/caip5.ts new file mode 100644 index 00000000..7d6641bd --- /dev/null +++ b/src/caip5.ts @@ -0,0 +1,42 @@ +import { ChainId } from "@iov/bcp"; +import { Sha256 } from "@iov/crypto"; +import { Encoding } from "@iov/encoding"; + +const { toHex, toUtf8 } = Encoding; + +const hashedPrefix = "hashed-"; + +/** + * Conversion between native chain IDs and CAIP-5 format + * + * @see https://github.com/ChainAgnostic/CAIPs/pull/9 + */ +export class Caip5 { + /** + * @param native The `chain_id` field from Tendermint's genesis file + */ + public static encode(native: string): ChainId { + if (!native) throw new Error("Input must not be empty"); + + if (!native.match(/^[-a-zA-Z0-9]{1,47}$/) || native.startsWith(hashedPrefix)) { + const hash = toHex(new Sha256(toUtf8(native)).digest()).slice(0, 16); + return `cosmos:${hashedPrefix}${hash}` as ChainId; + } else { + return `cosmos:${native}` as ChainId; + } + } + + public static decode(chainId: ChainId): string { + const match = chainId.match(/^cosmos:([-a-zA-Z0-9]{1,47})$/); + if (!match) { + throw new Error("Chain ID not compatible with CAIP-5"); + } + + const reference = match[1]; + if (reference.startsWith(hashedPrefix)) { + throw new Error("Hashed chain IDs cannot be decoded"); + } + + return reference; + } +} diff --git a/src/cosmoscodec.spec.ts b/src/cosmoscodec.spec.ts new file mode 100644 index 00000000..a4a046ec --- /dev/null +++ b/src/cosmoscodec.spec.ts @@ -0,0 +1,49 @@ +import { PostableBytes, PrehashType } from "@iov/bcp"; +import { Encoding } from "@iov/encoding"; + +import { cosmosCodec } from "./cosmoscodec"; +import { chainId, nonce, sendTxJson, signedTxBin, signedTxJson, txId } from "./testdata.spec"; + +const { toUtf8 } = Encoding; + +describe("cosmoscodec", () => { + it("properly generates bytes to sign", () => { + const expected = { + bytes: toUtf8( + '{"account_number":"0","chain_id":"cosmoshub-3","fee":{"amount":[{"amount":"2500","denom":"uatom"}],"gas":"100000"},"memo":"","msgs":[{"type":"cosmos-sdk/MsgSend","value":{"amount":[{"amount":"35997500","denom":"uatom"}],"from_address":"cosmos1txqfn5jmcts0x0q7krdxj8tgf98tj0965vqlmq","to_address":"cosmos1nynns8ex9fq6sjjfj8k79ymkdz4sqth06xexae"}}],"sequence":"99"}', + ), + prehashType: PrehashType.Sha256, + }; + const bytesToSign = cosmosCodec.bytesToSign(sendTxJson, nonce); + + expect(bytesToSign).toEqual(expected); + }); + + it("properly encodes transactions", () => { + const encoded = cosmosCodec.bytesToPost(signedTxJson); + expect(encoded).toEqual(signedTxBin); + }); + + it("throws when trying to decode a transaction without a nonce", () => { + expect(() => cosmosCodec.parseBytes(signedTxBin as PostableBytes, chainId)).toThrowError( + /nonce is required/i, + ); + }); + + it("properly decodes transactions", () => { + const decoded = cosmosCodec.parseBytes(signedTxBin as PostableBytes, chainId, nonce); + expect(decoded).toEqual(signedTxJson); + }); + + it("generates transaction id", () => { + const id = cosmosCodec.identifier(signedTxJson); + expect(id).toMatch(/^[0-9A-F]{64}$/); + expect(id).toEqual(txId); + }); + + it("round trip works", () => { + const encoded = cosmosCodec.bytesToPost(signedTxJson); + const decoded = cosmosCodec.parseBytes(encoded, chainId, nonce); + expect(decoded).toEqual(signedTxJson); + }); +}); diff --git a/src/cosmoscodec.ts b/src/cosmoscodec.ts new file mode 100644 index 00000000..ba449e4e --- /dev/null +++ b/src/cosmoscodec.ts @@ -0,0 +1,97 @@ +/* eslint-disable @typescript-eslint/camelcase */ +import { + Address, + ChainId, + Identity, + Nonce, + PostableBytes, + PrehashType, + SignableBytes, + SignedTransaction, + SigningJob, + TransactionId, + TxCodec, + UnsignedTransaction, +} from "@iov/bcp"; +import { Sha256 } from "@iov/crypto"; +import { Encoding } from "@iov/encoding"; +import { marshalTx, unmarshalTx } from "@tendermint/amino-js"; + +import { isValidAddress, pubkeyToAddress } from "./address"; +import { Caip5 } from "./caip5"; +import { parseTx } from "./decode"; +import { buildSignedTx, buildUnsignedTx } from "./encode"; + +const { toHex, 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 class CosmosCodec implements TxCodec { + public bytesToSign(unsigned: UnsignedTransaction, nonce: Nonce): SigningJob { + const accountNumber = 0; + const memo = (unsigned as any).memo; + const built = buildUnsignedTx(unsigned); + + const signMsg = sortJson({ + account_number: accountNumber.toString(), + chain_id: Caip5.decode(unsigned.chainId), + fee: (built.value as any).fee, + memo: memo, + msgs: (built.value as any).msg, + sequence: nonce.toString(), + }); + const signBytes = toUtf8(JSON.stringify(signMsg)); + + return { + bytes: signBytes as SignableBytes, + prehashType: PrehashType.Sha256, + }; + } + + public bytesToPost(signed: SignedTransaction): PostableBytes { + const built = buildSignedTx(signed); + const bytes = marshalTx(built, true); + return bytes as PostableBytes; + } + + public identifier(signed: SignedTransaction): TransactionId { + const bytes = this.bytesToPost(signed); + const hash = new Sha256(bytes).digest(); + return toHex(hash).toUpperCase() as TransactionId; + } + + public parseBytes(bytes: PostableBytes, chainId: ChainId, nonce?: Nonce): SignedTransaction { + if (nonce === undefined) { + throw new Error("Nonce is required"); + } + const parsed = unmarshalTx(bytes); + return parseTx(parsed, chainId, nonce); + } + + public identityToAddress(identity: Identity): Address { + const prefix = "cosmos"; + return pubkeyToAddress(identity.pubkey, prefix); + } + + public isValidAddress(address: string): boolean { + return isValidAddress(address); + } +} + +export const cosmosCodec = new CosmosCodec(); diff --git a/src/cosmosconnection.spec.ts b/src/cosmosconnection.spec.ts new file mode 100644 index 00000000..8bcd9bd5 --- /dev/null +++ b/src/cosmosconnection.spec.ts @@ -0,0 +1,332 @@ +import { + Address, + Algorithm, + ChainId, + isBlockInfoPending, + isFailedTransaction, + isSendTransaction, + PubkeyBytes, + SendTransaction, + TokenTicker, + TransactionState, +} from "@iov/bcp"; +import { Secp256k1 } from "@iov/crypto"; +import { Encoding } from "@iov/encoding"; +import { HdPaths, Secp256k1HdWallet, UserProfile } from "@iov/keycontrol"; + +import { cosmosCodec } from "./cosmoscodec"; +import { CosmosConnection } from "./cosmosconnection"; + +const { fromBase64, toHex } = Encoding; + +function pendingWithoutCosmos(): void { + if (!process.env.COSMOS_ENABLED) { + return pending("Set COSMOS_ENABLED to enable Cosmos node-based tests"); + } +} + +describe("CosmosConnection", () => { + const atom = "ATOM" as TokenTicker; + const httpUrl = "http://localhost:1317"; + const defaultChainId = "cosmos:testing" as ChainId; + const defaultEmptyAddress = "cosmos1h806c7khnvmjlywdrkdgk2vrayy2mmvf9rxk2r" as Address; + const defaultAddress = "cosmos1pkptre7fdkl6gfrzlesjjvhxhlc3r4gmmk8rs6" as Address; + const defaultPubkey = { + algo: Algorithm.Secp256k1, + data: fromBase64("A08EGB7ro1ORuFhjOnZcSgwYlpe0DSFjVNUIkNNQxwKQ") as PubkeyBytes, + }; + const faucetMnemonic = + "economy stock theory fatal elder harbor betray wasp final emotion task crumble siren bottom lizard educate guess current outdoor pair theory focus wife stone"; + const faucetPath = HdPaths.cosmos(0); + const defaultRecipient = "cosmos1t70qnpr0az8tf7py83m4ue5y89w58lkjmx0yq2" as Address; + + describe("establish", () => { + it("can connect to Cosmos via http", async () => { + pendingWithoutCosmos(); + const connection = await CosmosConnection.establish(httpUrl); + expect(connection).toBeTruthy(); + connection.disconnect(); + }); + }); + + describe("chainId", () => { + it("displays the chain ID", async () => { + pendingWithoutCosmos(); + const connection = await CosmosConnection.establish(httpUrl); + const chainId = connection.chainId(); + expect(chainId).toEqual(defaultChainId); + connection.disconnect(); + }); + }); + + describe("height", () => { + it("displays the current height", async () => { + pendingWithoutCosmos(); + const connection = await CosmosConnection.establish(httpUrl); + const height = await connection.height(); + expect(height).toBeGreaterThan(0); + connection.disconnect(); + }); + }); + + describe("getToken", () => { + it("displays a given token", async () => { + pendingWithoutCosmos(); + const connection = await CosmosConnection.establish(httpUrl); + const token = await connection.getToken("ATOM" as TokenTicker); + expect(token).toEqual({ + fractionalDigits: 6, + tokenName: "Atom", + tokenTicker: "ATOM" as TokenTicker, + }); + connection.disconnect(); + }); + + it("resolves to undefined if the token is not supported", async () => { + pendingWithoutCosmos(); + const connection = await CosmosConnection.establish(httpUrl); + const token = await connection.getToken("whatever" as TokenTicker); + expect(token).toBeUndefined(); + connection.disconnect(); + }); + }); + + describe("getAllTokens", () => { + it("resolves to a list of all supported tokens", async () => { + pendingWithoutCosmos(); + const connection = await CosmosConnection.establish(httpUrl); + const tokens = await connection.getAllTokens(); + expect(tokens).toEqual([ + { + fractionalDigits: 6, + tokenName: "Atom", + tokenTicker: "ATOM" as TokenTicker, + }, + ]); + connection.disconnect(); + }); + }); + + describe("getAccount", () => { + it("gets an empty account by address", async () => { + pendingWithoutCosmos(); + const connection = await CosmosConnection.establish(httpUrl); + const account = await connection.getAccount({ address: defaultEmptyAddress }); + expect(account).toBeUndefined(); + connection.disconnect(); + }); + + it("gets an account by address", async () => { + pendingWithoutCosmos(); + const connection = await CosmosConnection.establish(httpUrl); + const account = await connection.getAccount({ address: defaultAddress }); + if (account === undefined) { + throw new Error("Expected account not to be undefined"); + } + expect(account.address).toEqual(defaultAddress); + expect(account.pubkey).toEqual(defaultPubkey); + // Unsupported coins are filtered out + expect(account.balance.length).toEqual(1); + connection.disconnect(); + }); + + it("gets an account by pubkey", async () => { + pendingWithoutCosmos(); + const connection = await CosmosConnection.establish(httpUrl); + const account = await connection.getAccount({ pubkey: defaultPubkey }); + if (account === undefined) { + throw new Error("Expected account not to be undefined"); + } + expect(account.address).toEqual(defaultAddress); + expect(account.pubkey).toEqual({ + algo: Algorithm.Secp256k1, + data: fromBase64("A08EGB7ro1ORuFhjOnZcSgwYlpe0DSFjVNUIkNNQxwKQ"), + }); + // Unsupported coins are filtered out + expect(account.balance.length).toEqual(1); + connection.disconnect(); + }); + }); + + describe("integration tests", () => { + it("can post and get a transaction", async () => { + pendingWithoutCosmos(); + const connection = await CosmosConnection.establish(httpUrl); + const profile = new UserProfile(); + const wallet = profile.addWallet(Secp256k1HdWallet.fromMnemonic(faucetMnemonic)); + const faucet = await profile.createIdentity(wallet.id, defaultChainId, faucetPath); + const faucetAddress = cosmosCodec.identityToAddress(faucet); + + const unsigned = await connection.withDefaultFee({ + kind: "bcp/send", + chainId: defaultChainId, + sender: faucetAddress, + recipient: defaultRecipient, + memo: "My first payment", + amount: { + quantity: "75000", + fractionalDigits: 6, + tokenTicker: atom, + }, + }); + const nonce = await connection.getNonce({ address: faucetAddress }); + const signed = await profile.signTransaction(faucet, unsigned, cosmosCodec, nonce); + const postableBytes = cosmosCodec.bytesToPost(signed); + const response = await connection.postTx(postableBytes); + const { transactionId } = response; + const blockInfo = await response.blockInfo.waitFor(info => !isBlockInfoPending(info)); + expect(blockInfo.state).toEqual(TransactionState.Succeeded); + + const getResponse = await connection.getTx(transactionId); + expect(getResponse).toBeTruthy(); + expect(getResponse.transactionId).toEqual(transactionId); + if (isFailedTransaction(getResponse)) { + throw new Error("Expected transaction to succeed"); + } + expect(getResponse.log).toMatch(/success/i); + const { transaction, signatures } = getResponse; + if (!isSendTransaction(transaction)) { + throw new Error("Expected send transaction"); + } + expect(transaction.kind).toEqual(unsigned.kind); + expect(transaction.sender).toEqual(unsigned.sender); + expect(transaction.recipient).toEqual(unsigned.recipient); + expect(transaction.memo).toEqual(unsigned.memo); + expect(transaction.amount).toEqual(unsigned.amount); + expect(transaction.chainId).toEqual(unsigned.chainId); + + expect(signatures.length).toEqual(1); + expect(signatures[0].nonce).toEqual(signed.signatures[0].nonce); + expect(signatures[0].pubkey.algo).toEqual(signed.signatures[0].pubkey.algo); + expect(toHex(signatures[0].pubkey.data)).toEqual( + toHex(Secp256k1.compressPubkey(signed.signatures[0].pubkey.data)), + ); + expect(toHex(signatures[0].signature)).toEqual( + toHex(Secp256k1.trimRecoveryByte(signed.signatures[0].signature)), + ); + + connection.disconnect(); + }); + + it("can post and search for a transaction", async () => { + pendingWithoutCosmos(); + const connection = await CosmosConnection.establish(httpUrl); + const profile = new UserProfile(); + const wallet = profile.addWallet(Secp256k1HdWallet.fromMnemonic(faucetMnemonic)); + const faucet = await profile.createIdentity(wallet.id, defaultChainId, faucetPath); + const faucetAddress = cosmosCodec.identityToAddress(faucet); + + const unsigned = await connection.withDefaultFee({ + kind: "bcp/send", + chainId: defaultChainId, + sender: faucetAddress, + recipient: defaultRecipient, + memo: "My first payment", + amount: { + quantity: "75000", + fractionalDigits: 6, + tokenTicker: atom, + }, + }); + const nonce = await connection.getNonce({ address: faucetAddress }); + const signed = await profile.signTransaction(faucet, unsigned, cosmosCodec, nonce); + const postableBytes = cosmosCodec.bytesToPost(signed); + const response = await connection.postTx(postableBytes); + const { transactionId } = response; + const blockInfo = await response.blockInfo.waitFor(info => !isBlockInfoPending(info)); + expect(blockInfo.state).toEqual(TransactionState.Succeeded); + + // search by id + + const idSearchResponse = await connection.searchTx({ id: transactionId }); + expect(idSearchResponse).toBeTruthy(); + expect(idSearchResponse.length).toEqual(1); + + const idResult = idSearchResponse[0]; + expect(idResult.transactionId).toEqual(transactionId); + if (isFailedTransaction(idResult)) { + throw new Error("Expected transaction to succeed"); + } + expect(idResult.log).toMatch(/success/i); + const { transaction: idTransaction } = idResult; + if (!isSendTransaction(idTransaction)) { + throw new Error("Expected send transaction"); + } + expect(idTransaction.kind).toEqual(unsigned.kind); + expect(idTransaction.sender).toEqual(unsigned.sender); + expect(idTransaction.recipient).toEqual(unsigned.recipient); + expect(idTransaction.memo).toEqual(unsigned.memo); + expect(idTransaction.amount).toEqual(unsigned.amount); + + // search by sender address + + const senderAddressSearchResponse = await connection.searchTx({ sentFromOrTo: faucetAddress }); + expect(senderAddressSearchResponse).toBeTruthy(); + expect(senderAddressSearchResponse.length).toBeGreaterThanOrEqual(1); + + const senderAddressResult = senderAddressSearchResponse[senderAddressSearchResponse.length - 1]; + expect(senderAddressResult.transactionId).toEqual(transactionId); + if (isFailedTransaction(senderAddressResult)) { + throw new Error("Expected transaction to succeed"); + } + expect(senderAddressResult.log).toMatch(/success/i); + const { transaction: senderAddressTransaction } = senderAddressResult; + if (!isSendTransaction(senderAddressTransaction)) { + throw new Error("Expected send transaction"); + } + expect(senderAddressTransaction.kind).toEqual(unsigned.kind); + expect(senderAddressTransaction.sender).toEqual(unsigned.sender); + expect(senderAddressTransaction.recipient).toEqual(unsigned.recipient); + expect(senderAddressTransaction.memo).toEqual(unsigned.memo); + expect(senderAddressTransaction.amount).toEqual(unsigned.amount); + + // search by recipient address + // TODO: Support searching by recipient + + // const recipientAddressSearchResponse = await connection.searchTx({ sentFromOrTo: defaultRecipient }); + // expect(recipientAddressSearchResponse).toBeTruthy(); + // expect(recipientAddressSearchResponse.length).toBeGreaterThanOrEqual(1); + + // const recipientAddressResult = + // recipientAddressSearchResponse[recipientAddressSearchResponse.length - 1]; + // expect(recipientAddressResult.transactionId).toEqual(transactionId); + // if (isFailedTransaction(recipientAddressResult)) { + // throw new Error("Expected transaction to succeed"); + // } + // expect(recipientAddressResult.log).toMatch(/success/i); + // const { transaction: recipientAddressTransaction } = recipientAddressResult; + // if (!isSendTransaction(recipientAddressTransaction)) { + // throw new Error("Expected send transaction"); + // } + // expect(recipientAddressTransaction.kind).toEqual(unsigned.kind); + // expect(recipientAddressTransaction.sender).toEqual(unsigned.sender); + // expect(recipientAddressTransaction.recipient).toEqual(unsigned.recipient); + // expect(recipientAddressTransaction.memo).toEqual(unsigned.memo); + // expect(recipientAddressTransaction.amount).toEqual(unsigned.amount); + + // search by height + + const heightSearchResponse = await connection.searchTx({ height: idResult.height }); + expect(heightSearchResponse).toBeTruthy(); + expect(heightSearchResponse.length).toEqual(1); + + const heightResult = heightSearchResponse[0]; + expect(heightResult.transactionId).toEqual(transactionId); + if (isFailedTransaction(heightResult)) { + throw new Error("Expected transaction to succeed"); + } + expect(heightResult.log).toMatch(/success/i); + const { transaction: heightTransaction } = heightResult; + if (!isSendTransaction(heightTransaction)) { + throw new Error("Expected send transaction"); + } + expect(heightTransaction.kind).toEqual(unsigned.kind); + expect(heightTransaction.sender).toEqual(unsigned.sender); + expect(heightTransaction.recipient).toEqual(unsigned.recipient); + expect(heightTransaction.memo).toEqual(unsigned.memo); + expect(heightTransaction.amount).toEqual(unsigned.amount); + + connection.disconnect(); + }); + }); +}); diff --git a/src/cosmosconnection.ts b/src/cosmosconnection.ts new file mode 100644 index 00000000..2cad6de1 --- /dev/null +++ b/src/cosmosconnection.ts @@ -0,0 +1,285 @@ +/* eslint-disable @typescript-eslint/camelcase */ +import { + Account, + AccountQuery, + AddressQuery, + Algorithm, + BlockchainConnection, + BlockHeader, + BlockId, + BlockInfo, + ChainId, + ConfirmedAndSignedTransaction, + ConfirmedTransaction, + FailedTransaction, + Fee, + isConfirmedTransaction, + isPubkeyQuery, + isSendTransaction, + Nonce, + PostableBytes, + PostTxResponse, + PubkeyBytes, + PubkeyQuery, + Token, + TokenTicker, + TransactionId, + TransactionQuery, + TransactionState, + UnsignedTransaction, +} from "@iov/bcp"; +import { Encoding, Uint53 } from "@iov/encoding"; +import { DefaultValueProducer, ValueAndUpdates } from "@iov/stream"; +import equal from "fast-deep-equal"; +import { ReadonlyDate } from "readonly-date"; +import { Stream } from "xstream"; + +import { CosmosBech32Prefix, pubkeyToAddress } from "./address"; +import { Caip5 } from "./caip5"; +import { decodeAmount, parseTxsResponse } from "./decode"; +import { RestClient, TxsResponse } from "./restclient"; + +const { fromBase64 } = Encoding; + +interface ChainData { + readonly chainId: ChainId; +} + +// poll every 0.5 seconds (block time 1s) +const defaultPollInterval = 500; + +function buildQueryString({ + height, + id, + maxHeight, + minHeight, + sentFromOrTo, + signedBy, + tags, +}: TransactionQuery): string { + if ([maxHeight, minHeight, signedBy, tags].some(component => component !== undefined)) { + throw new Error("Transaction query by maxHeight, minHeight, signedBy or tags not yet supported"); + } + const heightComponent = height !== undefined ? `tx.height=${height}` : null; + const hashComponent = id !== undefined ? `tx.hash=${id}` : null; + const sentFromOrToComponent = sentFromOrTo !== undefined ? `message.sender=${sentFromOrTo}` : null; + // TODO: Support senders and recipients + // const sentFromOrToComponent = sentFromOrTo !== undefined ? `transfer.recipient=${sentFromOrTo}` : null; + const components: readonly (string | null)[] = [heightComponent, hashComponent, sentFromOrToComponent]; + return components.filter(Boolean).join("&"); +} + +export class CosmosConnection implements BlockchainConnection { + public static async establish(url: string): Promise { + const restClient = new RestClient(url); + const chainData = await this.initialize(restClient); + return new CosmosConnection(restClient, chainData); + } + + private static async initialize(restClient: RestClient): Promise { + const { node_info } = await restClient.nodeInfo(); + return { chainId: Caip5.encode(node_info.network) }; + } + + private readonly restClient: RestClient; + private readonly chainData: ChainData; + private readonly primaryToken: Token; + private readonly supportedTokens: readonly Token[]; + + private get prefix(): CosmosBech32Prefix { + return "cosmos"; + } + + private constructor(restClient: RestClient, chainData: ChainData) { + this.restClient = restClient; + this.chainData = chainData; + this.primaryToken = { + fractionalDigits: 6, + tokenName: "Atom", + tokenTicker: "ATOM" as TokenTicker, + }; + this.supportedTokens = [this.primaryToken]; + } + + public disconnect(): void { + return; + } + + public chainId(): ChainId { + return this.chainData.chainId; + } + + public async height(): Promise { + const { block_meta } = await this.restClient.blocksLatest(); + return block_meta.header.height; + } + + public async getToken(searchTicker: TokenTicker): Promise { + return (await this.getAllTokens()).find(({ tokenTicker }) => tokenTicker === searchTicker); + } + + public async getAllTokens(): Promise { + return this.supportedTokens; + } + + public async getAccount(query: AccountQuery): Promise { + const address = isPubkeyQuery(query) ? pubkeyToAddress(query.pubkey, this.prefix) : query.address; + const { result } = await this.restClient.authAccounts(address); + const account = result.value; + const supportedCoins = account.coins.filter(({ denom }) => + this.supportedTokens.find( + ({ tokenTicker }) => (tokenTicker === "ATOM" && denom === "uatom") || tokenTicker === denom, + ), + ); + return account.public_key === null + ? undefined + : { + address: address, + balance: supportedCoins.map(decodeAmount), + pubkey: { + algo: Algorithm.Secp256k1, + data: fromBase64(account.public_key.value) as PubkeyBytes, + }, + }; + } + + public watchAccount(_account: AccountQuery): Stream { + throw new Error("not implemented"); + } + + public async getNonce(query: AddressQuery | PubkeyQuery): Promise { + const address = isPubkeyQuery(query) ? pubkeyToAddress(query.pubkey, this.prefix) : query.address; + const { result } = await this.restClient.authAccounts(address); + const account = result.value; + return parseInt(account.sequence, 10) as Nonce; + } + + public async getNonces(query: AddressQuery | PubkeyQuery, count: number): Promise { + const checkedCount = new Uint53(count).toNumber(); + if (checkedCount === 0) { + return []; + } + const firstNonce = await this.getNonce(query); + return [...new Array(checkedCount)].map((_, i) => (firstNonce + i) as Nonce); + } + + public async getBlockHeader(height: number): Promise { + const { block_meta } = await this.restClient.blocks(height); + return { + id: block_meta.block_id.hash as BlockId, + height: block_meta.header.height, + time: new ReadonlyDate(block_meta.header.time), + transactionCount: block_meta.header.num_txs, + }; + } + + public watchBlockHeaders(): Stream { + throw new Error("not implemented"); + } + + public async getTx( + id: TransactionId, + ): Promise | FailedTransaction> { + try { + const response = await this.restClient.txsById(id); + const chainId = await this.chainId(); + return this.parseAndPopulateTxResponse(response, chainId); + } catch (error) { + if (error.response.status === 404) { + throw new Error("Transaction does not exist"); + } + throw error; + } + } + + public async postTx(tx: PostableBytes): Promise { + const { txhash, raw_log } = await this.restClient.postTx(tx); + const transactionId = txhash as TransactionId; + const firstEvent: BlockInfo = { state: TransactionState.Pending }; + let blockInfoInterval: NodeJS.Timeout; + let lastEventSent: BlockInfo; + const producer = new DefaultValueProducer(firstEvent, { + onStarted: () => { + blockInfoInterval = setInterval(async () => { + const searchResult = (await this.searchTx({ id: transactionId })).find(() => true); + if (searchResult) { + const event: BlockInfo = isConfirmedTransaction(searchResult) + ? { + state: TransactionState.Succeeded, + height: searchResult.height, + confirmations: searchResult.confirmations, + } + : { + state: TransactionState.Failed, + height: searchResult.height, + code: searchResult.code, + message: searchResult.message, + }; + if (!equal(event, lastEventSent)) { + producer.update(event); + lastEventSent = event; + } + } + }, defaultPollInterval); + }, + onStop: () => clearInterval(blockInfoInterval), + }); + return { + blockInfo: new ValueAndUpdates(producer), + transactionId: transactionId, + log: raw_log, + }; + } + + public async searchTx( + query: TransactionQuery, + ): Promise | FailedTransaction)[]> { + const queryString = buildQueryString(query); + const chainId = this.chainId(); + const { txs: responses } = await this.restClient.txs(queryString); + return Promise.all(responses.map(response => this.parseAndPopulateTxResponse(response, chainId))); + } + + public listenTx( + _query: TransactionQuery, + ): Stream | FailedTransaction> { + throw new Error("not implemented"); + } + + public liveTx( + _query: TransactionQuery, + ): Stream | FailedTransaction> { + throw new Error("not implemented"); + } + + public async getFeeQuote(tx: UnsignedTransaction): Promise { + if (!isSendTransaction(tx)) { + throw new Error("Received transaction of unsupported kind."); + } + return { + tokens: { + fractionalDigits: this.primaryToken.fractionalDigits, + quantity: "5000", + tokenTicker: this.primaryToken.tokenTicker, + }, + gasLimit: "200000", + }; + } + + public async withDefaultFee(tx: T): Promise { + return { + ...tx, + fee: await this.getFeeQuote(tx), + }; + } + + private async parseAndPopulateTxResponse( + response: TxsResponse, + chainId: ChainId, + ): Promise | FailedTransaction> { + const sender = (response.tx.value as any).msg[0].value.from_address; + const accountForHeight = await this.restClient.authAccounts(sender, response.height); + const nonce = (parseInt(accountForHeight.result.value.sequence, 10) - 1) as Nonce; + return parseTxsResponse(chainId, parseInt(response.height, 10), nonce, response); + } +} diff --git a/src/cosmosconnector.ts b/src/cosmosconnector.ts new file mode 100644 index 00000000..7e3d4ccd --- /dev/null +++ b/src/cosmosconnector.ts @@ -0,0 +1,18 @@ +import { ChainConnector, ChainId } from "@iov/bcp"; + +import { cosmosCodec } from "./cosmoscodec"; +import { CosmosConnection } from "./cosmosconnection"; + +/** + * A helper to connect to a cosmos-based chain at a given url + */ +export function createCosmosConnector( + url: string, + expectedChainId?: ChainId, +): ChainConnector { + return { + establishConnection: async () => CosmosConnection.establish(url), + codec: cosmosCodec, + expectedChainId: expectedChainId, + }; +} diff --git a/src/decode.spec.ts b/src/decode.spec.ts new file mode 100644 index 00000000..52f29ab7 --- /dev/null +++ b/src/decode.spec.ts @@ -0,0 +1,155 @@ +/* eslint-disable @typescript-eslint/camelcase */ +import { Address, Algorithm, TokenTicker } from "@iov/bcp"; +import { Encoding } from "@iov/encoding"; +import amino from "@tendermint/amino-js"; + +import { + decodeAmount, + decodeFullSignature, + decodePubkey, + decodeSignature, + parseFee, + parseMsg, + parseTx, + parseTxsResponse, +} from "./decode"; +import { chainId, nonce, signedTxJson, txId } from "./testdata.spec"; +import data from "./testdata/cosmoshub.json"; + +const { fromBase64 } = Encoding; + +describe("decode", () => { + const defaultPubkey = { + algo: Algorithm.Secp256k1, + data: fromBase64("AtQaCqFnshaZQp6rIkvAPyzThvCvXSDO+9AzbxVErqJP"), + }; + const defaultSignature = fromBase64( + "1nUcIH0CLT0/nQ0mBTDrT6kMG20NY/PsH7P2gc4bpYNGLEYjBmdWevXUJouSE/9A/60QG9cYeqyTe5kFDeIPxQ==", + ); + const defaultFullSignature = { + nonce: nonce, + pubkey: defaultPubkey, + signature: defaultSignature, + }; + const defaultAmount = { + fractionalDigits: 6, + quantity: "11657995", + tokenTicker: "ATOM" as TokenTicker, + }; + const defaultSendTransaction = { + kind: "bcp/send" as const, + chainId: chainId, + sender: "cosmos1h806c7khnvmjlywdrkdgk2vrayy2mmvf9rxk2r" as Address, + recipient: "cosmos1z7g5w84ynmjyg0kqpahdjqpj7yq34v3suckp0e" as Address, + amount: defaultAmount, + }; + const defaultFee = { + tokens: { + fractionalDigits: 6, + quantity: "5000", + tokenTicker: "ATOM" as TokenTicker, + }, + gasLimit: "200000", + }; + + describe("decodePubkey", () => { + it("works", () => { + const pubkey = { + type: "tendermint/PubKeySecp256k1", + value: "AtQaCqFnshaZQp6rIkvAPyzThvCvXSDO+9AzbxVErqJP", + }; + expect(decodePubkey(pubkey)).toEqual(defaultPubkey); + }); + }); + + describe("decodeSignature", () => { + it("works", () => { + const signature = + "1nUcIH0CLT0/nQ0mBTDrT6kMG20NY/PsH7P2gc4bpYNGLEYjBmdWevXUJouSE/9A/60QG9cYeqyTe5kFDeIPxQ=="; + expect(decodeSignature(signature)).toEqual(defaultSignature); + }); + }); + + describe("decodeFullSignature", () => { + it("works", () => { + const fullSignature = { + pub_key: { + type: "tendermint/PubKeySecp256k1", + value: "AtQaCqFnshaZQp6rIkvAPyzThvCvXSDO+9AzbxVErqJP", + }, + signature: "1nUcIH0CLT0/nQ0mBTDrT6kMG20NY/PsH7P2gc4bpYNGLEYjBmdWevXUJouSE/9A/60QG9cYeqyTe5kFDeIPxQ==", + }; + expect(decodeFullSignature(fullSignature, nonce)).toEqual(defaultFullSignature); + }); + }); + + describe("decodeAmount", () => { + it("works", () => { + const amount: amino.Coin = { + denom: "uatom", + amount: "11657995", + }; + expect(decodeAmount(amount)).toEqual(defaultAmount); + }); + }); + + describe("parseMsg", () => { + it("works", () => { + const msg: amino.Msg = { + type: "cosmos-sdk/MsgSend", + value: { + from_address: "cosmos1h806c7khnvmjlywdrkdgk2vrayy2mmvf9rxk2r", + to_address: "cosmos1z7g5w84ynmjyg0kqpahdjqpj7yq34v3suckp0e", + amount: [ + { + denom: "uatom", + amount: "11657995", + }, + ], + }, + }; + expect(parseMsg(msg, chainId)).toEqual(defaultSendTransaction); + }); + }); + + describe("parseFee", () => { + it("works", () => { + const fee = { + amount: [ + { + denom: "uatom", + amount: "5000", + }, + ], + gas: "200000", + }; + expect(parseFee(fee)).toEqual(defaultFee); + }); + }); + + describe("parseTx", () => { + it("works", () => { + expect(parseTx(data.tx, chainId, nonce)).toEqual(signedTxJson); + }); + }); + + describe("parseTxsResponse", () => { + it("works", () => { + const currentHeight = 2923; + const txsResponse = { + height: "2823", + txhash: txId, + raw_log: '[{"msg_index":0,"success":true,"log":""}]', + tx: data.tx, + }; + const expected = { + ...signedTxJson, + height: 2823, + confirmations: 101, + transactionId: txId, + log: '[{"msg_index":0,"success":true,"log":""}]', + }; + expect(parseTxsResponse(chainId, currentHeight, nonce, txsResponse)).toEqual(expected); + }); + }); +}); diff --git a/src/decode.ts b/src/decode.ts new file mode 100644 index 00000000..8018b2b1 --- /dev/null +++ b/src/decode.ts @@ -0,0 +1,129 @@ +import { + Address, + Algorithm, + Amount, + ChainId, + ConfirmedAndSignedTransaction, + Fee, + FullSignature, + Nonce, + PubkeyBundle, + PubkeyBytes, + SendTransaction, + SignatureBytes, + SignedTransaction, + TokenTicker, + TransactionId, + UnsignedTransaction, +} from "@iov/bcp"; +import { Encoding } from "@iov/encoding"; +import amino from "@tendermint/amino-js"; + +import { TxsResponse } from "./restclient"; +import { isAminoStdTx } from "./types"; + +const { fromBase64 } = Encoding; + +const atom = "ATOM" as TokenTicker; + +export function decodePubkey(pubkey: amino.PubKey): PubkeyBundle { + return { + algo: Algorithm.Secp256k1, + data: fromBase64(pubkey.value) as PubkeyBytes, + }; +} + +export function decodeSignature(signature: string): SignatureBytes { + return fromBase64(signature) as SignatureBytes; +} + +export function decodeFullSignature(signature: amino.StdSignature, nonce: number): FullSignature { + return { + nonce: nonce as Nonce, + pubkey: decodePubkey(signature.pub_key), + signature: decodeSignature(signature.signature), + }; +} + +export function decodeAmount(amount: amino.Coin): Amount { + if (amount.denom !== "uatom") { + throw new Error("Only ATOM amounts are supported"); + } + return { + fractionalDigits: 6, + quantity: amount.amount, + tokenTicker: atom, + }; +} + +export function parseMsg(msg: amino.Msg, chainId: ChainId): SendTransaction { + if (msg.type !== "cosmos-sdk/MsgSend") { + throw new Error("Unknown message type in transaction"); + } + if (!(msg.value as amino.MsgSend).from_address) { + throw new Error("Only MsgSend is supported"); + } + const msgValue = msg.value as amino.MsgSend; + if (msgValue.amount.length !== 1) { + throw new Error("Only MsgSend with one amount is supported"); + } + return { + kind: "bcp/send", + chainId: chainId, + sender: msgValue.from_address as Address, + recipient: msgValue.to_address as Address, + amount: decodeAmount(msgValue.amount[0]), + }; +} + +export function parseFee(fee: amino.StdFee): Fee { + if (fee.amount.length !== 1) { + throw new Error("Only fee with one amount is supported"); + } + return { + tokens: decodeAmount(fee.amount[0]), + gasLimit: fee.gas, + }; +} + +export function parseTx(tx: amino.Tx, chainId: ChainId, nonce: Nonce): SignedTransaction { + const txValue = tx.value; + if (!isAminoStdTx(txValue)) { + throw new Error("Only Amino StdTx is supported"); + } + if (txValue.msg.length !== 1) { + throw new Error("Only single-message transactions currently supported"); + } + + const [primarySignature] = txValue.signatures.map(signature => decodeFullSignature(signature, nonce)); + const msg = parseMsg(txValue.msg[0], chainId); + const fee = parseFee(txValue.fee); + + const transaction = { + ...msg, + chainId: chainId, + memo: txValue.memo, + fee: fee, + }; + + return { + transaction: transaction, + signatures: [primarySignature], + }; +} + +export function parseTxsResponse( + chainId: ChainId, + currentHeight: number, + nonce: Nonce, + response: TxsResponse, +): ConfirmedAndSignedTransaction { + const height = parseInt(response.height, 10); + return { + ...parseTx(response.tx, chainId, nonce), + height: height, + confirmations: currentHeight - height + 1, + transactionId: response.txhash as TransactionId, + log: response.raw_log, + }; +} diff --git a/src/encode.spec.ts b/src/encode.spec.ts new file mode 100644 index 00000000..4b4179e5 --- /dev/null +++ b/src/encode.spec.ts @@ -0,0 +1,326 @@ +/* eslint-disable @typescript-eslint/camelcase */ +import { + Address, + Algorithm, + Amount, + ChainId, + Nonce, + PubkeyBytes, + SendTransaction, + SignatureBytes, + SignedTransaction, + TokenTicker, +} from "@iov/bcp"; +import { Encoding } from "@iov/encoding"; + +import { + buildSignedTx, + buildUnsignedTx, + encodeAmount, + encodeFee, + encodeFullSignature, + encodePubkey, +} from "./encode"; + +const { fromBase64 } = Encoding; + +describe("encode", () => { + const atom = "ATOM" as TokenTicker; + // https://rpc.cosmos.network:26657/tx?hash=0x2268EB5AB730B45F8426078827BB5BB49819CE2B0D74B2C1D191070BADB379F1&prove=true + const defaultPubkey = { + algo: Algorithm.Secp256k1, + data: fromBase64("AtQaCqFnshaZQp6rIkvAPyzThvCvXSDO+9AzbxVErqJP") as PubkeyBytes, + }; + const defaultChainId = "not-used" as ChainId; + const defaultSender = "cosmos1h806c7khnvmjlywdrkdgk2vrayy2mmvf9rxk2r" as Address; + const defaultRecipient = "cosmos1z7g5w84ynmjyg0kqpahdjqpj7yq34v3suckp0e" as Address; + const defaultAmount: Amount = { + fractionalDigits: 6, + quantity: "11657995", + tokenTicker: atom, + }; + const defaultMemo = "hello cosmos hub"; + + describe("encodePubKey", () => { + it("encodes a Secp256k1 pubkey", () => { + expect(encodePubkey(defaultPubkey)).toEqual({ + type: "tendermint/PubKeySecp256k1", + value: "AtQaCqFnshaZQp6rIkvAPyzThvCvXSDO+9AzbxVErqJP", + }); + }); + }); + + describe("encodeAmount", () => { + it("encodes an amount", () => { + expect(encodeAmount(defaultAmount)).toEqual({ + denom: "uatom", + amount: "11657995", + }); + }); + }); + + describe("encodeFee", () => { + it("throws without tokens", () => { + const fee = { + gasLimit: "200000", + }; + expect(() => encodeFee(fee)).toThrowError(/cannot encode fee without tokens/i); + }); + + it("throws without gas limit", () => { + const fee = { + tokens: { + fractionalDigits: 6, + quantity: "5000", + tokenTicker: atom, + }, + }; + expect(() => encodeFee(fee)).toThrowError(/cannot encode fee without gas limit/i); + }); + + it("encodes a fee", () => { + const fee = { + tokens: { + fractionalDigits: 6, + quantity: "5000", + tokenTicker: atom, + }, + gasLimit: "200000", + }; + expect(encodeFee(fee)).toEqual({ + amount: [{ denom: "uatom", amount: "5000" }], + gas: "200000", + }); + }); + }); + + describe("encodeFullSignature", () => { + it("encodes a full signature", () => { + const signature = { + nonce: 0 as Nonce, + pubkey: { + algo: Algorithm.Secp256k1, + data: fromBase64("AtQaCqFnshaZQp6rIkvAPyzThvCvXSDO+9AzbxVErqJP") as PubkeyBytes, + }, + signature: fromBase64( + "1nUcIH0CLT0/nQ0mBTDrT6kMG20NY/PsH7P2gc4bpYNGLEYjBmdWevXUJouSE/9A/60QG9cYeqyTe5kFDeIPxQ==", + ) as SignatureBytes, + }; + expect(encodeFullSignature(signature)).toEqual({ + pub_key: { + type: "tendermint/PubKeySecp256k1", + value: "AtQaCqFnshaZQp6rIkvAPyzThvCvXSDO+9AzbxVErqJP", + }, + signature: "1nUcIH0CLT0/nQ0mBTDrT6kMG20NY/PsH7P2gc4bpYNGLEYjBmdWevXUJouSE/9A/60QG9cYeqyTe5kFDeIPxQ==", + }); + }); + + it("compresses uncompressed public keys", () => { + const signature = { + nonce: 0 as Nonce, + pubkey: { + algo: Algorithm.Secp256k1, + data: fromBase64( + "BE8EGB7ro1ORuFhjOnZcSgwYlpe0DSFjVNUIkNNQxwKQE7WHpoHoNswYeoFkuYpYSKK4mzFzMV/dB0DVAy4lnNU=", + ) as PubkeyBytes, + }, + signature: fromBase64( + "1nUcIH0CLT0/nQ0mBTDrT6kMG20NY/PsH7P2gc4bpYNGLEYjBmdWevXUJouSE/9A/60QG9cYeqyTe5kFDeIPxQ==", + ) as SignatureBytes, + }; + expect(encodeFullSignature(signature)).toEqual({ + pub_key: { + type: "tendermint/PubKeySecp256k1", + value: "A08EGB7ro1ORuFhjOnZcSgwYlpe0DSFjVNUIkNNQxwKQ", + }, + signature: "1nUcIH0CLT0/nQ0mBTDrT6kMG20NY/PsH7P2gc4bpYNGLEYjBmdWevXUJouSE/9A/60QG9cYeqyTe5kFDeIPxQ==", + }); + }); + + it("removes recovery values from signature data", () => { + const signature = { + nonce: 0 as Nonce, + pubkey: { + algo: Algorithm.Secp256k1, + data: fromBase64("AtQaCqFnshaZQp6rIkvAPyzThvCvXSDO+9AzbxVErqJP") as PubkeyBytes, + }, + signature: Uint8Array.from([ + ...fromBase64( + "1nUcIH0CLT0/nQ0mBTDrT6kMG20NY/PsH7P2gc4bpYNGLEYjBmdWevXUJouSE/9A/60QG9cYeqyTe5kFDeIPxQ==", + ), + 99, + ]) as SignatureBytes, + }; + expect(encodeFullSignature(signature)).toEqual({ + pub_key: { + type: "tendermint/PubKeySecp256k1", + value: "AtQaCqFnshaZQp6rIkvAPyzThvCvXSDO+9AzbxVErqJP", + }, + signature: "1nUcIH0CLT0/nQ0mBTDrT6kMG20NY/PsH7P2gc4bpYNGLEYjBmdWevXUJouSE/9A/60QG9cYeqyTe5kFDeIPxQ==", + }); + }); + }); + + describe("buildUnsignedTx", () => { + it("throws for unsupported transaction", () => { + const tx = { + kind: "bns/return_escrow", + chainId: defaultChainId, + escrowId: "defg", + }; + expect(() => buildUnsignedTx(tx)).toThrowError(/received transaction of unsupported kind/i); + }); + + it("builds a send transaction without fee", () => { + const tx = { + kind: "bcp/send", + chainId: defaultChainId, + amount: defaultAmount, + sender: defaultSender, + recipient: defaultRecipient, + memo: defaultMemo, + }; + expect(buildUnsignedTx(tx)).toEqual({ + type: "cosmos-sdk/StdTx", + value: { + msg: [ + { + type: "cosmos-sdk/MsgSend", + value: { + from_address: "cosmos1h806c7khnvmjlywdrkdgk2vrayy2mmvf9rxk2r", + to_address: "cosmos1z7g5w84ynmjyg0kqpahdjqpj7yq34v3suckp0e", + amount: [ + { + denom: "uatom", + amount: "11657995", + }, + ], + }, + }, + ], + signatures: [], + memo: defaultMemo, + fee: { + amount: [], + gas: "", + }, + }, + }); + }); + + it("builds a send transaction with fee", () => { + const tx = { + kind: "bcp/send", + chainId: defaultChainId, + amount: defaultAmount, + sender: defaultSender, + recipient: defaultRecipient, + memo: defaultMemo, + fee: { + tokens: { + fractionalDigits: 6, + quantity: "5000", + tokenTicker: atom, + }, + gasLimit: "200000", + }, + }; + expect(buildUnsignedTx(tx)).toEqual({ + type: "cosmos-sdk/StdTx", + value: { + msg: [ + { + type: "cosmos-sdk/MsgSend", + value: { + from_address: "cosmos1h806c7khnvmjlywdrkdgk2vrayy2mmvf9rxk2r", + to_address: "cosmos1z7g5w84ynmjyg0kqpahdjqpj7yq34v3suckp0e", + amount: [ + { + denom: "uatom", + amount: "11657995", + }, + ], + }, + }, + ], + fee: { + amount: [{ denom: "uatom", amount: "5000" }], + gas: "200000", + }, + signatures: [], + memo: defaultMemo, + }, + }); + }); + }); + + describe("buildSignedTx", () => { + it("builds a send transaction", () => { + const tx: SignedTransaction = { + transaction: { + kind: "bcp/send", + chainId: defaultChainId, + amount: defaultAmount, + sender: defaultSender, + recipient: defaultRecipient, + memo: defaultMemo, + fee: { + tokens: { + fractionalDigits: 6, + quantity: "5000", + tokenTicker: atom, + }, + gasLimit: "200000", + }, + }, + signatures: [ + { + nonce: 0 as Nonce, + pubkey: { + algo: Algorithm.Secp256k1, + data: fromBase64("AtQaCqFnshaZQp6rIkvAPyzThvCvXSDO+9AzbxVErqJP") as PubkeyBytes, + }, + signature: fromBase64( + "1nUcIH0CLT0/nQ0mBTDrT6kMG20NY/PsH7P2gc4bpYNGLEYjBmdWevXUJouSE/9A/60QG9cYeqyTe5kFDeIPxQ==", + ) as SignatureBytes, + }, + ], + }; + expect(buildSignedTx(tx)).toEqual({ + type: "cosmos-sdk/StdTx", + value: { + msg: [ + { + type: "cosmos-sdk/MsgSend", + value: { + from_address: "cosmos1h806c7khnvmjlywdrkdgk2vrayy2mmvf9rxk2r", + to_address: "cosmos1z7g5w84ynmjyg0kqpahdjqpj7yq34v3suckp0e", + amount: [ + { + denom: "uatom", + amount: "11657995", + }, + ], + }, + }, + ], + fee: { + amount: [{ denom: "uatom", amount: "5000" }], + gas: "200000", + }, + signatures: [ + { + pub_key: { + type: "tendermint/PubKeySecp256k1", + value: "AtQaCqFnshaZQp6rIkvAPyzThvCvXSDO+9AzbxVErqJP", + }, + signature: + "1nUcIH0CLT0/nQ0mBTDrT6kMG20NY/PsH7P2gc4bpYNGLEYjBmdWevXUJouSE/9A/60QG9cYeqyTe5kFDeIPxQ==", + }, + ], + memo: defaultMemo, + }, + }); + }); + }); +}); diff --git a/src/encode.ts b/src/encode.ts new file mode 100644 index 00000000..88ddb24d --- /dev/null +++ b/src/encode.ts @@ -0,0 +1,109 @@ +/* eslint-disable @typescript-eslint/camelcase */ +import { + Algorithm, + Amount, + Fee, + FullSignature, + isSendTransaction, + PubkeyBundle, + SignedTransaction, + UnsignedTransaction, +} from "@iov/bcp"; +import { Secp256k1 } from "@iov/crypto"; +import { Encoding } from "@iov/encoding"; +import amino from "@tendermint/amino-js"; + +import { AminoTx } from "./types"; + +const { toBase64 } = Encoding; + +export function encodePubkey(pubkey: PubkeyBundle): amino.PubKey { + switch (pubkey.algo) { + case Algorithm.Secp256k1: + return { + type: "tendermint/PubKeySecp256k1", + value: toBase64(pubkey.data), + }; + case Algorithm.Ed25519: + return { + type: "tendermint/PubKeyEd25519", + value: toBase64(pubkey.data), + }; + default: + throw new Error("Unsupported pubkey algo"); + } +} + +export function encodeAmount(amount: Amount): amino.Coin { + if (amount.tokenTicker !== "ATOM") { + throw new Error("Only ATOM amounts are supported"); + } + return { + denom: "uatom", + amount: amount.quantity, + }; +} + +export function encodeFee(fee: Fee): amino.StdFee { + if (fee.tokens === undefined) { + throw new Error("Cannot encode fee without tokens"); + } + if (fee.gasLimit === undefined) { + throw new Error("Cannot encode fee without gas limit"); + } + return { + amount: [encodeAmount(fee.tokens)], + gas: fee.gasLimit, + }; +} + +export function encodeFullSignature(fullSignature: FullSignature): amino.StdSignature { + return { + pub_key: { + type: "tendermint/PubKeySecp256k1", + value: toBase64(Secp256k1.compressPubkey(fullSignature.pubkey.data)), + }, + // Recovery seems to be unused + signature: toBase64(Secp256k1.trimRecoveryByte(fullSignature.signature)), + }; +} + +export function buildUnsignedTx(tx: UnsignedTransaction): AminoTx { + if (!isSendTransaction(tx)) { + throw new Error("Received transaction of unsupported kind"); + } + return { + type: "cosmos-sdk/StdTx", + value: { + msg: [ + { + type: "cosmos-sdk/MsgSend", + value: { + from_address: tx.sender, + to_address: tx.recipient, + amount: [encodeAmount(tx.amount)], + }, + }, + ], + memo: tx.memo || "", + signatures: [], + fee: tx.fee + ? encodeFee(tx.fee) + : { + amount: [], + gas: "", + }, + }, + }; +} + +export function buildSignedTx(tx: SignedTransaction): AminoTx { + const built = buildUnsignedTx(tx.transaction); + return { + ...built, + value: { + ...built.value, + signatures: tx.signatures.map(encodeFullSignature), + }, + }; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 00000000..4c607b18 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,3 @@ +export { cosmosCodec, CosmosCodec } from "./cosmoscodec"; +export { CosmosConnection } from "./cosmosconnection"; +export { createCosmosConnector } from "./cosmosconnector"; diff --git a/src/restclient.ts b/src/restclient.ts new file mode 100644 index 00000000..8ef49e48 --- /dev/null +++ b/src/restclient.ts @@ -0,0 +1,173 @@ +import { Address, PostableBytes, TransactionId } from "@iov/bcp"; +import amino, { unmarshalTx } from "@tendermint/amino-js"; +import axios, { AxiosInstance } from "axios"; + +import { AminoTx } from "./types"; + +interface NodeInfo { + readonly network: string; +} + +interface NodeInfoResponse { + readonly node_info: NodeInfo; +} + +interface BlockMeta { + readonly header: { + readonly height: number; + readonly time: string; + readonly num_txs: number; + }; + readonly block_id: { + readonly hash: string; + }; +} + +interface Block { + readonly header: { + readonly height: number; + }; +} + +interface BlocksResponse { + readonly block_meta: BlockMeta; + readonly block: Block; +} + +interface AuthAccountsResponse { + readonly result: { + readonly value: amino.BaseAccount; + }; +} + +export interface TxsResponse { + readonly height: string; + readonly txhash: string; + readonly raw_log: string; + readonly tx: AminoTx; +} + +interface SearchTxsResponse { + readonly total_count: string; + readonly count: string; + readonly page_number: string; + readonly page_total: string; + readonly limit: string; + readonly txs: readonly TxsResponse[]; +} + +interface PostTxsParams {} + +interface PostTxsResponse { + readonly height: string; + readonly txhash: string; + readonly code?: number; + readonly raw_log?: string; +} + +type RestClientResponse = + | NodeInfoResponse + | BlocksResponse + | AuthAccountsResponse + | TxsResponse + | SearchTxsResponse + | PostTxsResponse; + +type BroadcastMode = "block" | "sync" | "async"; + +export class RestClient { + private readonly client: AxiosInstance; + // From https://cosmos.network/rpc/#/ICS0/post_txs + // The supported broadcast modes include "block"(return after tx commit), "sync"(return afer CheckTx) and "async"(return right away). + private readonly mode: BroadcastMode; + + public constructor(url: string, mode: BroadcastMode = "block") { + const headers = { + post: { "Content-Type": "application/json" }, + }; + this.client = axios.create({ + baseURL: url, + headers: headers, + }); + this.mode = mode; + } + + public async get(path: string): Promise { + const { data } = await this.client.get(path); + if (data === null) { + throw new Error("Received null response from server"); + } + return data; + } + + public async post(path: string, params: PostTxsParams): Promise { + const { data } = await this.client.post(path, params); + if (data === null) { + throw new Error("Received null response from server"); + } + return data; + } + + 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; + } + + 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 BlocksResponse; + } + + 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 BlocksResponse; + } + + public async authAccounts(address: Address, height?: string): Promise { + const path = + height === undefined ? `/auth/accounts/${address}` : `/auth/accounts/${address}?tx.height=${height}`; + 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; + } + + public async txs(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; + } + + public async txsById(id: TransactionId): 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 postTx(tx: PostableBytes): Promise { + const unmarshalled = unmarshalTx(tx, true); + const params = { + tx: unmarshalled.value, + mode: this.mode, + }; + const responseData = await this.post("/txs", params); + if (!(responseData as any).txhash) { + throw new Error("Unexpected response data format"); + } + return responseData as PostTxsResponse; + } +} diff --git a/src/testdata.spec.ts b/src/testdata.spec.ts new file mode 100644 index 00000000..bf627e38 --- /dev/null +++ b/src/testdata.spec.ts @@ -0,0 +1,64 @@ +import { + Address, + Algorithm, + ChainId, + FullSignature, + Nonce, + PubkeyBundle, + PubkeyBytes, + SendTransaction, + SignatureBytes, + SignedTransaction, + TokenTicker, + TransactionId, +} from "@iov/bcp"; +import { Encoding } from "@iov/encoding"; + +import data from "./testdata/cosmoshub.json"; + +const { fromBase64 } = Encoding; + +export const pubJson: PubkeyBundle = { + algo: Algorithm.Secp256k1, + data: fromBase64(data.tx.value.signatures[0].pub_key.value) as PubkeyBytes, +}; + +export const chainId = "cosmos:cosmoshub-3" as ChainId; + +export const nonce = 99 as Nonce; + +export const sendTxJson: SendTransaction = { + kind: "bcp/send", + chainId: chainId, + sender: data.tx.value.msg[0].value.from_address as Address, + recipient: data.tx.value.msg[0].value.to_address as Address, + memo: data.tx.value.memo, + amount: { + fractionalDigits: 6, + quantity: data.tx.value.msg[0].value.amount[0].amount, + tokenTicker: "ATOM" as TokenTicker, + }, + fee: { + tokens: { + fractionalDigits: 6, + quantity: data.tx.value.fee.amount[0].amount, + tokenTicker: "ATOM" as TokenTicker, + }, + gasLimit: data.tx.value.fee.gas, + }, +}; + +export const signedTxSig: FullSignature = { + nonce: nonce, + pubkey: pubJson, + signature: fromBase64(data.tx.value.signatures[0].signature) as SignatureBytes, +}; + +export const signedTxJson: SignedTransaction = { + transaction: sendTxJson, + signatures: [signedTxSig], +}; + +export const signedTxBin = fromBase64(data.tx_data); + +export const txId = data.id as TransactionId; diff --git a/src/testdata/cosmoshub.json b/src/testdata/cosmoshub.json new file mode 100644 index 00000000..cb33539c --- /dev/null +++ b/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/src/types.ts b/src/types.ts new file mode 100644 index 00000000..c8a0ef72 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,10 @@ +import amino from "@tendermint/amino-js"; + +export type AminoTx = amino.Tx & { readonly value: amino.StdTx }; + +export function isAminoStdTx(txValue: amino.TxValue): txValue is amino.StdTx { + const { memo, msg, fee, signatures } = txValue as amino.StdTx; + return ( + typeof memo === "string" && Array.isArray(msg) && typeof fee === "object" && Array.isArray(signatures) + ); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..aaac1f7d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "outDir": "build", + "declarationDir": "build/types", + "rootDir": "src" + }, + "include": ["src/**/*"] +} diff --git a/tslint.json b/tslint.json new file mode 100644 index 00000000..0946f209 --- /dev/null +++ b/tslint.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tslint.json" +} diff --git a/typedoc.js b/typedoc.js new file mode 100644 index 00000000..e2387c7d --- /dev/null +++ b/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/types/address.d.ts b/types/address.d.ts new file mode 100644 index 00000000..776cb61d --- /dev/null +++ b/types/address.d.ts @@ -0,0 +1,12 @@ +import { Address, PubkeyBundle } from "@iov/bcp"; +export declare type CosmosAddressBech32Prefix = "cosmos" | "cosmosvalcons" | "cosmosvaloper"; +export declare type CosmosPubkeyBech32Prefix = "cosmospub" | "cosmosvalconspub" | "cosmosvaloperpub"; +export declare type CosmosBech32Prefix = CosmosAddressBech32Prefix | CosmosPubkeyBech32Prefix; +export declare function decodeCosmosAddress( + address: Address, +): { + readonly prefix: CosmosAddressBech32Prefix; + readonly data: Uint8Array; +}; +export declare function isValidAddress(address: string): boolean; +export declare function pubkeyToAddress(pubkey: PubkeyBundle, prefix: CosmosBech32Prefix): Address; diff --git a/types/caip5.d.ts b/types/caip5.d.ts new file mode 100644 index 00000000..60de2281 --- /dev/null +++ b/types/caip5.d.ts @@ -0,0 +1,13 @@ +import { ChainId } from "@iov/bcp"; +/** + * Conversion between native chain IDs and CAIP-5 format + * + * @see https://github.com/ChainAgnostic/CAIPs/pull/9 + */ +export declare class Caip5 { + /** + * @param native The `chain_id` field from Tendermint's genesis file + */ + static encode(native: string): ChainId; + static decode(chainId: ChainId): string; +} diff --git a/types/cosmoscodec.d.ts b/types/cosmoscodec.d.ts new file mode 100644 index 00000000..24ca7561 --- /dev/null +++ b/types/cosmoscodec.d.ts @@ -0,0 +1,21 @@ +import { + Address, + ChainId, + Identity, + Nonce, + PostableBytes, + SignedTransaction, + SigningJob, + TransactionId, + TxCodec, + UnsignedTransaction, +} from "@iov/bcp"; +export declare class CosmosCodec implements TxCodec { + bytesToSign(unsigned: UnsignedTransaction, nonce: Nonce): SigningJob; + bytesToPost(signed: SignedTransaction): PostableBytes; + identifier(signed: SignedTransaction): TransactionId; + parseBytes(bytes: PostableBytes, chainId: ChainId, nonce?: Nonce): SignedTransaction; + identityToAddress(identity: Identity): Address; + isValidAddress(address: string): boolean; +} +export declare const cosmosCodec: CosmosCodec; diff --git a/types/cosmosconnection.d.ts b/types/cosmosconnection.d.ts new file mode 100644 index 00000000..24ccface --- /dev/null +++ b/types/cosmosconnection.d.ts @@ -0,0 +1,53 @@ +import { + Account, + AccountQuery, + AddressQuery, + BlockchainConnection, + BlockHeader, + ChainId, + ConfirmedAndSignedTransaction, + ConfirmedTransaction, + FailedTransaction, + Fee, + Nonce, + PostableBytes, + PostTxResponse, + PubkeyQuery, + Token, + TokenTicker, + TransactionId, + TransactionQuery, + UnsignedTransaction, +} from "@iov/bcp"; +import { Stream } from "xstream"; +export declare class CosmosConnection implements BlockchainConnection { + static establish(url: string): Promise; + private static initialize; + private readonly restClient; + private readonly chainData; + private readonly primaryToken; + private readonly supportedTokens; + private get prefix(); + private constructor(); + disconnect(): void; + chainId(): ChainId; + height(): Promise; + getToken(searchTicker: TokenTicker): Promise; + getAllTokens(): Promise; + getAccount(query: AccountQuery): Promise; + watchAccount(_account: AccountQuery): Stream; + getNonce(query: AddressQuery | PubkeyQuery): Promise; + getNonces(query: AddressQuery | PubkeyQuery, count: number): Promise; + getBlockHeader(height: number): Promise; + watchBlockHeaders(): Stream; + getTx(id: TransactionId): Promise | FailedTransaction>; + postTx(tx: PostableBytes): Promise; + searchTx( + query: TransactionQuery, + ): Promise | FailedTransaction)[]>; + listenTx(_query: TransactionQuery): Stream | FailedTransaction>; + liveTx(_query: TransactionQuery): Stream | FailedTransaction>; + getFeeQuote(tx: UnsignedTransaction): Promise; + withDefaultFee(tx: T): Promise; + private parseAndPopulateTxResponse; +} diff --git a/types/cosmosconnector.d.ts b/types/cosmosconnector.d.ts new file mode 100644 index 00000000..db183826 --- /dev/null +++ b/types/cosmosconnector.d.ts @@ -0,0 +1,9 @@ +import { ChainConnector, ChainId } from "@iov/bcp"; +import { CosmosConnection } from "./cosmosconnection"; +/** + * A helper to connect to a cosmos-based chain at a given url + */ +export declare function createCosmosConnector( + url: string, + expectedChainId?: ChainId, +): ChainConnector; diff --git a/types/decode.d.ts b/types/decode.d.ts new file mode 100644 index 00000000..b1442bf6 --- /dev/null +++ b/types/decode.d.ts @@ -0,0 +1,28 @@ +import { + Amount, + ChainId, + ConfirmedAndSignedTransaction, + Fee, + FullSignature, + Nonce, + PubkeyBundle, + SendTransaction, + SignatureBytes, + SignedTransaction, + UnsignedTransaction, +} from "@iov/bcp"; +import amino from "@tendermint/amino-js"; +import { TxsResponse } from "./restclient"; +export declare function decodePubkey(pubkey: amino.PubKey): PubkeyBundle; +export declare function decodeSignature(signature: string): SignatureBytes; +export declare function decodeFullSignature(signature: amino.StdSignature, nonce: number): FullSignature; +export declare function decodeAmount(amount: amino.Coin): Amount; +export declare function parseMsg(msg: amino.Msg, chainId: ChainId): SendTransaction; +export declare function parseFee(fee: amino.StdFee): Fee; +export declare function parseTx(tx: amino.Tx, chainId: ChainId, nonce: Nonce): SignedTransaction; +export declare function parseTxsResponse( + chainId: ChainId, + currentHeight: number, + nonce: Nonce, + response: TxsResponse, +): ConfirmedAndSignedTransaction; diff --git a/types/encode.d.ts b/types/encode.d.ts new file mode 100644 index 00000000..53e5e697 --- /dev/null +++ b/types/encode.d.ts @@ -0,0 +1,9 @@ +import { Amount, Fee, FullSignature, PubkeyBundle, SignedTransaction, UnsignedTransaction } from "@iov/bcp"; +import amino from "@tendermint/amino-js"; +import { AminoTx } from "./types"; +export declare function encodePubkey(pubkey: PubkeyBundle): amino.PubKey; +export declare function encodeAmount(amount: Amount): amino.Coin; +export declare function encodeFee(fee: Fee): amino.StdFee; +export declare function encodeFullSignature(fullSignature: FullSignature): amino.StdSignature; +export declare function buildUnsignedTx(tx: UnsignedTransaction): AminoTx; +export declare function buildSignedTx(tx: SignedTransaction): AminoTx; diff --git a/types/index.d.ts b/types/index.d.ts new file mode 100644 index 00000000..4c607b18 --- /dev/null +++ b/types/index.d.ts @@ -0,0 +1,3 @@ +export { cosmosCodec, CosmosCodec } from "./cosmoscodec"; +export { CosmosConnection } from "./cosmosconnection"; +export { createCosmosConnector } from "./cosmosconnector"; diff --git a/types/restclient.d.ts b/types/restclient.d.ts new file mode 100644 index 00000000..df1d9251 --- /dev/null +++ b/types/restclient.d.ts @@ -0,0 +1,77 @@ +import { Address, PostableBytes, TransactionId } from "@iov/bcp"; +import amino from "@tendermint/amino-js"; +import { AminoTx } from "./types"; +interface NodeInfo { + readonly network: string; +} +interface NodeInfoResponse { + readonly node_info: NodeInfo; +} +interface BlockMeta { + readonly header: { + readonly height: number; + readonly time: string; + readonly num_txs: number; + }; + readonly block_id: { + readonly hash: string; + }; +} +interface Block { + readonly header: { + readonly height: number; + }; +} +interface BlocksResponse { + readonly block_meta: BlockMeta; + readonly block: Block; +} +interface AuthAccountsResponse { + readonly result: { + readonly value: amino.BaseAccount; + }; +} +export interface TxsResponse { + readonly height: string; + readonly txhash: string; + readonly raw_log: string; + readonly tx: AminoTx; +} +interface SearchTxsResponse { + readonly total_count: string; + readonly count: string; + readonly page_number: string; + readonly page_total: string; + readonly limit: string; + readonly txs: readonly TxsResponse[]; +} +interface PostTxsParams {} +interface PostTxsResponse { + readonly height: string; + readonly txhash: string; + readonly code?: number; + readonly raw_log?: string; +} +declare type RestClientResponse = + | NodeInfoResponse + | BlocksResponse + | AuthAccountsResponse + | TxsResponse + | SearchTxsResponse + | PostTxsResponse; +declare type BroadcastMode = "block" | "sync" | "async"; +export declare class RestClient { + private readonly client; + private readonly mode; + constructor(url: string, mode?: BroadcastMode); + get(path: string): Promise; + post(path: string, params: PostTxsParams): Promise; + nodeInfo(): Promise; + blocksLatest(): Promise; + blocks(height: number): Promise; + authAccounts(address: Address, height?: string): Promise; + txs(query: string): Promise; + txsById(id: TransactionId): Promise; + postTx(tx: PostableBytes): Promise; +} +export {}; diff --git a/types/types.d.ts b/types/types.d.ts new file mode 100644 index 00000000..cb5a44a9 --- /dev/null +++ b/types/types.d.ts @@ -0,0 +1,5 @@ +import amino from "@tendermint/amino-js"; +export declare type AminoTx = amino.Tx & { + readonly value: amino.StdTx; +}; +export declare function isAminoStdTx(txValue: amino.TxValue): txValue is amino.StdTx; diff --git a/webpack.web.config.js b/webpack.web.config.js new file mode 100644 index 00000000..a26d8e41 --- /dev/null +++ b/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(["COSMOS_ENABLED"])], + }, +];