Import from @iov/cosmos
This commit is contained in:
commit
0fc62d4aa8
8
.eslintignore
Normal file
8
.eslintignore
Normal file
@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
|
||||
build/
|
||||
custom_types/
|
||||
dist/
|
||||
docs/
|
||||
generated/
|
||||
types/
|
||||
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal 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
8
README.md
Normal file
@ -0,0 +1,8 @@
|
||||
# @iov/cosmos
|
||||
|
||||
[](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
26
jasmine-testrunner.js
Executable 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
54
karma.conf.js
Normal 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
0
nonces/1570527883
Normal file
0
nonces/1573026590
Normal file
0
nonces/1573026590
Normal file
0
nonces/1574869843
Normal file
0
nonces/1574869843
Normal file
0
nonces/1576569788
Normal file
0
nonces/1576569788
Normal file
0
nonces/1576595306
Normal file
0
nonces/1576595306
Normal file
0
nonces/1576678551
Normal file
0
nonces/1576678551
Normal file
0
nonces/1576746493
Normal file
0
nonces/1576746493
Normal file
0
nonces/1576760285
Normal file
0
nonces/1576760285
Normal file
0
nonces/1576767119
Normal file
0
nonces/1576767119
Normal file
0
nonces/1579019908
Normal file
0
nonces/1579019908
Normal file
1
nonces/README.txt
Normal file
1
nonces/README.txt
Normal file
@ -0,0 +1 @@
|
||||
Directory used to trigger lerna package updates for all packages
|
||||
53
package.json
Normal file
53
package.json
Normal 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
78
src/address.spec.ts
Normal 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
52
src/address.ts
Normal 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
79
src/caip5.spec.ts
Normal 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
42
src/caip5.ts
Normal 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
49
src/cosmoscodec.spec.ts
Normal 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
97
src/cosmoscodec.ts
Normal 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();
|
||||
332
src/cosmosconnection.spec.ts
Normal file
332
src/cosmosconnection.spec.ts
Normal 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
285
src/cosmosconnection.ts
Normal 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
18
src/cosmosconnector.ts
Normal 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
155
src/decode.spec.ts
Normal 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
129
src/decode.ts
Normal 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
326
src/encode.spec.ts
Normal 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
109
src/encode.ts
Normal 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
3
src/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { cosmosCodec, CosmosCodec } from "./cosmoscodec";
|
||||
export { CosmosConnection } from "./cosmosconnection";
|
||||
export { createCosmosConnector } from "./cosmosconnector";
|
||||
173
src/restclient.ts
Normal file
173
src/restclient.ts
Normal 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
64
src/testdata.spec.ts
Normal 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
44
src/testdata/cosmoshub.json
vendored
Normal 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
10
src/types.ts
Normal 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
10
tsconfig.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"outDir": "build",
|
||||
"declarationDir": "build/types",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
3
tslint.json
Normal file
3
tslint.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../../tslint.json"
|
||||
}
|
||||
14
typedoc.js
Normal file
14
typedoc.js
Normal 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
12
types/address.d.ts
vendored
Normal 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
13
types/caip5.d.ts
vendored
Normal 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
21
types/cosmoscodec.d.ts
vendored
Normal 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
53
types/cosmosconnection.d.ts
vendored
Normal 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
9
types/cosmosconnector.d.ts
vendored
Normal 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
28
types/decode.d.ts
vendored
Normal 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
9
types/encode.d.ts
vendored
Normal 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
3
types/index.d.ts
vendored
Normal 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
77
types/restclient.d.ts
vendored
Normal 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
5
types/types.d.ts
vendored
Normal 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
19
webpack.web.config.js
Normal 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"])],
|
||||
},
|
||||
];
|
||||
Loading…
Reference in New Issue
Block a user