diff --git a/packages/bcp/package.json b/packages/bcp/package.json index b91f2ec0..4721a2d6 100644 --- a/packages/bcp/package.json +++ b/packages/bcp/package.json @@ -43,6 +43,7 @@ "@iov/crypto": "^2.0.0-alpha.7", "@iov/encoding": "^2.0.0-alpha.7", "@iov/stream": "^2.0.0-alpha.7", + "@iov/utils": "^2.0.0-alpha.7", "fast-deep-equal": "^3.1.1", "readonly-date": "^1.0.0", "xstream": "^11.11.0" diff --git a/packages/bcp/src/cosmwasmcodec.ts b/packages/bcp/src/cosmwasmcodec.ts index b370bdd1..2df8f0ef 100644 --- a/packages/bcp/src/cosmwasmcodec.ts +++ b/packages/bcp/src/cosmwasmcodec.ts @@ -26,13 +26,13 @@ import { pubkeyToAddress } from "./address"; import { Caip5 } from "./caip5"; import { parseTx } from "./decode"; import { buildSignedTx, buildUnsignedTx } from "./encode"; -import { nonceToAccountNumber, nonceToSequence, TokenInfos } from "./types"; +import { BankTokens, nonceToAccountNumber, nonceToSequence } from "./types"; export class CosmWasmCodec implements TxCodec { private readonly addressPrefix: CosmosAddressBech32Prefix; - private readonly tokens: TokenInfos; + private readonly tokens: BankTokens; - public constructor(addressPrefix: CosmosAddressBech32Prefix, tokens: TokenInfos) { + public constructor(addressPrefix: CosmosAddressBech32Prefix, tokens: BankTokens) { this.addressPrefix = addressPrefix; this.tokens = tokens; } @@ -93,7 +93,7 @@ export class CosmWasmCodec implements TxCodec { const defaultPrefix = "cosmos" as CosmosAddressBech32Prefix; -const defaultTokens: TokenInfos = [ +const defaultTokens: BankTokens = [ { fractionalDigits: 6, ticker: "ATOM", diff --git a/packages/bcp/src/cosmwasmconnection.spec.ts b/packages/bcp/src/cosmwasmconnection.spec.ts index 36e29773..6c14f0d1 100644 --- a/packages/bcp/src/cosmwasmconnection.spec.ts +++ b/packages/bcp/src/cosmwasmconnection.spec.ts @@ -14,6 +14,7 @@ import { import { Secp256k1 } from "@iov/crypto"; import { Encoding } from "@iov/encoding"; import { HdPaths, Secp256k1HdWallet, UserProfile } from "@iov/keycontrol"; +import { assert } from "@iov/utils"; import { CosmWasmCodec, cosmWasmCodec } from "./cosmwasmcodec"; import { CosmWasmConnection, TokenConfiguration } from "./cosmwasmconnection"; @@ -33,38 +34,63 @@ describe("CosmWasmConnection", () => { 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; + const faucetAccount = { + pubkey: { + algo: Algorithm.Secp256k1, + data: fromBase64("A08EGB7ro1ORuFhjOnZcSgwYlpe0DSFjVNUIkNNQxwKQ") as PubkeyBytes, + }, + address: "cosmos1pkptre7fdkl6gfrzlesjjvhxhlc3r4gmmk8rs6" as Address, + }; + const unusedAccount = { + pubkey: { + algo: Algorithm.Secp256k1, + data: fromBase64("ArkCaFUJ/IH+vKBmNRCdUVl3mCAhbopk9jjW4Ko4OfRQ") as PubkeyBytes, + }, + address: "cosmos1cjsxept9rkggzxztslae9ndgpdyt2408lk850u" as Address, + }; const defaultPrefix = "cosmos" as CosmosAddressBech32Prefix; // this is for wasmd blockchain - const defaultTokens: TokenConfiguration = [ - { - fractionalDigits: 6, - name: "Fee Token", - ticker: "COSM", - denom: "ucosm", - }, - { - fractionalDigits: 6, - name: "Staking Token", - ticker: "STAKE", - denom: "ustake", - }, - ]; + const defaultConfig: TokenConfiguration = { + bankTokens: [ + { + fractionalDigits: 6, + name: "Fee Token", + ticker: "COSM", + denom: "ucosm", + }, + { + fractionalDigits: 6, + name: "Staking Token", + ticker: "STAKE", + denom: "ustake", + }, + ], + erc20Tokens: [ + { + contractAddress: "cosmos18vd8fpwxzck93qlwghaj6arh4p7c5n89uzcee5", + fractionalDigits: 5, + ticker: "ASH", + name: "Ash Token", + }, + { + contractAddress: "cosmos1hqrdl6wstt8qzshwc6mrumpjk9338k0lr4dqxd", + fractionalDigits: 0, + ticker: "BASH", + name: "Bash Token", + }, + ], + }; describe("establish", () => { it("can connect to Cosmos via http", async () => { pendingWithoutCosmos(); - const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultTokens); + const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig); expect(connection).toBeTruthy(); connection.disconnect(); }); @@ -73,7 +99,7 @@ describe("CosmWasmConnection", () => { describe("chainId", () => { it("displays the chain ID", async () => { pendingWithoutCosmos(); - const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultTokens); + const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig); const chainId = connection.chainId(); expect(chainId).toEqual(defaultChainId); connection.disconnect(); @@ -83,7 +109,7 @@ describe("CosmWasmConnection", () => { describe("height", () => { it("displays the current height", async () => { pendingWithoutCosmos(); - const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultTokens); + const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig); const height = await connection.height(); expect(height).toBeGreaterThan(0); connection.disconnect(); @@ -93,7 +119,7 @@ describe("CosmWasmConnection", () => { describe("getToken", () => { it("displays a given token", async () => { pendingWithoutCosmos(); - const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultTokens); + const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig); const token = await connection.getToken("COSM" as TokenTicker); expect(token).toEqual({ fractionalDigits: 6, @@ -105,7 +131,7 @@ describe("CosmWasmConnection", () => { it("resolves to undefined if the token is not supported", async () => { pendingWithoutCosmos(); - const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultTokens); + const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig); const token = await connection.getToken("whatever" as TokenTicker); expect(token).toBeUndefined(); connection.disconnect(); @@ -115,10 +141,19 @@ describe("CosmWasmConnection", () => { describe("getAllTokens", () => { it("resolves to a list of all supported tokens", async () => { pendingWithoutCosmos(); - const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultTokens); + const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig); const tokens = await connection.getAllTokens(); - // TODO: make this more flexible expect(tokens).toEqual([ + { + fractionalDigits: 5, + tokenName: "Ash Token", + tokenTicker: "ASH" as TokenTicker, + }, + { + fractionalDigits: 0, + tokenName: "Bash Token", + tokenTicker: "BASH" as TokenTicker, + }, { fractionalDigits: 6, tokenName: "Fee Token", @@ -137,7 +172,7 @@ describe("CosmWasmConnection", () => { describe("identifier", () => { it("calculates tx hash from PostableBytes", async () => { pendingWithoutCosmos(); - const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultTokens); + const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig); const postable = cosmWasmCodec.bytesToPost(signedTxJson); const id = await connection.identifier(postable); expect(id).toMatch(/^[0-9A-F]{64}$/); @@ -148,7 +183,7 @@ describe("CosmWasmConnection", () => { describe("getAccount", () => { it("gets an empty account by address", async () => { pendingWithoutCosmos(); - const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultTokens); + const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig); const account = await connection.getAccount({ address: defaultEmptyAddress }); expect(account).toBeUndefined(); connection.disconnect(); @@ -156,35 +191,50 @@ describe("CosmWasmConnection", () => { it("gets an account by address", async () => { pendingWithoutCosmos(); - const connection = await CosmWasmConnection.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); - // Undefined until we sign a transaction (on multiple runs against one server this will be set), allow both - if (account.pubkey !== undefined) { - expect(account.pubkey).toEqual(defaultPubkey); - } - // Starts with two tokens - expect(account.balance.length).toEqual(2); + const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig); + const account = await connection.getAccount({ address: unusedAccount.address }); + assert(account, "Account must be defined"); + expect(account.address).toEqual(unusedAccount.address); + expect(account.pubkey).toBeUndefined(); + expect(account.balance).toEqual([ + { + tokenTicker: "ASH" as TokenTicker, + quantity: "12812345", + fractionalDigits: 5, + }, + { + tokenTicker: "BASH" as TokenTicker, + quantity: "42", + fractionalDigits: 0, + }, + { + tokenTicker: "COSM" as TokenTicker, + quantity: "1000000000", + fractionalDigits: 6, + }, + { + tokenTicker: "STAKE" as TokenTicker, + quantity: "1000000000", + fractionalDigits: 6, + }, + ]); connection.disconnect(); }); it("gets an account by pubkey", async () => { pendingWithoutCosmos(); - const connection = await CosmWasmConnection.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); - // Undefined until we sign a transaction (on multiple runs against one server this will be set), allow both - if (account.pubkey !== undefined) { - expect(account.pubkey).toEqual(defaultPubkey); - } - // Starts with two tokens - expect(account.balance.length).toEqual(2); + const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig); + const byAddress = await connection.getAccount({ address: unusedAccount.address }); + const byPubkey = await connection.getAccount({ pubkey: unusedAccount.pubkey }); + expect(byPubkey).toEqual(byAddress); // above we verified that by address works as expected + connection.disconnect(); + }); + + it("has a pubkey when getting account with transactions", async () => { + pendingWithoutCosmos(); + const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig); + const account = await connection.getAccount({ address: faucetAccount.address }); + expect(account?.pubkey).toEqual(faucetAccount.pubkey); connection.disconnect(); }); }); @@ -192,7 +242,7 @@ describe("CosmWasmConnection", () => { describe("integration tests", () => { it("can post and get a transaction", async () => { pendingWithoutCosmos(); - const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultTokens); + const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig); const profile = new UserProfile(); const wallet = profile.addWallet(Secp256k1HdWallet.fromMnemonic(faucetMnemonic)); const faucet = await profile.createIdentity(wallet.id, defaultChainId, faucetPath); @@ -212,7 +262,7 @@ describe("CosmWasmConnection", () => { }); const nonce = await connection.getNonce({ address: faucetAddress }); // TODO: we need to use custom codecs everywhere - const codec = new CosmWasmCodec(defaultPrefix, defaultTokens); + const codec = new CosmWasmCodec(defaultPrefix, defaultConfig.bankTokens); const signed = await profile.signTransaction(faucet, unsigned, codec, nonce); const postableBytes = codec.bytesToPost(signed); const response = await connection.postTx(postableBytes); @@ -257,7 +307,7 @@ describe("CosmWasmConnection", () => { it("can post and search for a transaction", async () => { pendingWithoutCosmos(); - const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultTokens); + const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig); const profile = new UserProfile(); const wallet = profile.addWallet(Secp256k1HdWallet.fromMnemonic(faucetMnemonic)); const faucet = await profile.createIdentity(wallet.id, defaultChainId, faucetPath); @@ -277,7 +327,7 @@ describe("CosmWasmConnection", () => { }); const nonce = await connection.getNonce({ address: faucetAddress }); // TODO: we need to use custom codecs everywhere - const codec = new CosmWasmCodec(defaultPrefix, defaultTokens); + const codec = new CosmWasmCodec(defaultPrefix, defaultConfig.bankTokens); const signed = await profile.signTransaction(faucet, unsigned, codec, nonce); const postableBytes = codec.bytesToPost(signed); const response = await connection.postTx(postableBytes); diff --git a/packages/bcp/src/cosmwasmconnection.ts b/packages/bcp/src/cosmwasmconnection.ts index 25790a6a..d8e8dbb5 100644 --- a/packages/bcp/src/cosmwasmconnection.ts +++ b/packages/bcp/src/cosmwasmconnection.ts @@ -4,6 +4,7 @@ import { Account, AccountQuery, AddressQuery, + Amount, BlockchainConnection, BlockHeader, BlockId, @@ -37,7 +38,7 @@ import { Stream } from "xstream"; import { decodeCosmosPubkey, pubkeyToAddress } from "./address"; import { Caip5 } from "./caip5"; import { decodeAmount, parseTxsResponse } from "./decode"; -import { accountToNonce, TokenInfo } from "./types"; +import { accountToNonce, BankToken, Erc20Token } from "./types"; const { toHex } = Encoding; @@ -69,7 +70,12 @@ function buildQueryString({ return components.filter(Boolean).join("&"); } -export type TokenConfiguration = ReadonlyArray; +export interface TokenConfiguration { + /** Supported tokens of the Cosmos SDK bank module */ + readonly bankTokens: ReadonlyArray; + /** Smart contract based tokens (ERC20 compatible). Unset means empty array. */ + readonly erc20Tokens?: ReadonlyArray; +} export class CosmWasmConnection implements BlockchainConnection { // we must know prefix and tokens a priori to understand the chain @@ -91,10 +97,11 @@ export class CosmWasmConnection implements BlockchainConnection { private readonly restClient: RestClient; private readonly chainData: ChainData; private readonly addressPrefix: CosmosAddressBech32Prefix; - private readonly tokenInfo: readonly TokenInfo[]; + private readonly bankTokens: readonly BankToken[]; + private readonly erc20Tokens: readonly Erc20Token[]; // these are derived from arguments (cached for use in multiple functions) - private readonly primaryToken: Token; + private readonly feeToken: BankToken | undefined; private readonly supportedTokens: readonly Token[]; private constructor( @@ -106,14 +113,17 @@ export class CosmWasmConnection implements BlockchainConnection { this.restClient = restClient; this.chainData = chainData; this.addressPrefix = addressPrefix; - this.tokenInfo = tokens; - - this.supportedTokens = tokens.map(info => ({ - tokenTicker: info.ticker as TokenTicker, - tokenName: info.name, - fractionalDigits: info.fractionalDigits, - })); - this.primaryToken = this.supportedTokens[0]; + this.bankTokens = tokens.bankTokens; + this.feeToken = this.bankTokens.find(() => true); + const erc20Tokens = tokens.erc20Tokens || []; + this.erc20Tokens = erc20Tokens; + this.supportedTokens = [...tokens.bankTokens, ...erc20Tokens] + .map(info => ({ + tokenTicker: info.ticker as TokenTicker, + tokenName: info.name, + fractionalDigits: info.fractionalDigits, + })) + .sort((a, b) => a.tokenTicker.localeCompare(b.tokenTicker)); } public disconnect(): void { @@ -151,14 +161,35 @@ export class CosmWasmConnection implements BlockchainConnection { if (!account.address) { return undefined; } - const supportedCoins = account.coins.filter(({ denom }) => - this.tokenInfo.find(token => token.denom === denom), + + const supportedBankCoins = account.coins.filter(({ denom }) => + this.bankTokens.find(token => token.denom === denom), ); + const erc20Amounts = await Promise.all( + this.erc20Tokens.map( + async (erc20): Promise => { + const queryMsg = { balance: { address: address } }; + const response = JSON.parse( + await this.restClient.queryContractSmart(erc20.contractAddress, queryMsg), + ); + return { + fractionalDigits: erc20.fractionalDigits, + quantity: response.balance, + tokenTicker: erc20.ticker as TokenTicker, + }; + }, + ), + ); + + const balance = [ + ...supportedBankCoins.map(coin => decodeAmount(this.bankTokens, coin)), + ...erc20Amounts, + ].sort((a, b) => a.tokenTicker.localeCompare(b.tokenTicker)); const pubkey = !account.public_key ? undefined : decodeCosmosPubkey(account.public_key); return { address: address, - balance: supportedCoins.map(coin => decodeAmount(this.tokenInfo, coin)), + balance: balance, pubkey: pubkey, }; } @@ -280,11 +311,12 @@ export class CosmWasmConnection implements BlockchainConnection { if (!isSendTransaction(tx)) { throw new Error("Received transaction of unsupported kind."); } + if (!this.feeToken) throw new Error("This connection has no fee token configured."); return { tokens: { - fractionalDigits: this.primaryToken.fractionalDigits, + fractionalDigits: this.feeToken.fractionalDigits, quantity: "5000", - tokenTicker: this.primaryToken.tokenTicker, + tokenTicker: this.feeToken.ticker as TokenTicker, }, gasLimit: "200000", }; @@ -321,6 +353,6 @@ export class CosmWasmConnection implements BlockchainConnection { // this is technically not the proper nonce. maybe this causes issues for sig validation? // leaving for now unless it causes issues const sequence = (accountForHeight.result.value.sequence - 1) as Nonce; - return parseTxsResponse(chainId, parseInt(response.height, 10), sequence, response, this.tokenInfo); + return parseTxsResponse(chainId, parseInt(response.height, 10), sequence, response, this.bankTokens); } } diff --git a/packages/bcp/src/cosmwasmconnector.ts b/packages/bcp/src/cosmwasmconnector.ts index 61aaf006..cdf1c4b2 100644 --- a/packages/bcp/src/cosmwasmconnector.ts +++ b/packages/bcp/src/cosmwasmconnector.ts @@ -13,7 +13,7 @@ export function createCosmWasmConnector( tokens: TokenConfiguration, expectedChainId?: ChainId, ): ChainConnector { - const codec = new CosmWasmCodec(addressPrefix, tokens); + const codec = new CosmWasmCodec(addressPrefix, tokens.bankTokens); return { establishConnection: async () => CosmWasmConnection.establish(url, addressPrefix, tokens), codec: codec, diff --git a/packages/bcp/src/decode.spec.ts b/packages/bcp/src/decode.spec.ts index fe8feb51..5f48b6f3 100644 --- a/packages/bcp/src/decode.spec.ts +++ b/packages/bcp/src/decode.spec.ts @@ -15,7 +15,7 @@ import { } from "./decode"; import { chainId, nonce, signedTxJson, txId } from "./testdata.spec"; import data from "./testdata/cosmoshub.json"; -import { TokenInfos } from "./types"; +import { BankTokens } from "./types"; const { fromBase64, fromHex } = Encoding; @@ -52,7 +52,7 @@ describe("decode", () => { }, gasLimit: "200000", }; - const defaultTokens: TokenInfos = [ + const defaultTokens: BankTokens = [ { fractionalDigits: 6, ticker: "ATOM", diff --git a/packages/bcp/src/decode.ts b/packages/bcp/src/decode.ts index c68325ba..03b1b7fd 100644 --- a/packages/bcp/src/decode.ts +++ b/packages/bcp/src/decode.ts @@ -19,7 +19,7 @@ import { } from "@iov/bcp"; import { Decimal, Encoding } from "@iov/encoding"; -import { TokenInfos } from "./types"; +import { BankTokens } from "./types"; const { fromBase64 } = Encoding; @@ -52,7 +52,7 @@ export function decodeFullSignature(signature: types.StdSignature, nonce: number }; } -export function coinToDecimal(tokens: TokenInfos, coin: types.Coin): readonly [Decimal, string] { +export function coinToDecimal(tokens: BankTokens, coin: types.Coin): readonly [Decimal, string] { const match = tokens.find(({ denom }) => denom === coin.denom); if (!match) { throw Error(`unknown denom: ${coin.denom}`); @@ -61,7 +61,7 @@ export function coinToDecimal(tokens: TokenInfos, coin: types.Coin): readonly [D return [value, match.ticker]; } -export function decodeAmount(tokens: TokenInfos, coin: types.Coin): Amount { +export function decodeAmount(tokens: BankTokens, coin: types.Coin): Amount { const [value, ticker] = coinToDecimal(tokens, coin); return { quantity: value.atomics, @@ -70,7 +70,7 @@ export function decodeAmount(tokens: TokenInfos, coin: types.Coin): Amount { }; } -export function parseMsg(msg: types.Msg, chainId: ChainId, tokens: TokenInfos): UnsignedTransaction { +export function parseMsg(msg: types.Msg, chainId: ChainId, tokens: BankTokens): UnsignedTransaction { if (types.isMsgSend(msg)) { if (msg.value.amount.length !== 1) { throw new Error("Only MsgSend with one amount is supported"); @@ -93,7 +93,7 @@ export function parseMsg(msg: types.Msg, chainId: ChainId, tokens: TokenInfos): } } -export function parseFee(fee: types.StdFee, tokens: TokenInfos): Fee { +export function parseFee(fee: types.StdFee, tokens: BankTokens): Fee { if (fee.amount.length !== 1) { throw new Error("Only fee with one amount is supported"); } @@ -107,7 +107,7 @@ export function parseTx( txValue: types.StdTx, chainId: ChainId, nonce: Nonce, - tokens: TokenInfos, + tokens: BankTokens, ): SignedTransaction { if (!types.isAminoStdTx(txValue)) { throw new Error("Only Amino StdTx is supported"); @@ -138,7 +138,7 @@ export function parseTxsResponse( currentHeight: number, nonce: Nonce, response: TxsResponse, - tokens: TokenInfos, + tokens: BankTokens, ): ConfirmedAndSignedTransaction { const height = parseInt(response.height, 10); return { diff --git a/packages/bcp/src/encode.spec.ts b/packages/bcp/src/encode.spec.ts index b81e1c37..436430d5 100644 --- a/packages/bcp/src/encode.spec.ts +++ b/packages/bcp/src/encode.spec.ts @@ -21,7 +21,7 @@ import { encodeFullSignature, encodePubkey, } from "./encode"; -import { TokenInfos } from "./types"; +import { BankTokens } from "./types"; const { fromBase64 } = Encoding; @@ -41,7 +41,7 @@ describe("encode", () => { tokenTicker: atom, }; const defaultMemo = "hello cosmos hub"; - const defaultTokens: TokenInfos = [ + const defaultTokens: BankTokens = [ { fractionalDigits: 6, ticker: "ATOM", diff --git a/packages/bcp/src/encode.ts b/packages/bcp/src/encode.ts index 479895b2..a0a84d8e 100644 --- a/packages/bcp/src/encode.ts +++ b/packages/bcp/src/encode.ts @@ -12,7 +12,7 @@ import { } from "@iov/bcp"; import { Decimal, Encoding } from "@iov/encoding"; -import { TokenInfos } from "./types"; +import { BankTokens } from "./types"; const { toBase64 } = Encoding; @@ -33,7 +33,7 @@ export function encodePubkey(pubkey: PubkeyBundle): types.PubKey { } } -export function decimalToCoin(lookup: TokenInfos, value: Decimal, ticker: string): types.Coin { +export function decimalToCoin(lookup: BankTokens, value: Decimal, ticker: string): types.Coin { const match = lookup.find(token => token.ticker === ticker); if (!match) { throw Error(`unknown ticker: ${ticker}`); @@ -49,7 +49,7 @@ export function decimalToCoin(lookup: TokenInfos, value: Decimal, ticker: string }; } -export function encodeAmount(amount: Amount, tokens: TokenInfos): types.Coin { +export function encodeAmount(amount: Amount, tokens: BankTokens): types.Coin { return decimalToCoin( tokens, Decimal.fromAtomics(amount.quantity, amount.fractionalDigits), @@ -57,7 +57,7 @@ export function encodeAmount(amount: Amount, tokens: TokenInfos): types.Coin { ); } -export function encodeFee(fee: Fee, tokens: TokenInfos): types.StdFee { +export function encodeFee(fee: Fee, tokens: BankTokens): types.StdFee { if (fee.tokens === undefined) { throw new Error("Cannot encode fee without tokens"); } @@ -79,7 +79,7 @@ export function encodeFullSignature(fullSignature: FullSignature): types.StdSign } } -export function buildUnsignedTx(tx: UnsignedTransaction, tokens: TokenInfos): types.AminoTx { +export function buildUnsignedTx(tx: UnsignedTransaction, tokens: BankTokens): types.AminoTx { if (!isSendTransaction(tx)) { throw new Error("Received transaction of unsupported kind"); } @@ -108,7 +108,7 @@ export function buildUnsignedTx(tx: UnsignedTransaction, tokens: TokenInfos): ty }; } -export function buildSignedTx(tx: SignedTransaction, tokens: TokenInfos): types.AminoTx { +export function buildSignedTx(tx: SignedTransaction, tokens: BankTokens): types.AminoTx { const built = buildUnsignedTx(tx.transaction, tokens); return { ...built, diff --git a/packages/bcp/src/types.ts b/packages/bcp/src/types.ts index 71ec2be8..ae679136 100644 --- a/packages/bcp/src/types.ts +++ b/packages/bcp/src/types.ts @@ -1,7 +1,7 @@ import { types } from "@cosmwasm/sdk"; import { Nonce } from "@iov/bcp"; -export interface TokenInfo { +export interface BankToken { readonly denom: string; readonly ticker: string; /** @@ -16,7 +16,22 @@ export interface TokenInfo { readonly fractionalDigits: number; } -export type TokenInfos = ReadonlyArray; +export type BankTokens = ReadonlyArray; + +export interface Erc20Token { + readonly contractAddress: string; + readonly ticker: string; + /** + * The number of fractional digits the token supports. + * + * A quantity is expressed as atomic units. 10^fractionalDigits of those + * atomic units make up 1 token. + * + * E.g. in Ethereum 10^18 wei are 1 ETH and from the quantity 123000000000000000000 + * the last 18 digits are the fractional part and the rest the wole part. + */ + readonly fractionalDigits: number; +} // tslint:disable-next-line:no-bitwise const maxAcct = 1 << 23; diff --git a/packages/bcp/types/cosmwasmcodec.d.ts b/packages/bcp/types/cosmwasmcodec.d.ts index ba1353a8..6161b65d 100644 --- a/packages/bcp/types/cosmwasmcodec.d.ts +++ b/packages/bcp/types/cosmwasmcodec.d.ts @@ -11,11 +11,11 @@ import { TxCodec, UnsignedTransaction, } from "@iov/bcp"; -import { TokenInfos } from "./types"; +import { BankTokens } from "./types"; export declare class CosmWasmCodec implements TxCodec { private readonly addressPrefix; private readonly tokens; - constructor(addressPrefix: CosmosAddressBech32Prefix, tokens: TokenInfos); + constructor(addressPrefix: CosmosAddressBech32Prefix, tokens: BankTokens); bytesToSign(unsigned: UnsignedTransaction, nonce: Nonce): SigningJob; bytesToPost(signed: SignedTransaction): PostableBytes; identifier(_signed: SignedTransaction): TransactionId; diff --git a/packages/bcp/types/cosmwasmconnection.d.ts b/packages/bcp/types/cosmwasmconnection.d.ts index 413795dc..5b98a5fa 100644 --- a/packages/bcp/types/cosmwasmconnection.d.ts +++ b/packages/bcp/types/cosmwasmconnection.d.ts @@ -21,12 +21,21 @@ import { UnsignedTransaction, } from "@iov/bcp"; import { Stream } from "xstream"; -import { TokenInfo } from "./types"; -export declare type TokenConfiguration = ReadonlyArray< - TokenInfo & { - readonly name: string; - } ->; +import { BankToken, Erc20Token } from "./types"; +export interface TokenConfiguration { + /** Supported tokens of the Cosmos SDK bank module */ + readonly bankTokens: ReadonlyArray< + BankToken & { + readonly name: string; + } + >; + /** Smart contract based tokens (ERC20 compatible). Unset means empty array. */ + readonly erc20Tokens?: ReadonlyArray< + Erc20Token & { + readonly name: string; + } + >; +} export declare class CosmWasmConnection implements BlockchainConnection { static establish( url: string, @@ -37,8 +46,9 @@ export declare class CosmWasmConnection implements BlockchainConnection { private readonly restClient; private readonly chainData; private readonly addressPrefix; - private readonly tokenInfo; - private readonly primaryToken; + private readonly bankTokens; + private readonly erc20Tokens; + private readonly feeToken; private readonly supportedTokens; private constructor(); disconnect(): void; diff --git a/packages/bcp/types/decode.d.ts b/packages/bcp/types/decode.d.ts index 80de6596..4ef7d075 100644 --- a/packages/bcp/types/decode.d.ts +++ b/packages/bcp/types/decode.d.ts @@ -12,24 +12,24 @@ import { UnsignedTransaction, } from "@iov/bcp"; import { Decimal } from "@iov/encoding"; -import { TokenInfos } from "./types"; +import { BankTokens } from "./types"; export declare function decodePubkey(pubkey: types.PubKey): PubkeyBundle; export declare function decodeSignature(signature: string): SignatureBytes; export declare function decodeFullSignature(signature: types.StdSignature, nonce: number): FullSignature; -export declare function coinToDecimal(tokens: TokenInfos, coin: types.Coin): readonly [Decimal, string]; -export declare function decodeAmount(tokens: TokenInfos, coin: types.Coin): Amount; -export declare function parseMsg(msg: types.Msg, chainId: ChainId, tokens: TokenInfos): UnsignedTransaction; -export declare function parseFee(fee: types.StdFee, tokens: TokenInfos): Fee; +export declare function coinToDecimal(tokens: BankTokens, coin: types.Coin): readonly [Decimal, string]; +export declare function decodeAmount(tokens: BankTokens, coin: types.Coin): Amount; +export declare function parseMsg(msg: types.Msg, chainId: ChainId, tokens: BankTokens): UnsignedTransaction; +export declare function parseFee(fee: types.StdFee, tokens: BankTokens): Fee; export declare function parseTx( txValue: types.StdTx, chainId: ChainId, nonce: Nonce, - tokens: TokenInfos, + tokens: BankTokens, ): SignedTransaction; export declare function parseTxsResponse( chainId: ChainId, currentHeight: number, nonce: Nonce, response: TxsResponse, - tokens: TokenInfos, + tokens: BankTokens, ): ConfirmedAndSignedTransaction; diff --git a/packages/bcp/types/encode.d.ts b/packages/bcp/types/encode.d.ts index 7af2c690..c55e4240 100644 --- a/packages/bcp/types/encode.d.ts +++ b/packages/bcp/types/encode.d.ts @@ -1,11 +1,11 @@ import { types } from "@cosmwasm/sdk"; import { Amount, Fee, FullSignature, PubkeyBundle, SignedTransaction, UnsignedTransaction } from "@iov/bcp"; import { Decimal } from "@iov/encoding"; -import { TokenInfos } from "./types"; +import { BankTokens } from "./types"; export declare function encodePubkey(pubkey: PubkeyBundle): types.PubKey; -export declare function decimalToCoin(lookup: TokenInfos, value: Decimal, ticker: string): types.Coin; -export declare function encodeAmount(amount: Amount, tokens: TokenInfos): types.Coin; -export declare function encodeFee(fee: Fee, tokens: TokenInfos): types.StdFee; +export declare function decimalToCoin(lookup: BankTokens, value: Decimal, ticker: string): types.Coin; +export declare function encodeAmount(amount: Amount, tokens: BankTokens): types.Coin; +export declare function encodeFee(fee: Fee, tokens: BankTokens): types.StdFee; export declare function encodeFullSignature(fullSignature: FullSignature): types.StdSignature; -export declare function buildUnsignedTx(tx: UnsignedTransaction, tokens: TokenInfos): types.AminoTx; -export declare function buildSignedTx(tx: SignedTransaction, tokens: TokenInfos): types.AminoTx; +export declare function buildUnsignedTx(tx: UnsignedTransaction, tokens: BankTokens): types.AminoTx; +export declare function buildSignedTx(tx: SignedTransaction, tokens: BankTokens): types.AminoTx; diff --git a/packages/bcp/types/types.d.ts b/packages/bcp/types/types.d.ts index e5a6e631..35863a03 100644 --- a/packages/bcp/types/types.d.ts +++ b/packages/bcp/types/types.d.ts @@ -1,6 +1,6 @@ import { types } from "@cosmwasm/sdk"; import { Nonce } from "@iov/bcp"; -export interface TokenInfo { +export interface BankToken { readonly denom: string; readonly ticker: string; /** @@ -14,7 +14,21 @@ export interface TokenInfo { */ readonly fractionalDigits: number; } -export declare type TokenInfos = ReadonlyArray; +export declare type BankTokens = ReadonlyArray; +export interface Erc20Token { + readonly contractAddress: string; + readonly ticker: string; + /** + * The number of fractional digits the token supports. + * + * A quantity is expressed as atomic units. 10^fractionalDigits of those + * atomic units make up 1 token. + * + * E.g. in Ethereum 10^18 wei are 1 ETH and from the quantity 123000000000000000000 + * the last 18 digits are the fractional part and the rest the wole part. + */ + readonly fractionalDigits: number; +} export declare function accountToNonce({ account_number: account, sequence }: types.NonceInfo): Nonce; export declare function nonceToAccountNumber(nonce: Nonce): number; export declare function nonceToSequence(nonce: Nonce): number; diff --git a/packages/faucet/src/codec.ts b/packages/faucet/src/codec.ts index 2b0d895f..f5d27e48 100644 --- a/packages/faucet/src/codec.ts +++ b/packages/faucet/src/codec.ts @@ -2,27 +2,29 @@ import { CosmWasmCodec, CosmWasmConnection, TokenConfiguration } from "@cosmwasm import { TxCodec } from "@iov/bcp"; const prefix = "cosmos"; -const tokens: TokenConfiguration = [ - { - fractionalDigits: 6, - name: "Fee Token", - ticker: "COSM", - denom: "cosm", - }, - { - fractionalDigits: 6, - name: "Staking Token", - ticker: "STAKE", - denom: "stake", - }, -]; +const config: TokenConfiguration = { + bankTokens: [ + { + fractionalDigits: 6, + name: "Fee Token", + ticker: "COSM", + denom: "cosm", + }, + { + fractionalDigits: 6, + name: "Staking Token", + ticker: "STAKE", + denom: "stake", + }, + ], +}; export async function establishConnection(url: string): Promise { - return CosmWasmConnection.establish(url, prefix, tokens); + return CosmWasmConnection.establish(url, prefix, config); } export function codecImplementation(): TxCodec { - return new CosmWasmCodec(prefix, tokens); + return new CosmWasmCodec(prefix, config.bankTokens); } export function codecDefaultFractionalDigits(): number { diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 55a04c7f..f56e593d 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -40,6 +40,7 @@ "dependencies": { "@iov/crypto": "^2.0.0-alpha.7", "@iov/encoding": "^2.0.0-alpha.7", + "@iov/utils": "^2.0.0-alpha.7", "axios": "^0.19.0" }, "devDependencies": { diff --git a/packages/sdk/src/restclient.spec.ts b/packages/sdk/src/restclient.spec.ts index c822ac0a..6fd94051 100644 --- a/packages/sdk/src/restclient.spec.ts +++ b/packages/sdk/src/restclient.spec.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/camelcase */ import { Random, Sha256 } from "@iov/crypto"; import { Bech32, Encoding } from "@iov/encoding"; +import { assert } from "@iov/utils"; import { encodeSecp256k1Signature, makeSignBytes, marshalTx } from "./encoding"; import { leb128Encode } from "./leb128.spec"; @@ -40,8 +41,12 @@ const unusedAccount = { address: "cosmos1cjsxept9rkggzxztslae9ndgpdyt2408lk850u", }; +function cosmosEnabled(): boolean { + return !!process.env.COSMOS_ENABLED; +} + function pendingWithoutCosmos(): void { - if (!process.env.COSMOS_ENABLED) { + if (!cosmosEnabled()) { return pending("Set COSMOS_ENABLED to enable Cosmos node-based tests"); } } @@ -467,25 +472,28 @@ describe("RestClient", () => { const client = new RestClient(httpUrl); const noContract = makeRandomAddress(); const expectedKey = toAscii("config"); + let contractAddress: string | undefined; - /** - * Finds the most recent contract (created above) - * - * We assume the tests above ran, all instantiate the same contract and no other process squeezed in a different contract. - */ - async function getContractAddress(): Promise { - const contracts = Array.from(await client.listContractAddresses()); - const last = contracts.reverse().find(() => true); - if (!last) throw new Error("No contract found"); - return last; - } + beforeAll(async () => { + if (cosmosEnabled()) { + const pen = await Secp256k1Pen.fromMnemonic(faucet.mnemonic); + const uploadResult = await uploadContract(client, pen); + assert(!uploadResult.code); + const uploadLogs = parseLogs(uploadResult.logs); + const codeId = Number.parseInt(findAttribute(uploadLogs, "message", "code_id").value, 10); + const instantiateResult = await instantiateContract(client, pen, codeId, makeRandomAddress()); + assert(!instantiateResult.code); + const instantiateLogs = parseLogs(instantiateResult.logs); + const contractAddressAttr = findAttribute(instantiateLogs, "message", "contract_address"); + contractAddress = contractAddressAttr.value; + } + }); it("can get all state", async () => { pendingWithoutCosmos(); - const contractAddress = await getContractAddress(); // get contract state - const state = await client.getAllContractState(contractAddress); + const state = await client.getAllContractState(contractAddress!); expect(state.length).toEqual(1); const data = state[0]; expect(data.key.toLowerCase()).toEqual(toHex(expectedKey)); @@ -499,16 +507,15 @@ describe("RestClient", () => { it("can query by key", async () => { pendingWithoutCosmos(); - const contractAddress = await getContractAddress(); // query by one key - const model = await client.queryContractRaw(contractAddress, expectedKey); + const model = await client.queryContractRaw(contractAddress!, expectedKey); expect(model).not.toBeNull(); expect((model as any).verifier).toBeDefined(); expect((model as any).beneficiary).toBeDefined(); // missing key is null - const missing = await client.queryContractRaw(contractAddress, fromHex("cafe0dad")); + const missing = await client.queryContractRaw(contractAddress!, fromHex("cafe0dad")); expect(missing).toBeNull(); // bad address is null @@ -518,14 +525,13 @@ describe("RestClient", () => { it("can make smart queries", async () => { pendingWithoutCosmos(); - const contractAddress = await getContractAddress(); // we can query the verifier properly - const verifier = await client.queryContractSmart(contractAddress, { verifier: {} }); + const verifier = await client.queryContractSmart(contractAddress!, { verifier: {} }); expect(verifier).toEqual(faucet.address); // invalid query syntax throws an error - await client.queryContractSmart(contractAddress, { nosuchkey: {} }).then( + await client.queryContractSmart(contractAddress!, { nosuchkey: {} }).then( () => fail("shouldn't succeed"), error => expect(error).toMatch("Error parsing QueryMsg"), ); diff --git a/packages/sdk/src/restclient.ts b/packages/sdk/src/restclient.ts index cd128366..14809dae 100644 --- a/packages/sdk/src/restclient.ts +++ b/packages/sdk/src/restclient.ts @@ -324,14 +324,14 @@ export class RestClient { // Makes a "smart query" on the contract, returns response verbatim (json.RawMessage) // Throws error if no such contract or invalid query format - public async queryContractSmart(address: string, query: object): Promise { + public async queryContractSmart(address: string, query: object): Promise { const encoded = toHex(toUtf8(JSON.stringify(query))); const path = `/wasm/contract/${address}/smart/${encoded}?encoding=hex`; const responseData = (await this.get(path)) as WasmResponse; if (isWasmError(responseData)) { throw new Error(responseData.error); } - // no extra parse here + // no extra parse here for now, see https://github.com/confio/cosmwasm/issues/144 return responseData.result; } } diff --git a/packages/sdk/types/restclient.d.ts b/packages/sdk/types/restclient.d.ts index d8a23d42..ad5f735d 100644 --- a/packages/sdk/types/restclient.d.ts +++ b/packages/sdk/types/restclient.d.ts @@ -104,6 +104,6 @@ export declare class RestClient { getContractInfo(address: string): Promise; getAllContractState(address: string): Promise; queryContractRaw(address: string, key: Uint8Array): Promise; - queryContractSmart(address: string, query: object): Promise; + queryContractSmart(address: string, query: object): Promise; } export {}; diff --git a/scripts/cosm/README.md b/scripts/cosm/README.md index 07ea4583..09173dfd 100644 --- a/scripts/cosm/README.md +++ b/scripts/cosm/README.md @@ -83,7 +83,9 @@ You should get output matching the following: 1. Faucet
economy stock theory fatal elder harbor betray wasp final emotion task crumble siren bottom lizard educate guess current outdoor pair theory focus wife stone
+ A08EGB7ro1ORuFhjOnZcSgwYlpe0DSFjVNUIkNNQxwKQ
cosmos1pkptre7fdkl6gfrzlesjjvhxhlc3r4gmmk8rs6 2. Unused: for testing account state; this account never changes balances or nonces
oyster design unusual machine spread century engine gravity focus cave carry slot
+ ArkCaFUJ/IH+vKBmNRCdUVl3mCAhbopk9jjW4Ko4OfRQ
cosmos1cjsxept9rkggzxztslae9ndgpdyt2408lk850u diff --git a/scripts/cosm/deploy_erc20.js b/scripts/cosm/deploy_erc20.js index 6199806f..953f433b 100755 --- a/scripts/cosm/deploy_erc20.js +++ b/scripts/cosm/deploy_erc20.js @@ -88,7 +88,7 @@ async function main() { const codeId = Number.parseInt(codeIdAttr.value, 10); console.info(`Upload succeeded. Code ID is ${codeId}`); - const initMsg = { + const initMsgAsh = { decimals: 5, name: "Ash token", symbol: "ASH", @@ -103,16 +103,30 @@ async function main() { }, ], }; - const instantiationResult = await instantiateContract(client, pen, codeId, initMsg); - if (instantiationResult.code) { - throw new Error( - `Instantiation failed with code: ${instantiationResult.code}; log: '${instantiationResult.raw_log}'`, - ); + const initMsgBash = { + decimals: 0, + name: "Bash Token", + symbol: "BASH", + initial_balances: [ + { + address: faucetAddress, + amount: "999999999", + }, + { + address: unusedAccount, + amount: "42", + }, + ], + }; + for (const initMsg of [initMsgAsh, initMsgBash]) { + const initResult = await instantiateContract(client, pen, codeId, initMsg); + if (initResult.code) { + throw new Error(`Instantiation failed with code: ${initResult.code}; log: '${initResult.raw_log}'`); + } + const instantiationLogs = logs.parseLogs(initResult.logs); + const contractAddress = logs.findAttribute(instantiationLogs, "message", "contract_address").value; + console.info(`Contract instantiated for ${initMsg.symbol} at ${contractAddress}`); } - const instantiationLogs = logs.parseLogs(instantiationResult.logs); - const contractAddress = logs.findAttribute(instantiationLogs, "message", "contract_address").value; - - console.info(`Contract instantiated at ${contractAddress}`); } main().then(