Merge pull request #74 from confio/cosmwasmclient

Add CosmWasmClient to SDK
This commit is contained in:
Simon Warta 2020-02-11 18:36:03 +01:00 committed by GitHub
commit 1e4b1f512a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 727 additions and 291 deletions

View File

@ -4,7 +4,6 @@ import {
isValidAddress,
makeSignBytes,
marshalTx,
types,
unmarshalTx,
} from "@cosmwasm/sdk";
import {
@ -46,16 +45,13 @@ export class CosmWasmCodec implements TxCodec {
public bytesToSign(unsigned: UnsignedTransaction, nonce: Nonce): SigningJob {
const built = buildUnsignedTx(unsigned, this.bankTokens, this.erc20Tokens);
const nonceInfo: types.NonceInfo = {
account_number: nonceToAccountNumber(nonce),
sequence: nonceToSequence(nonce),
};
const signBytes = makeSignBytes(
built.value.msg,
built.value.fee,
Caip5.decode(unsigned.chainId),
built.value.memo || "",
nonceInfo,
nonceToAccountNumber(nonce),
nonceToSequence(nonce),
);
return {

View File

@ -1,5 +1,12 @@
/* eslint-disable @typescript-eslint/camelcase */
import { CosmosAddressBech32Prefix, RestClient, TxsResponse, types, unmarshalTx } from "@cosmwasm/sdk";
import {
CosmosAddressBech32Prefix,
CosmWasmClient,
RestClient,
TxsResponse,
types,
unmarshalTx,
} from "@cosmwasm/sdk";
import {
Account,
AccountQuery,
@ -86,16 +93,19 @@ export class CosmWasmConnection implements BlockchainConnection {
tokens: TokenConfiguration,
): Promise<CosmWasmConnection> {
const restClient = new RestClient(url);
const chainData = await this.initialize(restClient);
return new CosmWasmConnection(restClient, chainData, addressPrefix, tokens);
const cosmWasmClient = CosmWasmClient.makeReadOnly(url);
const chainData = await this.initialize(cosmWasmClient);
return new CosmWasmConnection(restClient, cosmWasmClient, chainData, addressPrefix, tokens);
}
private static async initialize(restClient: RestClient): Promise<ChainData> {
const { node_info } = await restClient.nodeInfo();
return { chainId: Caip5.encode(node_info.network) };
private static async initialize(cosmWasmClient: CosmWasmClient): Promise<ChainData> {
const rawChainId = await cosmWasmClient.chainId();
return { chainId: Caip5.encode(rawChainId) };
}
/** @deprecated everything we use from RestClient should be available in CosmWasmClient */
private readonly restClient: RestClient;
private readonly cosmWasmClient: CosmWasmClient;
private readonly chainData: ChainData;
private readonly addressPrefix: CosmosAddressBech32Prefix;
private readonly bankTokens: readonly BankToken[];
@ -107,11 +117,14 @@ export class CosmWasmConnection implements BlockchainConnection {
private constructor(
restClient: RestClient,
cosmWasmClient: CosmWasmClient,
chainData: ChainData,
addressPrefix: CosmosAddressBech32Prefix,
tokens: TokenConfiguration,
) {
// tslint:disable-next-line: deprecation
this.restClient = restClient;
this.cosmWasmClient = cosmWasmClient;
this.chainData = chainData;
this.addressPrefix = addressPrefix;
this.bankTokens = tokens.bankTokens;
@ -136,6 +149,7 @@ export class CosmWasmConnection implements BlockchainConnection {
}
public async height(): Promise<number> {
// tslint:disable-next-line: deprecation
const { block } = await this.restClient.blocksLatest();
return block.header.height;
}
@ -150,6 +164,7 @@ export class CosmWasmConnection implements BlockchainConnection {
public async identifier(signed: PostableBytes): Promise<TransactionId> {
const tx = unmarshalTx(signed);
// tslint:disable-next-line: deprecation
const bytes = await this.restClient.encodeTx(tx);
const hash = new Sha256(bytes).digest();
return toHex(hash).toUpperCase() as TransactionId;
@ -157,6 +172,7 @@ export class CosmWasmConnection implements BlockchainConnection {
public async getAccount(query: AccountQuery): Promise<Account | undefined> {
const address = isPubkeyQuery(query) ? pubkeyToAddress(query.pubkey, this.addressPrefix) : query.address;
// tslint:disable-next-line: deprecation
const { result } = await this.restClient.authAccounts(address);
const bankAccount = result.value;
const hasBankAccount = !!bankAccount.address;
@ -168,6 +184,7 @@ export class CosmWasmConnection implements BlockchainConnection {
this.erc20Tokens.map(
async (erc20): Promise<Amount> => {
const queryMsg = { balance: { address: address } };
// tslint:disable-next-line: deprecation
const smart = await this.restClient.queryContractSmart(erc20.contractAddress, queryMsg);
const response = JSON.parse(fromAscii(smart));
const normalizedBalance = new BN(response.balance).toString();
@ -203,9 +220,8 @@ export class CosmWasmConnection implements BlockchainConnection {
public async getNonce(query: AddressQuery | PubkeyQuery): Promise<Nonce> {
const address = isPubkeyQuery(query) ? pubkeyToAddress(query.pubkey, this.addressPrefix) : query.address;
const { result } = await this.restClient.authAccounts(address);
const account = result.value;
return accountToNonce(account);
const { accountNumber, sequence } = await this.cosmWasmClient.getNonce(address);
return accountToNonce(accountNumber, sequence);
}
public async getNonces(query: AddressQuery | PubkeyQuery, count: number): Promise<readonly Nonce[]> {
@ -219,6 +235,7 @@ export class CosmWasmConnection implements BlockchainConnection {
}
public async getBlockHeader(height: number): Promise<BlockHeader> {
// tslint:disable-next-line: deprecation
const { block_meta } = await this.restClient.blocks(height);
return {
id: block_meta.block_id.hash as BlockId,
@ -236,6 +253,7 @@ export class CosmWasmConnection implements BlockchainConnection {
id: TransactionId,
): Promise<ConfirmedAndSignedTransaction<UnsignedTransaction> | FailedTransaction> {
try {
// tslint:disable-next-line: deprecation
const response = await this.restClient.txsById(id);
const chainId = this.chainId();
return this.parseAndPopulateTxResponse(response, chainId);
@ -248,11 +266,8 @@ export class CosmWasmConnection implements BlockchainConnection {
}
public async postTx(tx: PostableBytes): Promise<PostTxResponse> {
const { code, txhash, raw_log } = await this.restClient.postTx(tx);
if (code) {
throw new Error(raw_log);
}
const transactionId = txhash as TransactionId;
const { transactionHash, rawLog } = await this.cosmWasmClient.postTx(tx);
const transactionId = transactionHash as TransactionId;
const firstEvent: BlockInfo = { state: TransactionState.Pending };
let blockInfoInterval: NodeJS.Timeout;
let lastEventSent: BlockInfo;
@ -285,7 +300,7 @@ export class CosmWasmConnection implements BlockchainConnection {
return {
blockInfo: new ValueAndUpdates<BlockInfo>(producer),
transactionId: transactionId,
log: raw_log,
log: rawLog,
};
}
@ -295,6 +310,7 @@ export class CosmWasmConnection implements BlockchainConnection {
const queryString = buildQueryString(query);
const chainId = this.chainId();
// TODO: we need pagination support
// tslint:disable-next-line: deprecation
const response = await this.restClient.txs(queryString + "&limit=50");
const { txs } = response;
return Promise.all(txs.map(tx => this.parseAndPopulateTxResponse(tx, chainId)));
@ -354,6 +370,7 @@ export class CosmWasmConnection implements BlockchainConnection {
throw new Error(`Got unsupported type of message: ${firstMsg.type}`);
}
// tslint:disable-next-line: deprecation
const accountForHeight = await this.restClient.authAccounts(senderAddress, response.height);
// this is technically not the proper nonce. maybe this causes issues for sig validation?
// leaving for now unless it causes issues

View File

@ -109,8 +109,8 @@ export function parseTx(
nonce: Nonce,
tokens: BankTokens,
): SignedTransaction {
if (!types.isAminoStdTx(txValue)) {
throw new Error("Only Amino StdTx is supported");
if (!types.isStdTx(txValue)) {
throw new Error("Only StdTx is supported");
}
if (txValue.msg.length !== 1) {
throw new Error("Only single-message transactions currently supported");

View File

@ -81,7 +81,7 @@ export function buildUnsignedTx(
tx: UnsignedTransaction,
bankTokens: BankTokens,
erc20Tokens: readonly Erc20Token[] = [],
): types.AminoTx {
): types.CosmosSdkTx {
if (!isSendTransaction(tx)) {
throw new Error("Received transaction of unsupported kind");
}
@ -152,7 +152,7 @@ export function buildSignedTx(
tx: SignedTransaction,
bankTokens: BankTokens,
erc20Tokens: readonly Erc20Token[] = [],
): types.AminoTx {
): types.CosmosSdkTx {
const built = buildUnsignedTx(tx.transaction, bankTokens, erc20Tokens);
return {
...built,

View File

@ -3,26 +3,13 @@ import { accountToNonce, nonceToAccountNumber, nonceToSequence } from "./types";
describe("nonceEncoding", () => {
it("works for input in range", () => {
const nonce = accountToNonce({
account_number: 1234,
sequence: 7890,
});
const nonce = accountToNonce(1234, 7890);
expect(nonceToAccountNumber(nonce)).toEqual(1234);
expect(nonceToSequence(nonce)).toEqual(7890);
});
it("errors on input too large", () => {
expect(() =>
accountToNonce({
account_number: 1234567890,
sequence: 7890,
}),
).toThrow();
expect(() =>
accountToNonce({
account_number: 178,
sequence: 97320247923,
}),
).toThrow();
expect(() => accountToNonce(1234567890, 7890)).toThrow();
expect(() => accountToNonce(178, 97320247923)).toThrow();
});
});

View File

@ -1,4 +1,3 @@
import { types } from "@cosmwasm/sdk";
import { Nonce } from "@iov/bcp";
export interface BankToken {
@ -40,17 +39,17 @@ const maxSeq = 1 << 20;
// this (lossily) encodes the two pieces of info (uint64) needed to sign into
// one (53-bit) number. Cross your fingers.
export function accountToNonce({ account_number: account, sequence }: types.NonceInfo): Nonce {
export function accountToNonce(accountNumber: number, sequence: number): Nonce {
// we allow 23 bits (8 million) for accounts, and 20 bits (1 million) for tx/account
// let's fix this soon
if (account > maxAcct) {
if (accountNumber > maxAcct) {
throw new Error("Account number is greater than 2^23, must update Nonce handler");
}
if (sequence > maxSeq) {
throw new Error("Sequence is greater than 2^20, must update Nonce handler");
}
const val = account * maxSeq + sequence;
const val = accountNumber * maxSeq + sequence;
return val as Nonce;
}

View File

@ -43,7 +43,9 @@ export declare class CosmWasmConnection implements BlockchainConnection {
tokens: TokenConfiguration,
): Promise<CosmWasmConnection>;
private static initialize;
/** @deprecated everything we use from RestClient should be available in CosmWasmClient */
private readonly restClient;
private readonly cosmWasmClient;
private readonly chainData;
private readonly addressPrefix;
private readonly bankTokens;

View File

@ -10,9 +10,9 @@ export declare function buildUnsignedTx(
tx: UnsignedTransaction,
bankTokens: BankTokens,
erc20Tokens?: readonly Erc20Token[],
): types.AminoTx;
): types.CosmosSdkTx;
export declare function buildSignedTx(
tx: SignedTransaction,
bankTokens: BankTokens,
erc20Tokens?: readonly Erc20Token[],
): types.AminoTx;
): types.CosmosSdkTx;

View File

@ -1,4 +1,3 @@
import { types } from "@cosmwasm/sdk";
import { Nonce } from "@iov/bcp";
export interface BankToken {
readonly denom: string;
@ -29,6 +28,6 @@ export interface Erc20Token {
*/
readonly fractionalDigits: number;
}
export declare function accountToNonce({ account_number: account, sequence }: types.NonceInfo): Nonce;
export declare function accountToNonce(accountNumber: number, sequence: number): Nonce;
export declare function nonceToAccountNumber(nonce: Nonce): number;
export declare function nonceToSequence(nonce: Nonce): number;

View File

@ -45,7 +45,7 @@ $ cosmwasm-cli
```ts
// Get account information
const account = (await client.authAccounts(faucetAddress)).result.value;
const { account_number, sequence } = (await client.authAccounts(faucetAddress)).result.value;
// Craft a send transaction
const emptyAddress = Bech32.encode("cosmos", Random.getBytes(20));
@ -64,7 +64,7 @@ const sendTokensMsg: types.MsgSend = {
},
};
const signBytes = makeSignBytes([sendTokensMsg], defaultFee, defaultNetworkId, memo, account);
const signBytes = makeSignBytes([sendTokensMsg], defaultFee, defaultNetworkId, memo, account_number, sequence);
const signature = encodeSecp256k1Signature(pen.pubkey, await pen.createSignature(signBytes));
const signedTx: types.StdTx = {
msg: [sendTokensMsg],

View File

@ -0,0 +1,223 @@
import { CosmWasmClient } from "./cosmwasmclient";
import { encodeSecp256k1Signature, makeSignBytes, marshalTx } from "./encoding";
import { findAttribute } from "./logs";
import { Secp256k1Pen } from "./pen";
import { RestClient } from "./restclient";
import { getRandomizedHackatom, makeRandomAddress } from "./testutils.spec";
import { Coin, MsgSend, StdFee } from "./types";
const httpUrl = "http://localhost:1317";
function cosmosEnabled(): boolean {
return !!process.env.COSMOS_ENABLED;
}
function pendingWithoutCosmos(): void {
if (!cosmosEnabled()) {
return pending("Set COSMOS_ENABLED to enable Cosmos node-based tests");
}
}
const faucet = {
mnemonic:
"economy stock theory fatal elder harbor betray wasp final emotion task crumble siren bottom lizard educate guess current outdoor pair theory focus wife stone",
pubkey: {
type: "tendermint/PubKeySecp256k1",
value: "A08EGB7ro1ORuFhjOnZcSgwYlpe0DSFjVNUIkNNQxwKQ",
},
address: "cosmos1pkptre7fdkl6gfrzlesjjvhxhlc3r4gmmk8rs6",
};
const unusedAccount = {
address: "cosmos1cjsxept9rkggzxztslae9ndgpdyt2408lk850u",
};
describe("CosmWasmClient", () => {
describe("makeReadOnly", () => {
it("can be constructed", () => {
const client = CosmWasmClient.makeReadOnly(httpUrl);
expect(client).toBeTruthy();
});
});
describe("chainId", () => {
it("works", async () => {
pendingWithoutCosmos();
const client = CosmWasmClient.makeReadOnly(httpUrl);
expect(await client.chainId()).toEqual("testing");
});
});
describe("getNonce", () => {
it("works", async () => {
pendingWithoutCosmos();
const client = CosmWasmClient.makeReadOnly(httpUrl);
expect(await client.getNonce(unusedAccount.address)).toEqual({
accountNumber: 5,
sequence: 0,
});
});
});
describe("postTx", () => {
it("works", async () => {
pendingWithoutCosmos();
const pen = await Secp256k1Pen.fromMnemonic(faucet.mnemonic);
const client = CosmWasmClient.makeReadOnly(httpUrl);
const memo = "My first contract on chain";
const sendMsg: MsgSend = {
type: "cosmos-sdk/MsgSend",
value: {
// eslint-disable-next-line @typescript-eslint/camelcase
from_address: faucet.address,
// eslint-disable-next-line @typescript-eslint/camelcase
to_address: makeRandomAddress(),
amount: [
{
denom: "ucosm",
amount: "1234567",
},
],
},
};
const fee: StdFee = {
amount: [
{
amount: "5000",
denom: "ucosm",
},
],
gas: "890000",
};
const chainId = await client.chainId();
const { accountNumber, sequence } = await client.getNonce(faucet.address);
const signBytes = makeSignBytes([sendMsg], fee, chainId, memo, accountNumber, sequence);
const signature = encodeSecp256k1Signature(pen.pubkey, await pen.createSignature(signBytes));
const signedTx = {
msg: [sendMsg],
fee: fee,
memo: memo,
signatures: [signature],
};
const { logs, transactionHash } = await client.postTx(marshalTx(signedTx));
const amountAttr = findAttribute(logs, "transfer", "amount");
expect(amountAttr.value).toEqual("1234567ucosm");
expect(transactionHash).toMatch(/^[0-9A-F]{64}$/);
});
});
describe("upload", () => {
it("works", async () => {
pendingWithoutCosmos();
const pen = await Secp256k1Pen.fromMnemonic(faucet.mnemonic);
const client = CosmWasmClient.makeWritable(httpUrl, faucet.address, async signBytes => {
return encodeSecp256k1Signature(pen.pubkey, await pen.createSignature(signBytes));
});
const codeId = await client.upload(getRandomizedHackatom());
expect(codeId).toBeGreaterThanOrEqual(1);
});
});
describe("instantiate", () => {
it("works with transfer amount", async () => {
pendingWithoutCosmos();
const pen = await Secp256k1Pen.fromMnemonic(faucet.mnemonic);
const client = CosmWasmClient.makeWritable(httpUrl, faucet.address, async signBytes => {
return encodeSecp256k1Signature(pen.pubkey, await pen.createSignature(signBytes));
});
const codeId = await client.upload(getRandomizedHackatom());
const transferAmount: readonly Coin[] = [
{
amount: "1234",
denom: "ucosm",
},
{
amount: "321",
denom: "ustake",
},
];
const beneficiaryAddress = makeRandomAddress();
const contractAddress = await client.instantiate(
codeId,
{
verifier: faucet.address,
beneficiary: beneficiaryAddress,
},
"Let's see",
transferAmount,
);
const rest = new RestClient(httpUrl);
const balance = (await rest.authAccounts(contractAddress)).result.value.coins;
expect(balance).toEqual(transferAmount);
});
it("can instantiate one code multiple times", async () => {
pendingWithoutCosmos();
const pen = await Secp256k1Pen.fromMnemonic(faucet.mnemonic);
const client = CosmWasmClient.makeWritable(httpUrl, faucet.address, async signBytes => {
return encodeSecp256k1Signature(pen.pubkey, await pen.createSignature(signBytes));
});
const codeId = await client.upload(getRandomizedHackatom());
const contractAddress1 = await client.instantiate(codeId, {
verifier: faucet.address,
beneficiary: makeRandomAddress(),
});
const contractAddress2 = await client.instantiate(codeId, {
verifier: faucet.address,
beneficiary: makeRandomAddress(),
});
expect(contractAddress1).not.toEqual(contractAddress2);
});
});
describe("execute", () => {
it("works", async () => {
pendingWithoutCosmos();
const pen = await Secp256k1Pen.fromMnemonic(faucet.mnemonic);
const client = CosmWasmClient.makeWritable(httpUrl, faucet.address, async signBytes => {
return encodeSecp256k1Signature(pen.pubkey, await pen.createSignature(signBytes));
});
const codeId = await client.upload(getRandomizedHackatom());
// instantiate
const transferAmount: readonly Coin[] = [
{
amount: "233444",
denom: "ucosm",
},
{
amount: "5454",
denom: "ustake",
},
];
const beneficiaryAddress = makeRandomAddress();
const contractAddress = await client.instantiate(
codeId,
{
verifier: faucet.address,
beneficiary: beneficiaryAddress,
},
undefined,
transferAmount,
);
// execute
const result = await client.execute(contractAddress, {}, undefined);
const [firstLog] = result.logs;
expect(firstLog.log).toEqual(`released funds to ${beneficiaryAddress}`);
// Verify token transfer from contract to beneficiary
const rest = new RestClient(httpUrl);
const beneficiaryBalance = (await rest.authAccounts(beneficiaryAddress)).result.value.coins;
expect(beneficiaryBalance).toEqual(transferAmount);
const contractBalance = (await rest.authAccounts(contractAddress)).result.value.coins;
expect(contractBalance).toEqual([]);
});
});
});

View File

@ -0,0 +1,238 @@
import { Encoding } from "@iov/encoding";
import { makeSignBytes, marshalTx } from "./encoding";
import { findAttribute, Log, parseLogs } from "./logs";
import { RestClient } from "./restclient";
import {
Coin,
MsgExecuteContract,
MsgInstantiateContract,
MsgStoreCode,
StdFee,
StdSignature,
} from "./types";
const defaultUploadFee: StdFee = {
amount: [
{
amount: "5000",
denom: "ucosm",
},
],
gas: "1000000", // one million
};
const defaultInitFee: StdFee = {
amount: [
{
amount: "5000",
denom: "ucosm",
},
],
gas: "500000", // 500k
};
const defaultExecFee: StdFee = {
amount: [
{
amount: "5000",
denom: "ucosm",
},
],
gas: "200000", // 200k
};
export interface SigningCallback {
(signBytes: Uint8Array): Promise<StdSignature>;
}
interface SigningData {
readonly senderAddress: string;
readonly signCallback: SigningCallback;
}
export interface GetNonceResult {
readonly accountNumber: number;
readonly sequence: number;
}
export interface PostTxResult {
readonly logs: readonly Log[];
readonly rawLog: string;
/** Transaction hash (might be used as transaction ID). Guaranteed to be non-exmpty upper-case hex */
readonly transactionHash: string;
}
export interface ExecuteResult {
readonly logs: readonly Log[];
}
export class CosmWasmClient {
public static makeReadOnly(url: string): CosmWasmClient {
return new CosmWasmClient(url);
}
public static makeWritable(
url: string,
senderAddress: string,
signCallback: SigningCallback,
): CosmWasmClient {
return new CosmWasmClient(url, {
senderAddress: senderAddress,
signCallback: signCallback,
});
}
private readonly restClient: RestClient;
private readonly signingData: SigningData | undefined;
private get senderAddress(): string {
if (!this.signingData) throw new Error("Signing data not set in this client");
return this.signingData.senderAddress;
}
private get signCallback(): SigningCallback {
if (!this.signingData) throw new Error("Signing data not set in this client");
return this.signingData.signCallback;
}
private constructor(url: string, signingData?: SigningData) {
this.restClient = new RestClient(url);
this.signingData = signingData;
}
public async chainId(): Promise<string> {
const response = await this.restClient.nodeInfo();
return response.node_info.network;
}
/**
* Returns account number and sequence.
*
* @param address returns data for this address. When unset, the client's sender adddress is used.
*/
public async getNonce(address?: string): Promise<GetNonceResult> {
const account = (await this.restClient.authAccounts(address || this.senderAddress)).result.value;
return {
accountNumber: account.account_number,
sequence: account.sequence,
};
}
public async postTx(tx: Uint8Array): Promise<PostTxResult> {
const result = await this.restClient.postTx(tx);
if (result.code) {
throw new Error(`Error when posting tx. Code: ${result.code}; Raw log: ${result.raw_log}`);
}
if (!result.txhash.match(/^([0-9A-F][0-9A-F])+$/)) {
throw new Error("Received ill-formatted txhash. Must be non-empty upper-case hex");
}
return {
logs: parseLogs(result.logs) || [],
rawLog: result.raw_log || "",
transactionHash: result.txhash,
};
}
/** Uploads code and returns a code ID */
public async upload(wasmCode: Uint8Array, memo = ""): Promise<number> {
const storeCodeMsg: MsgStoreCode = {
type: "wasm/store-code",
value: {
sender: this.senderAddress,
// eslint-disable-next-line @typescript-eslint/camelcase
wasm_byte_code: Encoding.toBase64(wasmCode),
source: "",
builder: "",
},
};
const fee = defaultUploadFee;
const { accountNumber, sequence } = await this.getNonce();
const chainId = await this.chainId();
const signBytes = makeSignBytes([storeCodeMsg], fee, chainId, memo, accountNumber, sequence);
const signature = await this.signCallback(signBytes);
const signedTx = {
msg: [storeCodeMsg],
fee: fee,
memo: memo,
signatures: [signature],
};
const result = await this.postTx(marshalTx(signedTx));
const codeIdAttr = findAttribute(result.logs, "message", "code_id");
const codeId = Number.parseInt(codeIdAttr.value, 10);
return codeId;
}
public async instantiate(
codeId: number,
initMsg: object,
memo = "",
transferAmount?: readonly Coin[],
): Promise<string> {
const instantiateMsg: MsgInstantiateContract = {
type: "wasm/instantiate",
value: {
sender: this.senderAddress,
// eslint-disable-next-line @typescript-eslint/camelcase
code_id: codeId.toString(),
// eslint-disable-next-line @typescript-eslint/camelcase
init_msg: initMsg,
// eslint-disable-next-line @typescript-eslint/camelcase
init_funds: transferAmount || [],
},
};
const fee = defaultInitFee;
const { accountNumber, sequence } = await this.getNonce();
const chainId = await this.chainId();
const signBytes = makeSignBytes([instantiateMsg], fee, chainId, memo, accountNumber, sequence);
const signature = await this.signCallback(signBytes);
const signedTx = {
msg: [instantiateMsg],
fee: fee,
memo: memo,
signatures: [signature],
};
const result = await this.postTx(marshalTx(signedTx));
const contractAddressAttr = findAttribute(result.logs, "message", "contract_address");
return contractAddressAttr.value;
}
public async execute(
contractAddress: string,
handleMsg: object,
memo = "",
transferAmount?: readonly Coin[],
): Promise<ExecuteResult> {
const executeMsg: MsgExecuteContract = {
type: "wasm/execute",
value: {
sender: this.senderAddress,
contract: contractAddress,
msg: handleMsg,
// eslint-disable-next-line @typescript-eslint/camelcase
sent_funds: transferAmount || [],
},
};
const fee = defaultExecFee;
const { accountNumber, sequence } = await this.getNonce();
const chainId = await this.chainId();
const signBytes = makeSignBytes([executeMsg], fee, chainId, memo, accountNumber, sequence);
const signature = await this.signCallback(signBytes);
const signedTx = {
msg: [executeMsg],
fee: fee,
memo: memo,
signatures: [signature],
};
const result = await this.postTx(marshalTx(signedTx));
return {
logs: result.logs,
};
}
}

View File

@ -1,10 +1,10 @@
import { Encoding } from "@iov/encoding";
import { isAminoStdTx, StdTx } from "./types";
import { isStdTx, StdTx } from "./types";
export function unmarshalTx(data: Uint8Array): StdTx {
const decoded = JSON.parse(Encoding.fromUtf8(data));
if (!isAminoStdTx(decoded)) {
if (!isStdTx(decoded)) {
throw new Error("Must be json encoded StdTx");
}
return decoded;

View File

@ -2,7 +2,7 @@ import { Secp256k1 } from "@iov/crypto";
import { Encoding } from "@iov/encoding";
import { encodeSecp256k1Pubkey } from "./pubkey";
import { Msg, NonceInfo, StdFee, StdSignature, StdTx } from "./types";
import { Msg, StdFee, StdSignature, StdTx } from "./types";
const { toBase64, toUtf8 } = Encoding;
@ -43,17 +43,18 @@ export function makeSignBytes(
fee: StdFee,
chainId: string,
memo: string,
account: NonceInfo,
accountNumber: number,
sequence: number,
): Uint8Array {
const signJson: SignJson = {
// eslint-disable-next-line @typescript-eslint/camelcase
account_number: account.account_number.toString(),
account_number: accountNumber.toString(),
// eslint-disable-next-line @typescript-eslint/camelcase
chain_id: chainId,
fee: fee,
memo: memo,
msgs: msgs,
sequence: account.sequence.toString(),
sequence: sequence.toString(),
};
const signMsg = sortJson(signJson);
return toUtf8(JSON.stringify(signMsg));

View File

@ -6,6 +6,7 @@ export { CosmosAddressBech32Prefix, encodeAddress, isValidAddress } from "./addr
export { unmarshalTx } from "./decoding";
export { encodeSecp256k1Signature, makeSignBytes, marshalTx } from "./encoding";
export { RestClient, TxsResponse } from "./restclient";
export { CosmWasmClient, ExecuteResult, GetNonceResult, PostTxResult } from "./cosmwasmclient";
export { makeCosmoshubPath, Pen, PrehashType, Secp256k1Pen } from "./pen";
export {
CosmosPubkeyBech32Prefix,

View File

@ -1,39 +0,0 @@
import { Encoding } from "@iov/encoding";
const { fromHex } = Encoding;
export function leb128Encode(uint: number): Uint8Array {
if (uint < 0) throw new Error("Only non-negative values supported");
if (uint > 0x7fffffff) throw new Error("Only values in signed int32 range allowed");
const out = new Array<number>();
let value = uint;
do {
// tslint:disable: no-bitwise
let byte = value & 0b01111111;
value >>= 7;
// more bytes to come: set high order bit of byte
if (value !== 0) byte ^= 0b10000000;
out.push(byte);
// tslint:enable: no-bitwise
} while (value !== 0);
return new Uint8Array(out);
}
describe("leb128", () => {
describe("leb128Encode", () => {
it("works for single byte values", () => {
// Values in 7 bit range are encoded as one byte
expect(leb128Encode(0)).toEqual(fromHex("00"));
expect(leb128Encode(20)).toEqual(fromHex("14"));
expect(leb128Encode(127)).toEqual(fromHex("7f"));
});
it("works for multi byte values", () => {
// from external souce (wasm-objdump)
expect(leb128Encode(145)).toEqual(fromHex("9101"));
expect(leb128Encode(1539)).toEqual(fromHex("830c"));
});
});
});

View File

@ -1,16 +1,15 @@
/* eslint-disable @typescript-eslint/camelcase */
import { Random, Sha256 } from "@iov/crypto";
import { Bech32, Encoding } from "@iov/encoding";
import { Sha256 } from "@iov/crypto";
import { Encoding } from "@iov/encoding";
import { assert } from "@iov/utils";
import { encodeSecp256k1Signature, makeSignBytes, marshalTx } from "./encoding";
import { leb128Encode } from "./leb128.spec";
import { findAttribute, parseLogs } from "./logs";
import { Pen, Secp256k1Pen } from "./pen";
import { encodeBech32Pubkey } from "./pubkey";
import { PostTxsResponse, RestClient } from "./restclient";
import contract from "./testdata/contract.json";
import cosmoshub from "./testdata/cosmoshub.json";
import { getRandomizedHackatom, makeRandomAddress } from "./testutils.spec";
import {
Coin,
Msg,
@ -60,36 +59,6 @@ function makeSignedTx(firstMsg: Msg, fee: StdFee, memo: string, firstSignature:
};
}
function getRandomizedContract(): Uint8Array {
const data = fromBase64(contract.data);
// The return value of the export function cosmwasm_api_0_6 is unused and
// can be randomized for testing.
//
// Find position of mutable bytes as follows:
// $ wasm-objdump -d contract.wasm | grep -F "cosmwasm_api_0_6" -A 1
// 00e67c func[149] <cosmwasm_api_0_6>:
// 00e67d: 41 83 0c | i32.const 1539
//
// In the last line, the addresses 00e67d-00e67f hold a one byte instruction
// (https://github.com/WebAssembly/design/blob/master/BinaryEncoding.md#constants-described-here)
// and a two byte value (leb128 encoded 1539)
// Any unsigned integer from 128 to 16383 is encoded to two leb128 bytes
const min = 128;
const max = 16383;
const random = Math.floor(Math.random() * (max - min)) + min;
const bytes = leb128Encode(random);
data[0x00e67d + 1] = bytes[0];
data[0x00e67d + 2] = bytes[1];
return data;
}
function makeRandomAddress(): string {
return Bech32.encode("cosmos", Random.getBytes(20));
}
async function uploadCustomContract(
client: RestClient,
pen: Pen,
@ -115,15 +84,15 @@ async function uploadCustomContract(
gas: "89000000",
};
const account = (await client.authAccounts(faucet.address)).result.value;
const signBytes = makeSignBytes([theMsg], fee, defaultNetworkId, memo, account);
const { account_number, sequence } = (await client.authAccounts(faucet.address)).result.value;
const signBytes = makeSignBytes([theMsg], fee, defaultNetworkId, memo, account_number, sequence);
const signature = encodeSecp256k1Signature(pen.pubkey, await pen.createSignature(signBytes));
const signedTx = makeSignedTx(theMsg, fee, memo, signature);
return client.postTx(marshalTx(signedTx));
}
async function uploadContract(client: RestClient, pen: Pen): Promise<PostTxsResponse> {
return uploadCustomContract(client, pen, getRandomizedContract());
return uploadCustomContract(client, pen, getRandomizedHackatom());
}
async function instantiateContract(
@ -156,8 +125,8 @@ async function instantiateContract(
gas: "89000000",
};
const account = (await client.authAccounts(faucet.address)).result.value;
const signBytes = makeSignBytes([theMsg], fee, defaultNetworkId, memo, account);
const { account_number, sequence } = (await client.authAccounts(faucet.address)).result.value;
const signBytes = makeSignBytes([theMsg], fee, defaultNetworkId, memo, account_number, sequence);
const signature = encodeSecp256k1Signature(pen.pubkey, await pen.createSignature(signBytes));
const signedTx = makeSignedTx(theMsg, fee, memo, signature);
return client.postTx(marshalTx(signedTx));
@ -188,8 +157,8 @@ async function executeContract(
gas: "89000000",
};
const account = (await client.authAccounts(faucet.address)).result.value;
const signBytes = makeSignBytes([theMsg], fee, defaultNetworkId, memo, account);
const { account_number, sequence } = (await client.authAccounts(faucet.address)).result.value;
const signBytes = makeSignBytes([theMsg], fee, defaultNetworkId, memo, account_number, sequence);
const signature = encodeSecp256k1Signature(pen.pubkey, await pen.createSignature(signBytes));
const signedTx = makeSignedTx(theMsg, fee, memo, signature);
return client.postTx(marshalTx(signedTx));
@ -289,9 +258,9 @@ describe("RestClient", () => {
};
const client = new RestClient(httpUrl);
const account = (await client.authAccounts(faucet.address)).result.value;
const { account_number, sequence } = (await client.authAccounts(faucet.address)).result.value;
const signBytes = makeSignBytes([theMsg], fee, defaultNetworkId, memo, account);
const signBytes = makeSignBytes([theMsg], fee, defaultNetworkId, memo, account_number, sequence);
const signature = encodeSecp256k1Signature(pen.pubkey, await pen.createSignature(signBytes));
const signedTx = makeSignedTx(theMsg, fee, memo, signature);
const result = await client.postTx(marshalTx(signedTx));
@ -376,7 +345,7 @@ describe("RestClient", () => {
const numExisting = existingInfos.length;
// upload data
const wasmCode = getRandomizedContract();
const wasmCode = getRandomizedHackatom();
const result = await uploadCustomContract(client, pen, wasmCode);
expect(result.code).toBeFalsy();
const logs = parseLogs(result.logs);

View File

@ -2,11 +2,11 @@ import { Encoding } from "@iov/encoding";
import axios, { AxiosError, AxiosInstance } from "axios";
import {
AminoTx,
CodeInfo,
ContractInfo,
CosmosSdkAccount,
isAminoStdTx,
CosmosSdkTx,
isStdTx,
Model,
parseWasmData,
StdTx,
@ -70,7 +70,7 @@ export interface TxsResponse {
readonly height: string;
readonly txhash: string;
readonly raw_log: string;
readonly tx: AminoTx;
readonly tx: CosmosSdkTx;
}
interface SearchTxsResponse {
@ -260,7 +260,7 @@ export class RestClient {
public async postTx(tx: Uint8Array): Promise<PostTxsResponse> {
// TODO: check this is StdTx
const decoded = JSON.parse(fromUtf8(tx));
if (!isAminoStdTx(decoded)) {
if (!isStdTx(decoded)) {
throw new Error("Must be json encoded StdTx");
}
const params = {

View File

@ -0,0 +1,72 @@
import { Random } from "@iov/crypto";
import { Bech32, Encoding } from "@iov/encoding";
import hackatom from "./testdata/contract.json";
const { fromHex } = Encoding;
export function leb128Encode(uint: number): Uint8Array {
if (uint < 0) throw new Error("Only non-negative values supported");
if (uint > 0x7fffffff) throw new Error("Only values in signed int32 range allowed");
const out = new Array<number>();
let value = uint;
do {
// tslint:disable: no-bitwise
let byte = value & 0b01111111;
value >>= 7;
// more bytes to come: set high order bit of byte
if (value !== 0) byte ^= 0b10000000;
out.push(byte);
// tslint:enable: no-bitwise
} while (value !== 0);
return new Uint8Array(out);
}
export function getRandomizedHackatom(): Uint8Array {
const data = Encoding.fromBase64(hackatom.data);
// The return value of the export function cosmwasm_api_0_6 is unused and
// can be randomized for testing.
//
// Find position of mutable bytes as follows:
// $ wasm-objdump -d contract.wasm | grep -F "cosmwasm_api_0_6" -A 1
// 00e67c func[149] <cosmwasm_api_0_6>:
// 00e67d: 41 83 0c | i32.const 1539
//
// In the last line, the addresses 00e67d-00e67f hold a one byte instruction
// (https://github.com/WebAssembly/design/blob/master/BinaryEncoding.md#constants-described-here)
// and a two byte value (leb128 encoded 1539)
// Any unsigned integer from 128 to 16383 is encoded to two leb128 bytes
const min = 128;
const max = 16383;
const random = Math.floor(Math.random() * (max - min)) + min;
const bytes = leb128Encode(random);
data[0x00e67d + 1] = bytes[0];
data[0x00e67d + 2] = bytes[1];
return data;
}
export function makeRandomAddress(): string {
return Bech32.encode("cosmos", Random.getBytes(20));
}
describe("leb128", () => {
describe("leb128Encode", () => {
it("works for single byte values", () => {
// Values in 7 bit range are encoded as one byte
expect(leb128Encode(0)).toEqual(fromHex("00"));
expect(leb128Encode(20)).toEqual(fromHex("14"));
expect(leb128Encode(127)).toEqual(fromHex("7f"));
});
it("works for multi byte values", () => {
// from external souce (wasm-objdump)
expect(leb128Encode(145)).toEqual(fromHex("9101"));
expect(leb128Encode(1539)).toEqual(fromHex("830c"));
});
});
});

View File

@ -2,14 +2,7 @@ import { Encoding } from "@iov/encoding";
const { fromBase64, fromHex } = Encoding;
// We will move all needed *interfaces* from amino-js here
// This means bcp can just import them from here (if needed at all)
export interface Tx {
readonly type: string;
// TODO
readonly value: unknown;
}
/** An Amino/Cosmos SDK StdTx */
export interface StdTx {
readonly msg: ReadonlyArray<Msg>;
readonly fee: StdFee;
@ -17,15 +10,18 @@ export interface StdTx {
readonly memo: string | undefined;
}
export type AminoTx = Tx & { readonly value: StdTx };
export function isAminoStdTx(txValue: unknown): txValue is StdTx {
export function isStdTx(txValue: unknown): txValue is StdTx {
const { memo, msg, fee, signatures } = txValue as StdTx;
return (
typeof memo === "string" && Array.isArray(msg) && typeof fee === "object" && Array.isArray(signatures)
);
}
export interface CosmosSdkTx {
readonly type: string;
readonly value: StdTx;
}
interface MsgTemplate {
readonly type: string;
readonly value: object;
@ -164,9 +160,6 @@ export interface CosmosSdkAccount {
readonly sequence: number;
}
/** The data we need from CosmosSdkAccount to create a nonce */
export type NonceInfo = Pick<CosmosSdkAccount, "account_number" | "sequence">;
export interface CodeInfo {
readonly id: number;
/** Bech32 account address */

49
packages/sdk/types/cosmwasmclient.d.ts vendored Normal file
View File

@ -0,0 +1,49 @@
import { Log } from "./logs";
import { Coin, StdSignature } from "./types";
export interface SigningCallback {
(signBytes: Uint8Array): Promise<StdSignature>;
}
export interface GetNonceResult {
readonly accountNumber: number;
readonly sequence: number;
}
export interface PostTxResult {
readonly logs: readonly Log[];
readonly rawLog: string;
/** Transaction hash (might be used as transaction ID). Guaranteed to be non-exmpty upper-case hex */
readonly transactionHash: string;
}
export interface ExecuteResult {
readonly logs: readonly Log[];
}
export declare class CosmWasmClient {
static makeReadOnly(url: string): CosmWasmClient;
static makeWritable(url: string, senderAddress: string, signCallback: SigningCallback): CosmWasmClient;
private readonly restClient;
private readonly signingData;
private get senderAddress();
private get signCallback();
private constructor();
chainId(): Promise<string>;
/**
* Returns account number and sequence.
*
* @param address returns data for this address. When unset, the client's sender adddress is used.
*/
getNonce(address?: string): Promise<GetNonceResult>;
postTx(tx: Uint8Array): Promise<PostTxResult>;
/** Uploads code and returns a code ID */
upload(wasmCode: Uint8Array, memo?: string): Promise<number>;
instantiate(
codeId: number,
initMsg: object,
memo?: string,
transferAmount?: readonly Coin[],
): Promise<string>;
execute(
contractAddress: string,
handleMsg: object,
memo?: string,
transferAmount?: readonly Coin[],
): Promise<ExecuteResult>;
}

View File

@ -1,10 +1,11 @@
import { Msg, NonceInfo, StdFee, StdSignature, StdTx } from "./types";
import { Msg, StdFee, StdSignature, StdTx } from "./types";
export declare function marshalTx(tx: StdTx): Uint8Array;
export declare function makeSignBytes(
msgs: readonly Msg[],
fee: StdFee,
chainId: string,
memo: string,
account: NonceInfo,
accountNumber: number,
sequence: number,
): Uint8Array;
export declare function encodeSecp256k1Signature(pubkey: Uint8Array, signature: Uint8Array): StdSignature;

View File

@ -5,6 +5,7 @@ export { CosmosAddressBech32Prefix, encodeAddress, isValidAddress } from "./addr
export { unmarshalTx } from "./decoding";
export { encodeSecp256k1Signature, makeSignBytes, marshalTx } from "./encoding";
export { RestClient, TxsResponse } from "./restclient";
export { CosmWasmClient, ExecuteResult, GetNonceResult, PostTxResult } from "./cosmwasmclient";
export { makeCosmoshubPath, Pen, PrehashType, Secp256k1Pen } from "./pen";
export {
CosmosPubkeyBech32Prefix,

View File

@ -1,4 +1,4 @@
import { AminoTx, CodeInfo, ContractInfo, CosmosSdkAccount, Model, StdTx } from "./types";
import { CodeInfo, ContractInfo, CosmosSdkAccount, CosmosSdkTx, Model, StdTx } from "./types";
interface NodeInfo {
readonly network: string;
}
@ -42,7 +42,7 @@ export interface TxsResponse {
readonly height: string;
readonly txhash: string;
readonly raw_log: string;
readonly tx: AminoTx;
readonly tx: CosmosSdkTx;
}
interface SearchTxsResponse {
readonly total_count: string;

View File

@ -1,17 +1,15 @@
export interface Tx {
readonly type: string;
readonly value: unknown;
}
/** An Amino/Cosmos SDK StdTx */
export interface StdTx {
readonly msg: ReadonlyArray<Msg>;
readonly fee: StdFee;
readonly signatures: ReadonlyArray<StdSignature>;
readonly memo: string | undefined;
}
export declare type AminoTx = Tx & {
export declare function isStdTx(txValue: unknown): txValue is StdTx;
export interface CosmosSdkTx {
readonly type: string;
readonly value: StdTx;
};
export declare function isAminoStdTx(txValue: unknown): txValue is StdTx;
}
interface MsgTemplate {
readonly type: string;
readonly value: object;
@ -118,8 +116,6 @@ export interface CosmosSdkAccount {
readonly account_number: number;
readonly sequence: number;
}
/** The data we need from CosmosSdkAccount to create a nonce */
export declare type NonceInfo = Pick<CosmosSdkAccount, "account_number" | "sequence">;
export interface CodeInfo {
readonly id: number;
/** Bech32 account address */

View File

@ -1,141 +1,72 @@
#!/usr/bin/env node
/* eslint-disable @typescript-eslint/camelcase */
const { Encoding } = require("@iov/encoding");
const {
encodeSecp256k1Signature,
makeSignBytes,
marshalTx,
logs,
RestClient,
Secp256k1Pen,
} = require("@cosmwasm/sdk");
const { CosmWasmClient, encodeSecp256k1Signature, Secp256k1Pen } = require("@cosmwasm/sdk");
const fs = require("fs");
const httpUrl = "http://localhost:1317";
const networkId = "testing";
const defaultFee = {
amount: [
{
amount: "5000",
denom: "ucosm",
},
],
gas: "1000000", // one million
const faucet = {
mnemonic:
"economy stock theory fatal elder harbor betray wasp final emotion task crumble siren bottom lizard educate guess current outdoor pair theory focus wife stone",
address: "cosmos1pkptre7fdkl6gfrzlesjjvhxhlc3r4gmmk8rs6",
};
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 faucetAddress = "cosmos1pkptre7fdkl6gfrzlesjjvhxhlc3r4gmmk8rs6";
const unusedAccount = "cosmos1cjsxept9rkggzxztslae9ndgpdyt2408lk850u";
async function uploadContract(client, pen, wasm) {
const memo = "Upload ERC20 contract";
const storeCodeMsg = {
type: "wasm/store-code",
value: {
sender: faucetAddress,
wasm_byte_code: Encoding.toBase64(wasm),
source: "",
builder: "",
const initMsgAsh = {
decimals: 5,
name: "Ash token",
symbol: "ASH",
initial_balances: [
{
address: faucet.address,
amount: "11",
},
};
const account = (await client.authAccounts(faucetAddress)).result.value;
const signBytes = makeSignBytes([storeCodeMsg], defaultFee, networkId, memo, account);
const signature = encodeSecp256k1Signature(pen.pubkey, await pen.createSignature(signBytes));
const signedTx = {
msg: [storeCodeMsg],
fee: defaultFee,
memo: memo,
signatures: [signature],
};
return client.postTx(marshalTx(signedTx));
}
async function instantiateContract(client, pen, codeId, msg, transferAmount) {
const memo = "Create an ERC20 instance";
const instantiateContractMsg = {
type: "wasm/instantiate",
value: {
sender: faucetAddress,
code_id: codeId.toString(),
init_msg: msg,
init_funds: transferAmount || [],
{
address: unusedAccount,
amount: "12812345",
},
};
const account = (await client.authAccounts(faucetAddress)).result.value;
const signBytes = makeSignBytes([instantiateContractMsg], defaultFee, networkId, memo, account);
const signature = encodeSecp256k1Signature(pen.pubkey, await pen.createSignature(signBytes));
const signedTx = {
msg: [instantiateContractMsg],
fee: defaultFee,
memo: memo,
signatures: [signature],
};
return client.postTx(marshalTx(signedTx));
}
],
};
const initMsgBash = {
decimals: 0,
name: "Bash Token",
symbol: "BASH",
initial_balances: [
{
address: faucet.address,
amount: "999999999",
},
{
address: unusedAccount,
amount: "42",
},
],
};
const initMsgCash = {
decimals: 18,
name: "Cash Token",
symbol: "CASH",
initial_balances: [
{
address: faucet.address,
amount: "189189189000000000000000000", // 189189189 CASH
},
],
};
async function main() {
const pen = await Secp256k1Pen.fromMnemonic(faucetMnemonic);
const client = new RestClient(httpUrl);
const pen = await Secp256k1Pen.fromMnemonic(faucet.mnemonic);
const client = CosmWasmClient.makeWritable(httpUrl, faucet.address, async signBytes => {
return encodeSecp256k1Signature(pen.pubkey, await pen.createSignature(signBytes));
});
const wasm = fs.readFileSync(__dirname + "/contracts/cw-erc20.wasm");
const uploadResult = await uploadContract(client, pen, wasm);
if (uploadResult.code) {
throw new Error(`Uploading failed with code: ${uploadResult.code}; log: '${uploadResult.raw_log}'`);
}
const codeIdAttr = logs.findAttribute(logs.parseLogs(uploadResult.logs), "message", "code_id");
const codeId = Number.parseInt(codeIdAttr.value, 10);
const codeId = await client.upload(wasm, "Upload ERC20 contract");
console.info(`Upload succeeded. Code ID is ${codeId}`);
const initMsgAsh = {
decimals: 5,
name: "Ash token",
symbol: "ASH",
initial_balances: [
{
address: faucetAddress,
amount: "11",
},
{
address: unusedAccount,
amount: "12812345",
},
],
};
const initMsgBash = {
decimals: 0,
name: "Bash Token",
symbol: "BASH",
initial_balances: [
{
address: faucetAddress,
amount: "999999999",
},
{
address: unusedAccount,
amount: "42",
},
],
};
const initMsgCash = {
decimals: 18,
name: "Cash Token",
symbol: "CASH",
initial_balances: [
{
address: faucetAddress,
amount: "189189189000000000000000000", // 189189189 CASH
},
],
};
for (const initMsg of [initMsgAsh, initMsgBash, initMsgCash]) {
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;
const memo = `Create an ERC20 instance for ${initMsg.symbol}`;
const contractAddress = await client.instantiate(codeId, initMsg, memo);
console.info(`Contract instantiated for ${initMsg.symbol} at ${contractAddress}`);
}
}