diff --git a/src/cosmoscodec.ts b/src/cosmoscodec.ts index ebfffabe..b5497f01 100644 --- a/src/cosmoscodec.ts +++ b/src/cosmoscodec.ts @@ -92,7 +92,7 @@ export class CosmosCodec implements TxCodec { } const parsed = unmarshalTx(bytes); // TODO: this needs access to token list - return parseTx(parsed, chainId, nonce); + return parseTx(parsed, chainId, nonce, this.tokens); } public identityToAddress(identity: Identity): Address { diff --git a/src/cosmosconnection.spec.ts b/src/cosmosconnection.spec.ts index 11daaa5c..5acbeb2c 100644 --- a/src/cosmosconnection.spec.ts +++ b/src/cosmosconnection.spec.ts @@ -16,6 +16,8 @@ import { HdPaths, Secp256k1HdWallet, UserProfile } from "@iov/keycontrol"; import { cosmosCodec } from "./cosmoscodec"; import { CosmosConnection } from "./cosmosconnection"; +import { CosmosBech32Prefix } from "./address"; +import { TokenInfos } from "./types"; const { fromBase64, toHex } = Encoding; @@ -40,10 +42,21 @@ describe("CosmosConnection", () => { const faucetPath = HdPaths.cosmos(0); const defaultRecipient = "cosmos1t70qnpr0az8tf7py83m4ue5y89w58lkjmx0yq2" as Address; + const defaultPrefix = "cosmos" as CosmosBech32Prefix; + + const defaultTokens: TokenInfos = [ + { + fractionalDigits: 6, + tokenName: "Atom (Cosmos Hub)", + tokenTicker: "ATOM" as TokenTicker, + denom: "uatom", + }, + ]; + 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 +65,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 +75,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,7 +85,7 @@ describe("CosmosConnection", () => { describe("getToken", () => { it("displays a given token", async () => { pendingWithoutCosmos(); - const connection = await CosmosConnection.establish(httpUrl); + const connection = await CosmosConnection.establish(httpUrl, defaultPrefix, defaultTokens); const token = await connection.getToken("cosm" as TokenTicker); expect(token).toEqual({ fractionalDigits: 6, @@ -84,7 +97,7 @@ describe("CosmosConnection", () => { 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,7 +107,7 @@ 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([ @@ -116,7 +129,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,7 +137,7 @@ 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"); @@ -138,7 +151,7 @@ describe("CosmosConnection", () => { 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"); @@ -157,7 +170,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); @@ -216,7 +229,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); diff --git a/src/cosmosconnection.ts b/src/cosmosconnection.ts index 8d7031ba..f4e8e6d5 100644 --- a/src/cosmosconnection.ts +++ b/src/cosmosconnection.ts @@ -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 { + // we must know prefix and tokens a priori to understand the chain + public static async establish( + url: string, + prefix: CosmosBech32Prefix, + tokenInfo: TokenInfos, + ): Promise { 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 { @@ -84,29 +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 { @@ -135,16 +146,13 @@ export class CosmosConnection implements BlockchainConnection { const { result } = await this.restClient.authAccounts(address); const account = result.value; 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 ? undefined : { address: address, - balance: supportedCoins.map(decodeAmount), + balance: supportedCoins.map(decodeAmount(this.tokenInfo)), pubkey: { algo: Algorithm.Secp256k1, data: fromBase64(account.public_key.value) as PubkeyBytes, @@ -289,6 +297,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); } } diff --git a/src/cosmosconnector.ts b/src/cosmosconnector.ts index 7e3d4ccd..e4b13716 100644 --- a/src/cosmosconnector.ts +++ b/src/cosmosconnector.ts @@ -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 { return { - establishConnection: async () => CosmosConnection.establish(url), + establishConnection: async () => CosmosConnection.establish(url, prefix, tokenInfo), codec: cosmosCodec, expectedChainId: expectedChainId, }; diff --git a/src/decode.spec.ts b/src/decode.spec.ts index 52f29ab7..8ca2e5f1 100644 --- a/src/decode.spec.ts +++ b/src/decode.spec.ts @@ -15,9 +15,19 @@ import { } from "./decode"; import { chainId, nonce, signedTxJson, txId } from "./testdata.spec"; import data from "./testdata/cosmoshub.json"; +import { TokenInfos } from "./types"; const { fromBase64 } = Encoding; +const defaultTokens: TokenInfos = [ + { + fractionalDigits: 6, + tokenName: "Atom (Cosmos Hub)", + tokenTicker: "ATOM" as TokenTicker, + denom: "uatom", + }, +]; + describe("decode", () => { const defaultPubkey = { algo: Algorithm.Secp256k1, @@ -89,7 +99,7 @@ describe("decode", () => { denom: "uatom", amount: "11657995", }; - expect(decodeAmount(amount)).toEqual(defaultAmount); + expect(decodeAmount(defaultTokens)(amount)).toEqual(defaultAmount); }); }); @@ -108,7 +118,7 @@ describe("decode", () => { ], }, }; - expect(parseMsg(msg, chainId)).toEqual(defaultSendTransaction); + expect(parseMsg(msg, chainId, defaultTokens)).toEqual(defaultSendTransaction); }); }); @@ -123,13 +133,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 +159,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); }); }); }); diff --git a/src/decode.ts b/src/decode.ts index 46c60b14..af0f2187 100644 --- a/src/decode.ts +++ b/src/decode.ts @@ -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; @@ -47,20 +47,13 @@ export function decodeFullSignature(signature: amino.StdSignature, nonce: number // 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 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, - }; -} -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"); } @@ -77,21 +70,21 @@ export function parseMsg(msg: amino.Msg, chainId: ChainId): SendTransaction { sender: msgValue.from_address as Address, recipient: msgValue.to_address as Address, // TODO: this needs access to token list - amount: decodeAmount(msgValue.amount[0]), + 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"); @@ -102,9 +95,9 @@ export function parseTx(tx: amino.Tx, chainId: ChainId, nonce: Nonce): SignedTra const [primarySignature] = txValue.signatures.map(signature => decodeFullSignature(signature, nonce)); // TODO: this needs access to token list - const msg = parseMsg(txValue.msg[0], chainId); + const msg = parseMsg(txValue.msg[0], chainId, tokens); // TODO: this needs access to token list - const fee = parseFee(txValue.fee); + const fee = parseFee(txValue.fee, tokens); const transaction = { ...msg, @@ -124,10 +117,11 @@ export function parseTxsResponse( currentHeight: number, nonce: Nonce, response: TxsResponse, + tokens: TokenInfos, ): ConfirmedAndSignedTransaction { 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, diff --git a/src/types.ts b/src/types.ts index dabdc851..03996413 100644 --- a/src/types.ts +++ b/src/types.ts @@ -28,8 +28,8 @@ export function amountToCoin(lookup: ReadonlyArray, amount: Amount): }; } -export function coinToAmount(lookup: ReadonlyArray, coin: amino.Coin): Amount { - const match = lookup.find(({ denom }) => denom === coin.denom); +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}`); }