Merge pull request #1 from confio/support-multiple-tokens

Support multiple tokens
This commit is contained in:
Ethan Frey 2020-01-23 11:23:30 +01:00 committed by GitHub
commit de52df68d4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 364 additions and 201 deletions

12
.editorconfig Normal file
View File

@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.py]
indent_size = 4

61
.eslintrc.json Normal file
View File

@ -0,0 +1,61 @@
{
"env": {
"es6": true,
"jasmine": true,
"node": true,
"worker": true
},
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2018
},
"plugins": ["@typescript-eslint", "prettier", "simple-import-sort", "import"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"prettier/@typescript-eslint",
"plugin:prettier/recommended",
"plugin:import/typescript"
],
"rules": {
"curly": ["warn", "multi-line", "consistent"],
"no-console": ["warn", { "allow": ["error", "info", "warn"] }],
"no-param-reassign": "warn",
"no-shadow": "warn",
"prefer-const": "warn",
"spaced-comment": ["warn", "always", { "line": { "markers": ["/ <reference"] } }],
"import/no-cycle": "warn",
"simple-import-sort/sort": "warn",
"@typescript-eslint/explicit-function-return-type": ["warn", { "allowExpressions": true }],
"@typescript-eslint/no-empty-interface": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
"@typescript-eslint/no-use-before-define": "warn"
},
"overrides": [
{
"files": "**/*.js",
"rules": {
"@typescript-eslint/no-var-requires": "off"
}
},
{
"files": "**/*.spec.ts",
"rules": {
"@typescript-eslint/no-non-null-assertion": "off"
}
},
{
"files": "jasmine-testrunner.js",
"rules": {
"@typescript-eslint/camelcase": ["error", { "properties": "never" }]
}
},
{
"files": "packages/{iov-bns,iov-tendermint-rpc}/**/*.ts",
"rules": {
"@typescript-eslint/camelcase": ["error", { "allow": ["v0_[0-9]+"] }]
}
}
]
}

4
.prettierrc.json Normal file
View File

@ -0,0 +1,4 @@
{
"printWidth": 110,
"trailingComma": "all"
}

View File

