Merge pull request #66 from confio/erc20-balances

Add read support for multiple ERC20 tokens
This commit is contained in:
Simon Warta 2020-02-10 13:12:01 +01:00 committed by GitHub
commit e68db46b01
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 319 additions and 172 deletions

View File

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

View File

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

View File

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

View File

@ -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<TokenInfo & { readonly name: string }>;
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 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<Amount> => {
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);
}
}

View File

@ -13,7 +13,7 @@ export function createCosmWasmConnector(
tokens: TokenConfiguration,
expectedChainId?: ChainId,
): ChainConnector<CosmWasmConnection> {
const codec = new CosmWasmCodec(addressPrefix, tokens);
const codec = new CosmWasmCodec(addressPrefix, tokens.bankTokens);
return {
establishConnection: async () => CosmWasmConnection.establish(url, addressPrefix, tokens),
codec: codec,

View File

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

View File

@ -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<UnsignedTransaction> {
const height = parseInt(response.height, 10);
return {

View File

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

View File

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

View File

@ -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<TokenInfo>;
export type BankTokens = ReadonlyArray<BankToken>;
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;

View File

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

View File

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

View File

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

View File

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

View File

@ -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<TokenInfo>;
export declare type BankTokens = ReadonlyArray<BankToken>;
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;

View File

@ -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<CosmWasmConnection> {
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 {

View File

@ -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": {

View File

@ -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<string> {
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"),
);

View File

@ -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<unknown> {
public async queryContractSmart(address: string, query: object): Promise<string> {
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;
}
}

View File

@ -104,6 +104,6 @@ export declare class RestClient {
getContractInfo(address: string): Promise<ContractInfo>;
getAllContractState(address: string): Promise<readonly WasmData[]>;
queryContractRaw(address: string, key: Uint8Array): Promise<unknown | null>;
queryContractSmart(address: string, query: object): Promise<unknown>;
queryContractSmart(address: string, query: object): Promise<string>;
}
export {};

View File

@ -83,7 +83,9 @@ You should get output matching the following:
1. Faucet<br>
economy stock theory fatal elder harbor betray wasp final emotion task crumble siren bottom lizard educate guess current outdoor pair theory focus wife stone<br>
A08EGB7ro1ORuFhjOnZcSgwYlpe0DSFjVNUIkNNQxwKQ<br>
cosmos1pkptre7fdkl6gfrzlesjjvhxhlc3r4gmmk8rs6
2. Unused: for testing account state; this account never changes balances or nonces<br>
oyster design unusual machine spread century engine gravity focus cave carry slot<br>
ArkCaFUJ/IH+vKBmNRCdUVl3mCAhbopk9jjW4Ko4OfRQ<br>
cosmos1cjsxept9rkggzxztslae9ndgpdyt2408lk850u

View File

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