Import from @iov/cosmos

This commit is contained in:
Ethan Frey 2020-01-22 09:41:48 +01:00
commit 0fc62d4aa8
49 changed files with 2490 additions and 0 deletions

8
.eslintignore Normal file
View File

@ -0,0 +1,8 @@
node_modules/
build/
custom_types/
dist/
docs/
generated/
types/

19
.gitignore vendored Normal file
View File

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

8
README.md Normal file
View File

@ -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)).

26
jasmine-testrunner.js Executable file
View File

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

54
karma.conf.js Normal file
View File

@ -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"],
},
},
});
};

0
nonces/1570527883 Normal file
View File

0
nonces/1573026590 Normal file
View File

0
nonces/1574869843 Normal file
View File

0
nonces/1576569788 Normal file
View File

0
nonces/1576595306 Normal file
View File

0
nonces/1576678551 Normal file
View File

0
nonces/1576746493 Normal file
View File

0
nonces/1576760285 Normal file
View File

0
nonces/1576767119 Normal file
View File

0
nonces/1579019908 Normal file
View File

1
nonces/README.txt Normal file
View File

@ -0,0 +1 @@
Directory used to trigger lerna package updates for all packages

53
package.json Normal file
View File

@ -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 <admin@iov.one>",
"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"
}
}

78
src/address.spec.ts Normal file
View File

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

52
src/address.ts Normal file
View File

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

79
src/caip5.spec.ts Normal file
View File

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

42
src/caip5.ts Normal file
View File

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

49
src/cosmoscodec.spec.ts Normal file
View File

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

97
src/cosmoscodec.ts Normal file
View File

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

View File

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

285
src/cosmosconnection.ts Normal file
View File

@ -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<CosmosConnection> {
const restClient = new RestClient(url);
const chainData = await this.initialize(restClient);
return new CosmosConnection(restClient, chainData);
}
private static async initialize(restClient: RestClient): Promise<ChainData> {
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<number> {
const { block_meta } = await this.restClient.blocksLatest();
return block_meta.header.height;
}
public async getToken(searchTicker: TokenTicker): Promise<Token | undefined> {
return (await this.getAllTokens()).find(({ tokenTicker }) => tokenTicker === searchTicker);
}
public async getAllTokens(): Promise<readonly Token[]> {
return this.supportedTokens;
}
public async getAccount(query: AccountQuery): Promise<Account | undefined> {
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<Account | undefined> {
throw new Error("not implemented");
}
public async getNonce(query: AddressQuery | PubkeyQuery): Promise<Nonce> {
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<readonly Nonce[]> {
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<BlockHeader> {
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<BlockHeader> {
throw new Error("not implemented");
}
public async getTx(
id: TransactionId,
): Promise<ConfirmedAndSignedTransaction<UnsignedTransaction> | 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<PostTxResponse> {
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<BlockInfo>(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<BlockInfo>(producer),
transactionId: transactionId,
log: raw_log,
};
}
public async searchTx(
query: TransactionQuery,
): Promise<readonly (ConfirmedTransaction<UnsignedTransaction> | 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<ConfirmedTransaction<UnsignedTransaction> | FailedTransaction> {
throw new Error("not implemented");
}
public liveTx(
_query: TransactionQuery,
): Stream<ConfirmedTransaction<UnsignedTransaction> | FailedTransaction> {
throw new Error("not implemented");
}
public async getFeeQuote(tx: UnsignedTransaction): Promise<Fee> {
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<T extends UnsignedTransaction>(tx: T): Promise<T> {
return {
...tx,
fee: await this.getFeeQuote(tx),
};
}
private async parseAndPopulateTxResponse(
response: TxsResponse,
chainId: ChainId,
): Promise<ConfirmedAndSignedTransaction<UnsignedTransaction> | 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);
}
}

18
src/cosmosconnector.ts Normal file
View File

@ -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<CosmosConnection> {
return {
establishConnection: async () => CosmosConnection.establish(url),
codec: cosmosCodec,
expectedChainId: expectedChainId,
};
}

155
src/decode.spec.ts Normal file
View File

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

129
src/decode.ts Normal file
View File

@ -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<UnsignedTransaction> {
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,
};
}

326
src/encode.spec.ts Normal file
View File

@ -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<SendTransaction> = {
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,
},
});
});
});
});