@ -9,6 +9,7 @@ import {
SignableBytes,
SignedTransaction,
SigningJob,
TokenTicker,
TransactionId,
TxCodec,
UnsignedTransaction,
@ -17,10 +18,11 @@ import { Sha256 } from "@iov/crypto";
import { Encoding } from "@iov/encoding";
import { marshalTx, unmarshalTx } from "@tendermint/amino-js";
import { isValidAddress, pubkeyToAddress } from "./address";
import { isValidAddress, pubkeyToAddress, CosmosBech32Prefix } from "./address";
import { Caip5 } from "./caip5";
import { parseTx } from "./decode";
import { buildSignedTx, buildUnsignedTx } from "./encode";
import { TokenInfos } from "./types";
const { toHex, toUtf8 } = Encoding;
@ -43,10 +45,18 @@ function sortJson(json: any): any {
}
export class CosmosCodec implements TxCodec {
private readonly prefix: CosmosBech32Prefix;
private readonly tokens: TokenInfos;
public constructor(prefix: CosmosBech32Prefix, tokens: TokenInfos) {
this.prefix = prefix;
this.tokens = tokens;
}
public bytesToSign(unsigned: UnsignedTransaction, nonce: Nonce): SigningJob {
const accountNumber = 0;
const memo = (unsigned as any).memo;
const built = buildUnsignedTx(unsigned);
const built = buildUnsignedTx(unsigned, this.tokens);
const signMsg = sortJson({
account_number: accountNumber.toString(),
@ -65,7 +75,7 @@ export class CosmosCodec implements TxCodec {
}
public bytesToPost(signed: SignedTransaction): PostableBytes {
const built = buildSignedTx(signed);
const built = buildSignedTx(signed, this.tokens);
const bytes = marshalTx(built, true);
return bytes as PostableBytes;
}
@ -81,12 +91,12 @@ export class CosmosCodec implements TxCodec {
throw new Error("Nonce is required");
}
const parsed = unmarshalTx(bytes);
return parseTx(parsed, chainId, nonce);
// TODO: this needs access to token list
return parseTx(parsed, chainId, nonce, this.tokens);
}
public identityToAddress(identity: Identity): Address {
const prefix = "cosmos";
return pubkeyToAddress(identity.pubkey, prefix);
return pubkeyToAddress(identity.pubkey, this.prefix);
}
public isValidAddress(address: string): boolean {
@ -94,4 +104,15 @@ export class CosmosCodec implements TxCodec {
}
}
export const cosmosCodec = new CosmosCodec();
const defaultPrefix = "cosmos" as CosmosBech32Prefix;
const defaultTokens: TokenInfos = [
{
fractionalDigits: 6,
tokenName: "Atom (Cosmos Hub)",
tokenTicker: "ATOM" as TokenTicker,
denom: "uatom",
},
];
export const cosmosCodec = new CosmosCodec(defaultPrefix, defaultTokens);

View File

@ -14,8 +14,10 @@ import { Secp256k1 } from "@iov/crypto";
import { Encoding } from "@iov/encoding";
import { HdPaths, Secp256k1HdWallet, UserProfile } from "@iov/keycontrol";
import { cosmosCodec } from "./cosmoscodec";
import { cosmosCodec, CosmosCodec } from "./cosmoscodec";
import { CosmosConnection } from "./cosmosconnection";
import { CosmosBech32Prefix } from "./address";
import { TokenInfos } from "./types";
const { fromBase64, toHex } = Encoding;
@ -26,7 +28,7 @@ function pendingWithoutCosmos(): void {
}
describe("CosmosConnection", () => {
const atom = "ATOM" as TokenTicker;
const cosm = "COSM" as TokenTicker;
const httpUrl = "http://localhost:1317";
const defaultChainId = "cosmos:testing" as ChainId;
const defaultEmptyAddress = "cosmos1h806c7khnvmjlywdrkdgk2vrayy2mmvf9rxk2r" as Address;
@ -40,10 +42,28 @@ describe("CosmosConnection", () => {
const faucetPath = HdPaths.cosmos(0);
const defaultRecipient = "cosmos1t70qnpr0az8tf7py83m4ue5y89w58lkjmx0yq2" as Address;
const defaultPrefix = "cosmos" as CosmosBech32Prefix;
// this is for wasmd blockchain
const defaultTokens: TokenInfos = [
{
fractionalDigits: 6,
tokenName: "Fee Token",
tokenTicker: "COSM" as TokenTicker,
denom: "cosm",
},
{
fractionalDigits: 6,
tokenName: "Staking Token",
tokenTicker: "STAKE" as TokenTicker,
denom: "stake",
},
];
describe("establish", () => {
it("can connect to Cosmos via http", async () => {
pendingWithoutCosmos();
const connection = await CosmosConnection.establish(httpUrl);
const connection = await CosmosConnection.establish(httpUrl, defaultPrefix, defaultTokens);
expect(connection).toBeTruthy();
connection.disconnect();
});
@ -52,7 +72,7 @@ describe("CosmosConnection", () => {
describe("chainId", () => {
it("displays the chain ID", async () => {
pendingWithoutCosmos();
const connection = await CosmosConnection.establish(httpUrl);
const connection = await CosmosConnection.establish(httpUrl, defaultPrefix, defaultTokens);
const chainId = connection.chainId();
expect(chainId).toEqual(defaultChainId);
connection.disconnect();
@ -62,7 +82,7 @@ describe("CosmosConnection", () => {
describe("height", () => {
it("displays the current height", async () => {
pendingWithoutCosmos();
const connection = await CosmosConnection.establish(httpUrl);
const connection = await CosmosConnection.establish(httpUrl, defaultPrefix, defaultTokens);
const height = await connection.height();
expect(height).toBeGreaterThan(0);
connection.disconnect();
@ -72,19 +92,19 @@ describe("CosmosConnection", () => {
describe("getToken", () => {
it("displays a given token", async () => {
pendingWithoutCosmos();
const connection = await CosmosConnection.establish(httpUrl);
const token = await connection.getToken("cosm" as TokenTicker);
const connection = await CosmosConnection.establish(httpUrl, defaultPrefix, defaultTokens);
const token = await connection.getToken("COSM" as TokenTicker);
expect(token).toEqual({
fractionalDigits: 6,
tokenName: "Cosm",
tokenTicker: "cosm" as TokenTicker,
tokenName: "Fee Token",
tokenTicker: "COSM" as TokenTicker,
});
connection.disconnect();
});
it("resolves to undefined if the token is not supported", async () => {
pendingWithoutCosmos();
const connection = await CosmosConnection.establish(httpUrl);
const connection = await CosmosConnection.establish(httpUrl, defaultPrefix, defaultTokens);
const token = await connection.getToken("whatever" as TokenTicker);
expect(token).toBeUndefined();
connection.disconnect();
@ -94,20 +114,20 @@ describe("CosmosConnection", () => {
describe("getAllTokens", () => {
it("resolves to a list of all supported tokens", async () => {
pendingWithoutCosmos();
const connection = await CosmosConnection.establish(httpUrl);
const connection = await CosmosConnection.establish(httpUrl, defaultPrefix, defaultTokens);
const tokens = await connection.getAllTokens();
// TODO: make this more flexible
expect(tokens).toEqual([
{
fractionalDigits: 6,
tokenName: "Cosm",
tokenTicker: "cosm" as TokenTicker,
tokenName: "Fee Token",
tokenTicker: "COSM" as TokenTicker,
},
{
fractionalDigits: 6,
tokenName: "Stake",
tokenTicker: "stake" as TokenTicker,
}
tokenName: "Staking Token",
tokenTicker: "STAKE" as TokenTicker,
},
]);
connection.disconnect();
});
@ -116,7 +136,7 @@ describe("CosmosConnection", () => {
describe("getAccount", () => {
it("gets an empty account by address", async () => {
pendingWithoutCosmos();
const connection = await CosmosConnection.establish(httpUrl);
const connection = await CosmosConnection.establish(httpUrl, defaultPrefix, defaultTokens);
const account = await connection.getAccount({ address: defaultEmptyAddress });
expect(account).toBeUndefined();
connection.disconnect();
@ -124,32 +144,31 @@ describe("CosmosConnection", () => {
it("gets an account by address", async () => {
pendingWithoutCosmos();
const connection = await CosmosConnection.establish(httpUrl);
const connection = await CosmosConnection.establish(httpUrl, defaultPrefix, defaultTokens);
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);
// Undefined until we sign a transaction
expect(account.pubkey).toEqual(undefined);
// Starts with two tokens
expect(account.balance.length).toEqual(2);
connection.disconnect();
});
it("gets an account by pubkey", async () => {
pendingWithoutCosmos();
const connection = await CosmosConnection.establish(httpUrl);
const connection = await CosmosConnection.establish(httpUrl, defaultPrefix, defaultTokens);
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);
// Undefined until we sign a transaction
expect(account.pubkey).toEqual(undefined);
// Starts with two tokens
expect(account.balance.length).toEqual(2);
connection.disconnect();
});
});
@ -157,7 +176,7 @@ describe("CosmosConnection", () => {
describe("integration tests", () => {
it("can post and get a transaction", async () => {
pendingWithoutCosmos();
const connection = await CosmosConnection.establish(httpUrl);
const connection = await CosmosConnection.establish(httpUrl, defaultPrefix, defaultTokens);
const profile = new UserProfile();
const wallet = profile.addWallet(Secp256k1HdWallet.fromMnemonic(faucetMnemonic));
const faucet = await profile.createIdentity(wallet.id, defaultChainId, faucetPath);
@ -172,13 +191,17 @@ describe("CosmosConnection", () => {
amount: {
quantity: "75000",
fractionalDigits: 6,
tokenTicker: atom,
tokenTicker: cosm,
},
});
const nonce = await connection.getNonce({ address: faucetAddress });
const signed = await profile.signTransaction(faucet, unsigned, cosmosCodec, nonce);
const postableBytes = cosmosCodec.bytesToPost(signed);
// TODO: we need to use custom codecs everywhere
const codec = new CosmosCodec(defaultPrefix, defaultTokens);
console.log("nonce:", nonce);
const signed = await profile.signTransaction(faucet, unsigned, codec, nonce);
const postableBytes = codec.bytesToPost(signed);
const response = await connection.postTx(postableBytes);
console.log(response);
const { transactionId } = response;
const blockInfo = await response.blockInfo.waitFor(info => !isBlockInfoPending(info));
expect(blockInfo.state).toEqual(TransactionState.Succeeded);
@ -216,7 +239,7 @@ describe("CosmosConnection", () => {
it("can post and search for a transaction", async () => {
pendingWithoutCosmos();
const connection = await CosmosConnection.establish(httpUrl);
const connection = await CosmosConnection.establish(httpUrl, defaultPrefix, defaultTokens);
const profile = new UserProfile();
const wallet = profile.addWallet(Secp256k1HdWallet.fromMnemonic(faucetMnemonic));
const faucet = await profile.createIdentity(wallet.id, defaultChainId, faucetPath);
@ -231,13 +254,16 @@ describe("CosmosConnection", () => {
amount: {
quantity: "75000",
fractionalDigits: 6,
tokenTicker: atom,
tokenTicker: cosm,
},
});
const nonce = await connection.getNonce({ address: faucetAddress });
const signed = await profile.signTransaction(faucet, unsigned, cosmosCodec, nonce);
const postableBytes = cosmosCodec.bytesToPost(signed);
// TODO: we need to use custom codecs everywhere
const codec = new CosmosCodec(defaultPrefix, defaultTokens);
const signed = await profile.signTransaction(faucet, unsigned, codec, nonce);
const postableBytes = codec.bytesToPost(signed);
const response = await connection.postTx(postableBytes);
console.log(response);
const { transactionId } = response;
const blockInfo = await response.blockInfo.waitFor(info => !isBlockInfoPending(info));
expect(blockInfo.state).toEqual(TransactionState.Succeeded);

View File

@ -38,6 +38,7 @@ import { CosmosBech32Prefix, pubkeyToAddress } from "./address";
import { Caip5 } from "./caip5";
import { decodeAmount, parseTxsResponse } from "./decode";
import { RestClient, TxsResponse } from "./restclient";
import { TokenInfos } from "./types";
const { fromBase64 } = Encoding;
@ -70,10 +71,15 @@ function buildQueryString({
}
export class CosmosConnection implements BlockchainConnection {
public static async establish(url: string): Promise<CosmosConnection> {
// we must know prefix and tokens a priori to understand the chain
public static async establish(
url: string,
prefix: CosmosBech32Prefix,
tokenInfo: TokenInfos,
): Promise<CosmosConnection> {
const restClient = new RestClient(url);
const chainData = await this.initialize(restClient);
return new CosmosConnection(restClient, chainData);
return new CosmosConnection(restClient, chainData, prefix, tokenInfo);
}
private static async initialize(restClient: RestClient): Promise<ChainData> {
@ -84,26 +90,34 @@ export class CosmosConnection implements BlockchainConnection {
private readonly restClient: RestClient;
private readonly chainData: ChainData;
private readonly primaryToken: Token;
// TODO: deprecate this???
private readonly supportedTokens: readonly Token[];
private readonly _prefix: CosmosBech32Prefix;
private readonly tokenInfo: TokenInfos;
private get prefix(): CosmosBech32Prefix {
return "cosmos";
return this._prefix;
}
private constructor(restClient: RestClient, chainData: ChainData) {
private constructor(
restClient: RestClient,
chainData: ChainData,
prefix: CosmosBech32Prefix,
tokenInfo: TokenInfos,
) {
this.restClient = restClient;
this.chainData = chainData;
// TODO: this is an argument
this.primaryToken = {
fractionalDigits: 6,
tokenName: "Cosm",
tokenTicker: "cosm" as TokenTicker,
};
this.supportedTokens = [this.primaryToken, {
fractionalDigits: 6,
tokenName: "Stake",
tokenTicker: "stake" as TokenTicker,
}];
this._prefix = prefix;
this.tokenInfo = tokenInfo;
this.supportedTokens = this.tokenInfo.map(info => ({
tokenTicker: info.tokenTicker,
tokenName: info.tokenName,
fractionalDigits: info.fractionalDigits,
}));
this.primaryToken = this.supportedTokens[0];
}
public disconnect(): void {
@ -131,22 +145,23 @@ export class CosmosConnection implements BlockchainConnection {
const address = isPubkeyQuery(query) ? pubkeyToAddress(query.pubkey, this.prefix) : query.address;
const { result } = await this.restClient.authAccounts(address);
const account = result.value;
if (!account.address) {
return undefined;
}
const supportedCoins = account.coins.filter(({ denom }) =>
this.supportedTokens.find(
// TODO: ugly special case - fix this
({ tokenTicker }) => (tokenTicker === "ATOM" && denom === "uatom") || tokenTicker === denom,
),
this.tokenInfo.find(token => token.denom === denom),
);
return account.public_key === null
const pubkey = !account.public_key
? undefined
: {
address: address,
balance: supportedCoins.map(decodeAmount),
pubkey: {
algo: Algorithm.Secp256k1,
data: fromBase64(account.public_key.value) as PubkeyBytes,
},
algo: Algorithm.Secp256k1,
data: fromBase64(account.public_key.value) as PubkeyBytes,
};
return {
address: address,
balance: supportedCoins.map(decodeAmount(this.tokenInfo)),
pubkey: pubkey,
};
}
public watchAccount(_account: AccountQuery): Stream<Account | undefined> {
@ -199,6 +214,7 @@ export class CosmosConnection implements BlockchainConnection {
}
public async postTx(tx: PostableBytes): Promise<PostTxResponse> {
// TODO: we need to check errors here... bad chain-id breaks this
const { txhash, raw_log } = await this.restClient.postTx(tx);
const transactionId = txhash as TransactionId;
const firstEvent: BlockInfo = { state: TransactionState.Pending };
@ -286,6 +302,6 @@ export class CosmosConnection implements BlockchainConnection {
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);
return parseTxsResponse(chainId, parseInt(response.height, 10), nonce, response, this.tokenInfo);
}
}

View File

@ -2,16 +2,20 @@ import { ChainConnector, ChainId } from "@iov/bcp";
import { cosmosCodec } from "./cosmoscodec";
import { CosmosConnection } from "./cosmosconnection";
import { CosmosBech32Prefix } from "./address";
import { TokenInfos } from "./types";
/**
* A helper to connect to a cosmos-based chain at a given url
*/
export function createCosmosConnector(
url: string,
prefix: CosmosBech32Prefix,
tokenInfo: TokenInfos,
expectedChainId?: ChainId,
): ChainConnector<CosmosConnection> {
return {
establishConnection: async () => CosmosConnection.establish(url),
establishConnection: async () => CosmosConnection.establish(url, prefix, tokenInfo),
codec: cosmosCodec,
expectedChainId: expectedChainId,
};

View File

@ -15,6 +15,7 @@ import {
} from "./decode";
import { chainId, nonce, signedTxJson, txId } from "./testdata.spec";
import data from "./testdata/cosmoshub.json";
import { TokenInfos } from "./types";
const { fromBase64 } = Encoding;
@ -51,6 +52,14 @@ describe("decode", () => {
},
gasLimit: "200000",
};
const defaultTokens: TokenInfos = [
{
fractionalDigits: 6,
tokenName: "Atom (Cosmos Hub)",
tokenTicker: "ATOM" as TokenTicker,
denom: "uatom",
},
];
describe("decodePubkey", () => {
it("works", () => {
@ -89,7 +98,7 @@ describe("decode", () => {
denom: "uatom",
amount: "11657995",
};
expect(decodeAmount(amount)).toEqual(defaultAmount);
expect(decodeAmount(defaultTokens)(amount)).toEqual(defaultAmount);
});
});
@ -108,7 +117,7 @@ describe("decode", () => {
],
},
};
expect(parseMsg(msg, chainId)).toEqual(defaultSendTransaction);
expect(parseMsg(msg, chainId, defaultTokens)).toEqual(defaultSendTransaction);
});
});
@ -123,13 +132,13 @@ describe("decode", () => {
],
gas: "200000",
};
expect(parseFee(fee)).toEqual(defaultFee);
expect(parseFee(fee, defaultTokens)).toEqual(defaultFee);
});
});
describe("parseTx", () => {
it("works", () => {
expect(parseTx(data.tx, chainId, nonce)).toEqual(signedTxJson);
expect(parseTx(data.tx, chainId, nonce, defaultTokens)).toEqual(signedTxJson);
});
});
@ -149,7 +158,7 @@ describe("decode", () => {
transactionId: txId,
log: '[{"msg_index":0,"success":true,"log":""}]',
};
expect(parseTxsResponse(chainId, currentHeight, nonce, txsResponse)).toEqual(expected);
expect(parseTxsResponse(chainId, currentHeight, nonce, txsResponse, defaultTokens)).toEqual(expected);
});
});
});

View File

@ -20,7 +20,7 @@ import { Encoding } from "@iov/encoding";
import amino from "@tendermint/amino-js";
import { TxsResponse } from "./restclient";
import { isAminoStdTx } from "./types";
import { isAminoStdTx, TokenInfos, coinToAmount } from "./types";
const { fromBase64 } = Encoding;
@ -45,20 +45,15 @@ export function decodeFullSignature(signature: amino.StdSignature, nonce: number
};
}
export function decodeAmount(amount: amino.Coin): Amount {
// TODO: more uglyness here (breaks unit tests)
if (amount.denom !== "uatom") {
throw new Error("Only ATOM amounts are supported");
}
return {
fractionalDigits: 6,
quantity: amount.amount,
tokenTicker: atom,
// tokenTicker: amount.denom as TokenTicker,
};
}
// TODO: this needs access to token list - we need something more like amountToCoin and coinToAmount here
// and wire that info all the way from both connection and codec.
export function parseMsg(msg: amino.Msg, chainId: ChainId): SendTransaction {
// TODO: return null vs throw exception for undefined???
export const decodeAmount = (tokens: TokenInfos) => (coin: amino.Coin): Amount => {
return coinToAmount(tokens, coin);
};
export function parseMsg(msg: amino.Msg, chainId: ChainId, tokens: TokenInfos): SendTransaction {
if (msg.type !== "cosmos-sdk/MsgSend") {
throw new Error("Unknown message type in transaction");
}
@ -74,21 +69,22 @@ export function parseMsg(msg: amino.Msg, chainId: ChainId): SendTransaction {
chainId: chainId,
sender: msgValue.from_address as Address,
recipient: msgValue.to_address as Address,
amount: decodeAmount(msgValue.amount[0]),
// TODO: this needs access to token list
amount: decodeAmount(tokens)(msgValue.amount[0]),
};
}
export function parseFee(fee: amino.StdFee): Fee {
export function parseFee(fee: amino.StdFee, tokens: TokenInfos): Fee {
if (fee.amount.length !== 1) {
throw new Error("Only fee with one amount is supported");
}
return {
tokens: decodeAmount(fee.amount[0]),
tokens: decodeAmount(tokens)(fee.amount[0]),
gasLimit: fee.gas,
};
}
export function parseTx(tx: amino.Tx, chainId: ChainId, nonce: Nonce): SignedTransaction {
export function parseTx(tx: amino.Tx, chainId: ChainId, nonce: Nonce, tokens: TokenInfos): SignedTransaction {
const txValue = tx.value;
if (!isAminoStdTx(txValue)) {
throw new Error("Only Amino StdTx is supported");
@ -98,8 +94,10 @@ export function parseTx(tx: amino.Tx, chainId: ChainId, nonce: Nonce): SignedTra
}
const [primarySignature] = txValue.signatures.map(signature => decodeFullSignature(signature, nonce));
const msg = parseMsg(txValue.msg[0], chainId);
const fee = parseFee(txValue.fee);
// TODO: this needs access to token list
const msg = parseMsg(txValue.msg[0], chainId, tokens);
// TODO: this needs access to token list
const fee = parseFee(txValue.fee, tokens);
const transaction = {
...msg,
@ -119,10 +117,11 @@ export function parseTxsResponse(
currentHeight: number,
nonce: Nonce,
response: TxsResponse,
tokens: TokenInfos,
): ConfirmedAndSignedTransaction<UnsignedTransaction> {
const height = parseInt(response.height, 10);
return {
...parseTx(response.tx, chainId, nonce),
...parseTx(response.tx, chainId, nonce, tokens),
height: height,
confirmations: currentHeight - height + 1,
transactionId: response.txhash as TransactionId,

View File

@ -21,6 +21,7 @@ import {
encodeFullSignature,
encodePubkey,
} from "./encode";
import { TokenInfos } from "./types";
const { fromBase64 } = Encoding;
@ -40,6 +41,14 @@ describe("encode", () => {
tokenTicker: atom,
};
const defaultMemo = "hello cosmos hub";
const defaultTokens: TokenInfos = [
{
fractionalDigits: 6,
tokenName: "Atom (Cosmos Hub)",
tokenTicker: "ATOM" as TokenTicker,
denom: "uatom",
},
];
describe("encodePubKey", () => {
it("encodes a Secp256k1 pubkey", () => {
@ -52,7 +61,7 @@ describe("encode", () => {
describe("encodeAmount", () => {
it("encodes an amount", () => {
expect(encodeAmount(defaultAmount)).toEqual({
expect(encodeAmount(defaultAmount, defaultTokens)).toEqual({
denom: "uatom",
amount: "11657995",
});
@ -64,7 +73,7 @@ describe("encode", () => {
const fee = {
gasLimit: "200000",
};
expect(() => encodeFee(fee)).toThrowError(/cannot encode fee without tokens/i);
expect(() => encodeFee(fee, defaultTokens)).toThrowError(/cannot encode fee without tokens/i);
});
it("throws without gas limit", () => {
@ -75,7 +84,7 @@ describe("encode", () => {
tokenTicker: atom,
},
};
expect(() => encodeFee(fee)).toThrowError(/cannot encode fee without gas limit/i);
expect(() => encodeFee(fee, defaultTokens)).toThrowError(/cannot encode fee without gas limit/i);
});
it("encodes a fee", () => {
@ -87,7 +96,7 @@ describe("encode", () => {
},
gasLimit: "200000",
};
expect(encodeFee(fee)).toEqual({
expect(encodeFee(fee, defaultTokens)).toEqual({
amount: [{ denom: "uatom", amount: "5000" }],
gas: "200000",
});
@ -168,7 +177,9 @@ describe("encode", () => {
chainId: defaultChainId,
escrowId: "defg",
};
expect(() => buildUnsignedTx(tx)).toThrowError(/received transaction of unsupported kind/i);
expect(() => buildUnsignedTx(tx, defaultTokens)).toThrowError(
/received transaction of unsupported kind/i,
);
});
it("builds a send transaction without fee", () => {
@ -180,7 +191,7 @@ describe("encode", () => {
recipient: defaultRecipient,
memo: defaultMemo,
};
expect(buildUnsignedTx(tx)).toEqual({
expect(buildUnsignedTx(tx, defaultTokens)).toEqual({
type: "cosmos-sdk/StdTx",
value: {
msg: [
@ -225,7 +236,7 @@ describe("encode", () => {
gasLimit: "200000",
},
};
expect(buildUnsignedTx(tx)).toEqual({
expect(buildUnsignedTx(tx, defaultTokens)).toEqual({
type: "cosmos-sdk/StdTx",
value: {
msg: [
@ -286,7 +297,7 @@ describe("encode", () => {
},
],
};
expect(buildSignedTx(tx)).toEqual({
expect(buildSignedTx(tx, defaultTokens)).toEqual({
type: "cosmos-sdk/StdTx",
value: {
msg: [

View File

@ -13,7 +13,7 @@ import { Secp256k1 } from "@iov/crypto";
import { Encoding } from "@iov/encoding";
import amino from "@tendermint/amino-js";
import { AminoTx } from "./types";
import { AminoTx, TokenInfos, amountToCoin } from "./types";
const { toBase64 } = Encoding;
@ -34,17 +34,11 @@ export function encodePubkey(pubkey: PubkeyBundle): amino.PubKey {
}
}
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 encodeAmount(amount: Amount, tokens: TokenInfos): amino.Coin {
return amountToCoin(tokens, amount);
}
export function encodeFee(fee: Fee): amino.StdFee {
export function encodeFee(fee: Fee, tokens: TokenInfos): amino.StdFee {
if (fee.tokens === undefined) {
throw new Error("Cannot encode fee without tokens");
}
@ -52,7 +46,7 @@ export function encodeFee(fee: Fee): amino.StdFee {
throw new Error("Cannot encode fee without gas limit");
}
return {
amount: [encodeAmount(fee.tokens)],
amount: [encodeAmount(fee.tokens, tokens)],
gas: fee.gasLimit,
};
}
@ -68,7 +62,7 @@ export function encodeFullSignature(fullSignature: FullSignature): amino.StdSign
};
}
export function buildUnsignedTx(tx: UnsignedTransaction): AminoTx {
export function buildUnsignedTx(tx: UnsignedTransaction, tokens: TokenInfos): AminoTx {
if (!isSendTransaction(tx)) {
throw new Error("Received transaction of unsupported kind");
}
@ -81,14 +75,14 @@ export function buildUnsignedTx(tx: UnsignedTransaction): AminoTx {
value: {
from_address: tx.sender,
to_address: tx.recipient,
amount: [encodeAmount(tx.amount)],
amount: [encodeAmount(tx.amount, tokens)],
},
},
],
memo: tx.memo || "",
signatures: [],
fee: tx.fee
? encodeFee(tx.fee)
? encodeFee(tx.fee, tokens)
: {
amount: [],
gas: "",
@ -97,8 +91,8 @@ export function buildUnsignedTx(tx: UnsignedTransaction): AminoTx {
};
}
export function buildSignedTx(tx: SignedTransaction): AminoTx {
const built = buildUnsignedTx(tx.transaction);
export function buildSignedTx(tx: SignedTransaction, tokens: TokenInfos): AminoTx {
const built = buildUnsignedTx(tx.transaction, tokens);
return {
...built,
value: {

View File

@ -1,3 +1,4 @@
import { Amount, Token } from "@iov/bcp";
import amino from "@tendermint/amino-js";
export type AminoTx = amino.Tx & { readonly value: amino.StdTx };
@ -8,3 +9,33 @@ export function isAminoStdTx(txValue: amino.TxValue): txValue is amino.StdTx {
typeof memo === "string" && Array.isArray(msg) && typeof fee === "object" && Array.isArray(signatures)
);
}
export interface TokenInfo extends Token {
readonly denom: string;
}
export type TokenInfos = ReadonlyArray<TokenInfo>;
// TODO: alias amino types
export function amountToCoin(lookup: ReadonlyArray<TokenInfo>, amount: Amount): amino.Coin {
const match = lookup.find(({ tokenTicker }) => tokenTicker === amount.tokenTicker);
if (!match) {
throw Error(`unknown ticker: ${amount.tokenTicker}`);
}
return {
denom: match.denom,
amount: amount.quantity,
};
}
export function coinToAmount(tokens: TokenInfos, coin: amino.Coin): Amount {
const match = tokens.find(({ denom }) => denom === coin.denom);
if (!match) {
throw Error(`unknown denom: ${coin.denom}`);
}
return {
tokenTicker: match.tokenTicker,
fractionalDigits: match.fractionalDigits,
quantity: coin.amount,
};
}

21
types/address.d.ts vendored
View File

@ -1,23 +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 type CosmosAddressBech32Prefix = "cosmos" | "cosmosvalcons" | "cosmosvaloper";
export declare type CosmosPubkeyBech32Prefix = "cosmospub" | "cosmosvalconspub" | "cosmosvaloperpub";
export declare type CosmosBech32Prefix = CosmosAddressBech32Prefix | CosmosPubkeyBech32Prefix;
export declare function decodeCosmosAddress(
address: Address
address: Address,
): {
readonly prefix: CosmosAddressBech32Prefix;
readonly data: Uint8Array;
};
export declare function isValidAddress(address: string): boolean;
export declare function pubkeyToAddress(
pubkey: PubkeyBundle,
prefix: CosmosBech32Prefix
): Address;
export declare function pubkeyToAddress(pubkey: PubkeyBundle, prefix: CosmosBech32Prefix): Address;

View File

@ -8,17 +8,18 @@ import {
SigningJob,
TransactionId,
TxCodec,
UnsignedTransaction
UnsignedTransaction,
} from "@iov/bcp";
import { CosmosBech32Prefix } from "./address";
import { TokenInfos } from "./types";
export declare class CosmosCodec implements TxCodec {
private readonly prefix;
private readonly tokens;
constructor(prefix: CosmosBech32Prefix, tokens: TokenInfos);
bytesToSign(unsigned: UnsignedTransaction, nonce: Nonce): SigningJob;
bytesToPost(signed: SignedTransaction): PostableBytes;
identifier(signed: SignedTransaction): TransactionId;
parseBytes(
bytes: PostableBytes,
chainId: ChainId,
nonce?: Nonce
): SignedTransaction;
parseBytes(bytes: PostableBytes, chainId: ChainId, nonce?: Nonce): SignedTransaction;
identityToAddress(identity: Identity): Address;
isValidAddress(address: string): boolean;
}

View File

@ -17,16 +17,20 @@ import {
TokenTicker,
TransactionId,
TransactionQuery,
UnsignedTransaction
UnsignedTransaction,
} from "@iov/bcp";
import { Stream } from "xstream";
import { CosmosBech32Prefix } from "./address";
import { TokenInfos } from "./types";
export declare class CosmosConnection implements BlockchainConnection {
static establish(url: string): Promise<CosmosConnection>;
static establish(url: string, prefix: CosmosBech32Prefix, tokenInfo: TokenInfos): Promise<CosmosConnection>;
private static initialize;
private readonly restClient;
private readonly chainData;
private readonly primaryToken;
private readonly supportedTokens;
private readonly _prefix;
private readonly tokenInfo;
private get prefix();
private constructor();
disconnect(): void;
@ -37,29 +41,16 @@ export declare class CosmosConnection implements BlockchainConnection {
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[]>;
getNonces(query: AddressQuery | PubkeyQuery, count: number): Promise<readonly Nonce[]>;
getBlockHeader(height: number): Promise<BlockHeader>;
watchBlockHeaders(): Stream<BlockHeader>;
getTx(
id: TransactionId
): Promise<
ConfirmedAndSignedTransaction<UnsignedTransaction> | FailedTransaction
>;
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>;
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;

View File

@ -1,9 +1,13 @@
import { ChainConnector, ChainId } from "@iov/bcp";
import { CosmosConnection } from "./cosmosconnection";
import { CosmosBech32Prefix } from "./address";
import { TokenInfos } from "./types";
/**
* A helper to connect to a cosmos-based chain at a given url
*/
export declare function createCosmosConnector(
url: string,
expectedChainId?: ChainId
prefix: CosmosBech32Prefix,
tokenInfo: TokenInfos,
expectedChainId?: ChainId,
): ChainConnector<CosmosConnection>;

23
types/decode.d.ts vendored
View File

@ -9,30 +9,27 @@ import {
SendTransaction,
SignatureBytes,
SignedTransaction,
UnsignedTransaction
UnsignedTransaction,
} from "@iov/bcp";
import amino from "@tendermint/amino-js";
import { TxsResponse } from "./restclient";
import { TokenInfos } from "./types";
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 decodeFullSignature(signature: amino.StdSignature, nonce: number): FullSignature;
export declare const decodeAmount: (tokens: TokenInfos) => (coin: amino.Coin) => Amount;
export declare function parseMsg(msg: amino.Msg, chainId: ChainId, tokens: TokenInfos): SendTransaction;
export declare function parseFee(fee: amino.StdFee, tokens: TokenInfos): Fee;
export declare function parseTx(
tx: amino.Tx,
chainId: ChainId,
nonce: Nonce
nonce: Nonce,
tokens: TokenInfos,
): SignedTransaction;
export declare function parseTxsResponse(
chainId: ChainId,
currentHeight: number,
nonce: Nonce,
response: TxsResponse
response: TxsResponse,
tokens: TokenInfos,
): ConfirmedAndSignedTransaction<UnsignedTransaction>;

23
types/encode.d.ts vendored
View File

@ -1,18 +1,9 @@
import {
Amount,
Fee,
FullSignature,
PubkeyBundle,
SignedTransaction,
UnsignedTransaction
} from "@iov/bcp";
import { Amount, Fee, FullSignature, PubkeyBundle, SignedTransaction, UnsignedTransaction } from "@iov/bcp";
import amino from "@tendermint/amino-js";
import { AminoTx } from "./types";
import { AminoTx, TokenInfos } 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;
export declare function encodeAmount(amount: Amount, tokens: TokenInfos): amino.Coin;
export declare function encodeFee(fee: Fee, tokens: TokenInfos): amino.StdFee;
export declare function encodeFullSignature(fullSignature: FullSignature): amino.StdSignature;
export declare function buildUnsignedTx(tx: UnsignedTransaction, tokens: TokenInfos): AminoTx;
export declare function buildSignedTx(tx: SignedTransaction, tokens: TokenInfos): AminoTx;

View File

@ -69,10 +69,7 @@ export declare class RestClient {
nodeInfo(): Promise<NodeInfoResponse>;
blocksLatest(): Promise<BlocksResponse>;
blocks(height: number): Promise<BlocksResponse>;
authAccounts(
address: Address,
height?: string
): Promise<AuthAccountsResponse>;
authAccounts(address: Address, height?: string): Promise<AuthAccountsResponse>;
txs(query: string): Promise<SearchTxsResponse>;
txsById(id: TransactionId): Promise<TxsResponse>;
postTx(tx: PostableBytes): Promise<PostTxsResponse>;

11
types/types.d.ts vendored
View File

@ -1,7 +1,12 @@
import { Amount, Token } from "@iov/bcp";
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;
export declare function isAminoStdTx(txValue: amino.TxValue): txValue is amino.StdTx;
export interface TokenInfo extends Token {
readonly denom: string;
}
export declare type TokenInfos = ReadonlyArray<TokenInfo>;
export declare function amountToCoin(lookup: ReadonlyArray<TokenInfo>, amount: Amount): amino.Coin;
export declare function coinToAmount(tokens: TokenInfos, coin: amino.Coin): Amount;