109
src/encode.ts Normal file
View File

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

3
src/index.ts Normal file
View File

@ -0,0 +1,3 @@
export { cosmosCodec, CosmosCodec } from "./cosmoscodec";
export { CosmosConnection } from "./cosmosconnection";
export { createCosmosConnector } from "./cosmosconnector";

173
src/restclient.ts Normal file
View File

@ -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<RestClientResponse> {
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<RestClientResponse> {
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<NodeInfoResponse> {
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<BlocksResponse> {
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<BlocksResponse> {
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<AuthAccountsResponse> {
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<SearchTxsResponse> {
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<TxsResponse> {
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<PostTxsResponse> {
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;
}
}

64
src/testdata.spec.ts Normal file
View File

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

44
src/testdata/cosmoshub.json vendored Normal file
View File

@ -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"
}

10
src/types.ts Normal file
View File

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

10
tsconfig.json Normal file
View File

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"outDir": "build",
"declarationDir": "build/types",
"rootDir": "src"
},
"include": ["src/**/*"]
}

3
tslint.json Normal file
View File

@ -0,0 +1,3 @@
{
"extends": "../../tslint.json"
}

14
typedoc.js Normal file
View File

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

12
types/address.d.ts vendored Normal file
View File

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

13
types/caip5.d.ts vendored Normal file
View File

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

21
types/cosmoscodec.d.ts vendored Normal file
View File

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

53
types/cosmosconnection.d.ts vendored Normal file
View File

@ -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<CosmosConnection>;
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<number>;
getToken(searchTicker: TokenTicker): Promise<Token | undefined>;
getAllTokens(): Promise<readonly Token[]>;
getAccount(query: AccountQuery): Promise<Account | undefined>;
watchAccount(_account: AccountQuery): Stream<Account | undefined>;
getNonce(query: AddressQuery | PubkeyQuery): Promise<Nonce>;
getNonces(query: AddressQuery | PubkeyQuery, count: number): Promise<readonly Nonce[]>;
getBlockHeader(height: number): Promise<BlockHeader>;
watchBlockHeaders(): Stream<BlockHeader>;
getTx(id: TransactionId): Promise<ConfirmedAndSignedTransaction<UnsignedTransaction> | FailedTransaction>;
postTx(tx: PostableBytes): Promise<PostTxResponse>;
searchTx(
query: TransactionQuery,
): Promise<readonly (ConfirmedTransaction<UnsignedTransaction> | FailedTransaction)[]>;
listenTx(_query: TransactionQuery): Stream<ConfirmedTransaction<UnsignedTransaction> | FailedTransaction>;
liveTx(_query: TransactionQuery): Stream<ConfirmedTransaction<UnsignedTransaction> | FailedTransaction>;
getFeeQuote(tx: UnsignedTransaction): Promise<Fee>;
withDefaultFee<T extends UnsignedTransaction>(tx: T): Promise<T>;
private parseAndPopulateTxResponse;
}

9
types/cosmosconnector.d.ts vendored Normal file
View File

@ -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<CosmosConnection>;

28
types/decode.d.ts vendored Normal file
View File

@ -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<UnsignedTransaction>;

9
types/encode.d.ts vendored Normal file
View File

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

3
types/index.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
export { cosmosCodec, CosmosCodec } from "./cosmoscodec";
export { CosmosConnection } from "./cosmosconnection";
export { createCosmosConnector } from "./cosmosconnector";

77
types/restclient.d.ts vendored Normal file
View File

@ -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<RestClientResponse>;
post(path: string, params: PostTxsParams): Promise<RestClientResponse>;
nodeInfo(): Promise<NodeInfoResponse>;
blocksLatest(): Promise<BlocksResponse>;
blocks(height: number): Promise<BlocksResponse>;
authAccounts(address: Address, height?: string): Promise<AuthAccountsResponse>;
txs(query: string): Promise<SearchTxsResponse>;
txsById(id: TransactionId): Promise<TxsResponse>;
postTx(tx: PostableBytes): Promise<PostTxsResponse>;
}
export {};

5
types/types.d.ts vendored Normal file
View File

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

19
webpack.web.config.js Normal file
View File

@ -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"])],
},
];