From 6b522e5b4dd70fe8e1e688489e8b33fda205ea85 Mon Sep 17 00:00:00 2001 From: Simon Warta Date: Mon, 17 Feb 2020 13:51:48 +0100 Subject: [PATCH] Split CosmWasmClient and SigningCosmWasmClient --- packages/bcp/src/cosmwasmconnection.ts | 2 +- packages/sdk/src/cosmwasmclient.spec.ts | 204 +++------------ packages/sdk/src/cosmwasmclient.ts | 232 +----------------- packages/sdk/src/index.ts | 4 +- .../sdk/src/signingcosmwasmclient.spec.ts | 164 +++++++++++++ packages/sdk/src/signingcosmwasmclient.ts | 210 ++++++++++++++++ packages/sdk/src/testutils.spec.ts | 10 + packages/sdk/types/cosmwasmclient.d.ts | 54 +--- packages/sdk/types/index.d.ts | 4 +- packages/sdk/types/signingcosmwasmclient.d.ts | 43 ++++ 10 files changed, 476 insertions(+), 451 deletions(-) create mode 100644 packages/sdk/src/signingcosmwasmclient.spec.ts create mode 100644 packages/sdk/src/signingcosmwasmclient.ts create mode 100644 packages/sdk/types/signingcosmwasmclient.d.ts diff --git a/packages/bcp/src/cosmwasmconnection.ts b/packages/bcp/src/cosmwasmconnection.ts index afd6ea70..07c6e391 100644 --- a/packages/bcp/src/cosmwasmconnection.ts +++ b/packages/bcp/src/cosmwasmconnection.ts @@ -73,7 +73,7 @@ export class CosmWasmConnection implements BlockchainConnection { addressPrefix: CosmosAddressBech32Prefix, tokens: TokenConfiguration, ): Promise { - const cosmWasmClient = CosmWasmClient.makeReadOnly(url); + const cosmWasmClient = new CosmWasmClient(url); const chainData = await this.initialize(cosmWasmClient); return new CosmWasmConnection(cosmWasmClient, chainData, addressPrefix, tokens); } diff --git a/packages/sdk/src/cosmwasmclient.spec.ts b/packages/sdk/src/cosmwasmclient.spec.ts index 0c8292b3..e9666b80 100644 --- a/packages/sdk/src/cosmwasmclient.spec.ts +++ b/packages/sdk/src/cosmwasmclient.spec.ts @@ -8,24 +8,21 @@ import { makeSignBytes, marshalTx } from "./encoding"; import { findAttribute } from "./logs"; import { Secp256k1Pen } from "./pen"; import { RestClient } from "./restclient"; +import { SigningCosmWasmClient } from "./signingcosmwasmclient"; import cosmoshub from "./testdata/cosmoshub.json"; -import { getRandomizedHackatom, makeRandomAddress, tendermintIdMatcher } from "./testutils.spec"; -import { Coin, CosmosSdkTx, MsgSend, StdFee } from "./types"; +import { + cosmosEnabled, + getRandomizedHackatom, + makeRandomAddress, + pendingWithoutCosmos, + tendermintIdMatcher, +} from "./testutils.spec"; +import { CosmosSdkTx, MsgSend, StdFee } from "./types"; const { fromAscii, fromUtf8, toAscii } = Encoding; 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", @@ -51,7 +48,7 @@ interface HackatomInstance { describe("CosmWasmClient", () => { describe("makeReadOnly", () => { it("can be constructed", () => { - const client = CosmWasmClient.makeReadOnly(httpUrl); + const client = new CosmWasmClient(httpUrl); expect(client).toBeTruthy(); }); }); @@ -59,7 +56,7 @@ describe("CosmWasmClient", () => { describe("chainId", () => { it("works", async () => { pendingWithoutCosmos(); - const client = CosmWasmClient.makeReadOnly(httpUrl); + const client = new CosmWasmClient(httpUrl); expect(await client.chainId()).toEqual("testing"); }); }); @@ -67,7 +64,7 @@ describe("CosmWasmClient", () => { describe("getNonce", () => { it("works", async () => { pendingWithoutCosmos(); - const client = CosmWasmClient.makeReadOnly(httpUrl); + const client = new CosmWasmClient(httpUrl); expect(await client.getNonce(unusedAccount.address)).toEqual({ accountNumber: 5, sequence: 0, @@ -76,7 +73,7 @@ describe("CosmWasmClient", () => { it("throws for missing accounts", async () => { pendingWithoutCosmos(); - const client = CosmWasmClient.makeReadOnly(httpUrl); + const client = new CosmWasmClient(httpUrl); const missing = makeRandomAddress(); await client.getNonce(missing).then( () => fail("this must not succeed"), @@ -88,7 +85,7 @@ describe("CosmWasmClient", () => { describe("getAccount", () => { it("works", async () => { pendingWithoutCosmos(); - const client = CosmWasmClient.makeReadOnly(httpUrl); + const client = new CosmWasmClient(httpUrl); expect(await client.getAccount(unusedAccount.address)).toEqual({ address: unusedAccount.address, account_number: 5, @@ -103,7 +100,7 @@ describe("CosmWasmClient", () => { it("returns undefined for missing accounts", async () => { pendingWithoutCosmos(); - const client = CosmWasmClient.makeReadOnly(httpUrl); + const client = new CosmWasmClient(httpUrl); const missing = makeRandomAddress(); expect(await client.getAccount(missing)).toBeUndefined(); }); @@ -112,7 +109,7 @@ describe("CosmWasmClient", () => { describe("getBlock", () => { it("works for latest block", async () => { pendingWithoutCosmos(); - const client = CosmWasmClient.makeReadOnly(httpUrl); + const client = new CosmWasmClient(httpUrl); const response = await client.getBlock(); // id @@ -132,7 +129,7 @@ describe("CosmWasmClient", () => { it("works for block by height", async () => { pendingWithoutCosmos(); - const client = CosmWasmClient.makeReadOnly(httpUrl); + const client = new CosmWasmClient(httpUrl); const height = parseInt((await client.getBlock()).block.header.height, 10); const response = await client.getBlock(height - 1); @@ -155,7 +152,7 @@ describe("CosmWasmClient", () => { describe("getIdentifier", () => { it("works", async () => { pendingWithoutCosmos(); - const client = CosmWasmClient.makeReadOnly(httpUrl); + const client = new CosmWasmClient(httpUrl); expect(await client.getIdentifier(cosmoshub.tx)).toEqual(cosmoshub.id); }); }); @@ -164,7 +161,7 @@ describe("CosmWasmClient", () => { it("works", async () => { pendingWithoutCosmos(); const pen = await Secp256k1Pen.fromMnemonic(faucet.mnemonic); - const client = CosmWasmClient.makeReadOnly(httpUrl); + const client = new CosmWasmClient(httpUrl); const memo = "My first contract on chain"; const sendMsg: MsgSend = { @@ -222,7 +219,7 @@ describe("CosmWasmClient", () => { beforeAll(async () => { if (cosmosEnabled()) { const pen = await Secp256k1Pen.fromMnemonic(faucet.mnemonic); - const client = CosmWasmClient.makeWritable(httpUrl, faucet.address, signBytes => pen.sign(signBytes)); + const client = new SigningCosmWasmClient(httpUrl, faucet.address, signBytes => pen.sign(signBytes)); const recipient = makeRandomAddress(); const transferAmount = [ @@ -248,7 +245,7 @@ describe("CosmWasmClient", () => { it("can search by ID", async () => { pendingWithoutCosmos(); assert(posted, "value must be set in beforeAll()"); - const client = CosmWasmClient.makeReadOnly(httpUrl); + const client = new CosmWasmClient(httpUrl); const result = await client.searchTx({ id: posted.hash }); expect(result.length).toEqual(1); expect(result[0]).toEqual( @@ -262,7 +259,7 @@ describe("CosmWasmClient", () => { it("can search by ID (non existent)", async () => { pendingWithoutCosmos(); - const client = CosmWasmClient.makeReadOnly(httpUrl); + const client = new CosmWasmClient(httpUrl); const nonExistentId = "0000000000000000000000000000000000000000000000000000000000000000"; const result = await client.searchTx({ id: nonExistentId }); expect(result.length).toEqual(0); @@ -271,7 +268,7 @@ describe("CosmWasmClient", () => { it("can search by height", async () => { pendingWithoutCosmos(); assert(posted, "value must be set in beforeAll()"); - const client = CosmWasmClient.makeReadOnly(httpUrl); + const client = new CosmWasmClient(httpUrl); const result = await client.searchTx({ height: posted.height }); expect(result.length).toEqual(1); expect(result[0]).toEqual( @@ -286,7 +283,7 @@ describe("CosmWasmClient", () => { it("can search by sender", async () => { pendingWithoutCosmos(); assert(posted, "value must be set in beforeAll()"); - const client = CosmWasmClient.makeReadOnly(httpUrl); + const client = new CosmWasmClient(httpUrl); const result = await client.searchTx({ sentFromOrTo: posted.sender }); expect(result.length).toBeGreaterThanOrEqual(1); expect(result[result.length - 1]).toEqual( @@ -301,7 +298,7 @@ describe("CosmWasmClient", () => { it("can search by recipient", async () => { pendingWithoutCosmos(); assert(posted, "value must be set in beforeAll()"); - const client = CosmWasmClient.makeReadOnly(httpUrl); + const client = new CosmWasmClient(httpUrl); const result = await client.searchTx({ sentFromOrTo: posted.recipient }); expect(result.length).toBeGreaterThanOrEqual(1); expect(result[result.length - 1]).toEqual( @@ -314,141 +311,6 @@ describe("CosmWasmClient", () => { }); }); - describe("upload", () => { - it("works", async () => { - pendingWithoutCosmos(); - const pen = await Secp256k1Pen.fromMnemonic(faucet.mnemonic); - const client = CosmWasmClient.makeWritable(httpUrl, faucet.address, signBytes => pen.sign(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, signBytes => pen.sign(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, signBytes => pen.sign(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, signBytes => pen.sign(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([]); - }); - }); - - describe("sendTokens", () => { - it("works", async () => { - pendingWithoutCosmos(); - const pen = await Secp256k1Pen.fromMnemonic(faucet.mnemonic); - const client = CosmWasmClient.makeWritable(httpUrl, faucet.address, signBytes => pen.sign(signBytes)); - - // instantiate - const transferAmount: readonly Coin[] = [ - { - amount: "7890", - denom: "ucosm", - }, - ]; - const beneficiaryAddress = makeRandomAddress(); - - // no tokens here - const before = await client.getAccount(beneficiaryAddress); - expect(before).toBeUndefined(); - - // send - const result = await client.sendTokens(beneficiaryAddress, transferAmount, "for dinner"); - const [firstLog] = result.logs; - expect(firstLog).toBeTruthy(); - - // got tokens - const after = await client.getAccount(beneficiaryAddress); - assert(after); - expect(after.coins).toEqual(transferAmount); - }); - }); - describe("queryContractRaw", () => { const configKey = toAscii("config"); const otherKey = toAscii("this_does_not_exist"); @@ -458,7 +320,7 @@ describe("CosmWasmClient", () => { if (cosmosEnabled()) { pendingWithoutCosmos(); const pen = await Secp256k1Pen.fromMnemonic(faucet.mnemonic); - const client = CosmWasmClient.makeWritable(httpUrl, faucet.address, signBytes => pen.sign(signBytes)); + const client = new SigningCosmWasmClient(httpUrl, faucet.address, signBytes => pen.sign(signBytes)); const codeId = await client.upload(getRandomizedHackatom()); const initMsg = { verifier: makeRandomAddress(), beneficiary: makeRandomAddress() }; const contractAddress = await client.instantiate(codeId, initMsg); @@ -470,7 +332,7 @@ describe("CosmWasmClient", () => { pendingWithoutCosmos(); assert(contract); - const client = CosmWasmClient.makeReadOnly(httpUrl); + const client = new CosmWasmClient(httpUrl); const raw = await client.queryContractRaw(contract.address, configKey); assert(raw, "must get result"); expect(JSON.parse(fromUtf8(raw))).toEqual({ @@ -484,7 +346,7 @@ describe("CosmWasmClient", () => { pendingWithoutCosmos(); assert(contract); - const client = CosmWasmClient.makeReadOnly(httpUrl); + const client = new CosmWasmClient(httpUrl); const raw = await client.queryContractRaw(contract.address, otherKey); expect(raw).toBeNull(); }); @@ -494,7 +356,7 @@ describe("CosmWasmClient", () => { assert(contract); const nonExistentAddress = makeRandomAddress(); - const client = CosmWasmClient.makeReadOnly(httpUrl); + const client = new CosmWasmClient(httpUrl); await client.queryContractRaw(nonExistentAddress, configKey).then( () => fail("must not succeed"), error => expect(error).toMatch(`No contract found at address "${nonExistentAddress}"`), @@ -509,7 +371,7 @@ describe("CosmWasmClient", () => { if (cosmosEnabled()) { pendingWithoutCosmos(); const pen = await Secp256k1Pen.fromMnemonic(faucet.mnemonic); - const client = CosmWasmClient.makeWritable(httpUrl, faucet.address, signBytes => pen.sign(signBytes)); + const client = new SigningCosmWasmClient(httpUrl, faucet.address, signBytes => pen.sign(signBytes)); const codeId = await client.upload(getRandomizedHackatom()); const initMsg = { verifier: makeRandomAddress(), beneficiary: makeRandomAddress() }; const contractAddress = await client.instantiate(codeId, initMsg); @@ -521,7 +383,7 @@ describe("CosmWasmClient", () => { pendingWithoutCosmos(); assert(contract); - const client = CosmWasmClient.makeReadOnly(httpUrl); + const client = new CosmWasmClient(httpUrl); const verifier = await client.queryContractSmart(contract.address, { verifier: {} }); expect(fromAscii(verifier)).toEqual(contract.initMsg.verifier); }); @@ -530,7 +392,7 @@ describe("CosmWasmClient", () => { pendingWithoutCosmos(); assert(contract); - const client = CosmWasmClient.makeReadOnly(httpUrl); + const client = new CosmWasmClient(httpUrl); await client.queryContractSmart(contract.address, { broken: {} }).then( () => fail("must not succeed"), error => expect(error).toMatch(/Error parsing QueryMsg/i), @@ -541,7 +403,7 @@ describe("CosmWasmClient", () => { pendingWithoutCosmos(); const nonExistentAddress = makeRandomAddress(); - const client = CosmWasmClient.makeReadOnly(httpUrl); + const client = new CosmWasmClient(httpUrl); await client.queryContractSmart(nonExistentAddress, { verifier: {} }).then( () => fail("must not succeed"), error => expect(error).toMatch(`No contract found at address "${nonExistentAddress}"`), diff --git a/packages/sdk/src/cosmwasmclient.ts b/packages/sdk/src/cosmwasmclient.ts index 1b857ff8..abf0ff21 100644 --- a/packages/sdk/src/cosmwasmclient.ts +++ b/packages/sdk/src/cosmwasmclient.ts @@ -1,59 +1,9 @@ import { Sha256 } from "@iov/crypto"; import { Encoding } from "@iov/encoding"; -import { makeSignBytes, marshalTx } from "./encoding"; -import { findAttribute, Log, parseLogs } from "./logs"; +import { Log, parseLogs } from "./logs"; import { BlockResponse, RestClient, TxsResponse } from "./restclient"; -import { - Coin, - CosmosSdkAccount, - CosmosSdkTx, - MsgExecuteContract, - MsgInstantiateContract, - MsgSend, - MsgStoreCode, - StdFee, - StdSignature, -} from "./types"; - -export interface FeeTable { - readonly upload: StdFee; - readonly init: StdFee; - readonly exec: StdFee; - readonly send: StdFee; -} - -function singleAmount(amount: number, denom: string): readonly Coin[] { - return [{ amount: amount.toString(), denom: denom }]; -} - -const defaultFees: FeeTable = { - upload: { - amount: singleAmount(25000, "ucosm"), - gas: "1000000", // one million - }, - init: { - amount: singleAmount(12500, "ucosm"), - gas: "500000", // 500k - }, - exec: { - amount: singleAmount(5000, "ucosm"), - gas: "200000", // 200k - }, - send: { - amount: singleAmount(2000, "ucosm"), - gas: "80000", // 80k - }, -}; - -export interface SigningCallback { - (signBytes: Uint8Array): Promise; -} - -export interface SigningData { - readonly senderAddress: string; - readonly signCallback: SigningCallback; -} +import { CosmosSdkAccount, CosmosSdkTx } from "./types"; export interface GetNonceResult { readonly accountNumber: number; @@ -93,44 +43,11 @@ function isSearchBySentFromOrToQuery(query: SearchTxQuery): query is SearchBySen return (query as SearchBySentFromOrToQuery).sentFromOrTo !== undefined; } -export interface ExecuteResult { - readonly logs: readonly Log[]; -} - export class CosmWasmClient { - public static makeReadOnly(url: string): CosmWasmClient { - return new CosmWasmClient(url, undefined, {}); - } + protected readonly restClient: RestClient; - public static makeWritable( - url: string, - senderAddress: string, - signCallback: SigningCallback, - feeTable?: Partial, - ): CosmWasmClient { - return new CosmWasmClient( - url, - { - senderAddress: senderAddress, - signCallback: signCallback, - }, - feeTable || {}, - ); - } - - private readonly restClient: RestClient; - private readonly signingData: SigningData | undefined; - private readonly fees: FeeTable; - - 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 | undefined, customFees: Partial) { + public constructor(url: string) { this.restClient = new RestClient(url); - this.signingData = signingData; - this.fees = { ...defaultFees, ...customFees }; } public async chainId(): Promise { @@ -138,11 +55,6 @@ export class CosmWasmClient { return response.node_info.network; } - public get senderAddress(): string { - if (!this.signingData) throw new Error("Signing data not set in this client"); - return this.signingData.senderAddress; - } - /** * Returns a 32 byte upper-case hex transaction hash (typically used as the transaction ID) */ @@ -160,7 +72,7 @@ export class CosmWasmClient { * * @param address returns data for this address. When unset, the client's sender adddress is used. */ - public async getNonce(address?: string): Promise { + public async getNonce(address: string): Promise { const account = await this.getAccount(address); if (!account) { throw new Error( @@ -173,8 +85,8 @@ export class CosmWasmClient { }; } - public async getAccount(address?: string): Promise { - const account = await this.restClient.authAccounts(address || this.senderAddress); + public async getAccount(address: string): Promise { + const account = await this.restClient.authAccounts(address); const value = account.result.value; return value.address === "" ? undefined : value; } @@ -230,136 +142,6 @@ export class CosmWasmClient { }; } - /** Uploads code and returns a code ID */ - public async upload(wasmCode: Uint8Array, memo = ""): Promise { - 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 = this.fees.upload; - 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 { - 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 = this.fees.init; - 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 { - 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 = this.fees.exec; - 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, - }; - } - - public async sendTokens( - recipientAddress: string, - transferAmount: readonly Coin[], - memo = "", - ): Promise { - const sendMsg: MsgSend = { - type: "cosmos-sdk/MsgSend", - value: { - // eslint-disable-next-line @typescript-eslint/camelcase - from_address: this.senderAddress, - // eslint-disable-next-line @typescript-eslint/camelcase - to_address: recipientAddress, - amount: transferAmount, - }, - }; - const fee = this.fees.send; - const { accountNumber, sequence } = await this.getNonce(); - const chainId = await this.chainId(); - const signBytes = makeSignBytes([sendMsg], fee, chainId, memo, accountNumber, sequence); - const signature = await this.signCallback(signBytes); - const signedTx = { - msg: [sendMsg], - fee: fee, - memo: memo, - signatures: [signature], - }; - - return this.postTx(marshalTx(signedTx)); - } - /** * Returns the data at the key if present (raw contract dependent storage data) * or null if no data at this key. diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index b9e0ee97..371c4cbe 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -8,15 +8,12 @@ export { makeSignBytes, marshalTx } from "./encoding"; export { RestClient, TxsResponse } from "./restclient"; export { CosmWasmClient, - ExecuteResult, GetNonceResult, PostTxResult, SearchByHeightQuery, SearchByIdQuery, SearchBySentFromOrToQuery, SearchTxQuery, - SigningCallback, - SigningData, } from "./cosmwasmclient"; export { makeCosmoshubPath, Pen, PrehashType, Secp256k1Pen } from "./pen"; export { @@ -27,3 +24,4 @@ export { } from "./pubkey"; export { findSequenceForSignedTx } from "./sequence"; export { encodeSecp256k1Signature, decodeSignature } from "./signature"; +export { SigningCallback, SigningCosmWasmClient, ExecuteResult } from "./signingcosmwasmclient"; diff --git a/packages/sdk/src/signingcosmwasmclient.spec.ts b/packages/sdk/src/signingcosmwasmclient.spec.ts new file mode 100644 index 00000000..ba26e4ff --- /dev/null +++ b/packages/sdk/src/signingcosmwasmclient.spec.ts @@ -0,0 +1,164 @@ +import { assert } from "@iov/utils"; + +import { Secp256k1Pen } from "./pen"; +import { RestClient } from "./restclient"; +import { SigningCosmWasmClient } from "./signingcosmwasmclient"; +import { getRandomizedHackatom, makeRandomAddress, pendingWithoutCosmos } from "./testutils.spec"; +import { Coin } from "./types"; + +const httpUrl = "http://localhost:1317"; + +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", +}; + +describe("SigningCosmWasmClient", () => { + describe("makeReadOnly", () => { + it("can be constructed", async () => { + const pen = await Secp256k1Pen.fromMnemonic(faucet.mnemonic); + const client = new SigningCosmWasmClient(httpUrl, faucet.address, signBytes => pen.sign(signBytes)); + expect(client).toBeTruthy(); + }); + }); + + describe("upload", () => { + it("works", async () => { + pendingWithoutCosmos(); + const pen = await Secp256k1Pen.fromMnemonic(faucet.mnemonic); + const client = new SigningCosmWasmClient(httpUrl, faucet.address, signBytes => pen.sign(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 = new SigningCosmWasmClient(httpUrl, faucet.address, signBytes => pen.sign(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 = new SigningCosmWasmClient(httpUrl, faucet.address, signBytes => pen.sign(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 = new SigningCosmWasmClient(httpUrl, faucet.address, signBytes => pen.sign(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([]); + }); + }); + + describe("sendTokens", () => { + it("works", async () => { + pendingWithoutCosmos(); + const pen = await Secp256k1Pen.fromMnemonic(faucet.mnemonic); + const client = new SigningCosmWasmClient(httpUrl, faucet.address, signBytes => pen.sign(signBytes)); + + // instantiate + const transferAmount: readonly Coin[] = [ + { + amount: "7890", + denom: "ucosm", + }, + ]; + const beneficiaryAddress = makeRandomAddress(); + + // no tokens here + const before = await client.getAccount(beneficiaryAddress); + expect(before).toBeUndefined(); + + // send + const result = await client.sendTokens(beneficiaryAddress, transferAmount, "for dinner"); + const [firstLog] = result.logs; + expect(firstLog).toBeTruthy(); + + // got tokens + const after = await client.getAccount(beneficiaryAddress); + assert(after); + expect(after.coins).toEqual(transferAmount); + }); + }); +}); diff --git a/packages/sdk/src/signingcosmwasmclient.ts b/packages/sdk/src/signingcosmwasmclient.ts new file mode 100644 index 00000000..ca56e863 --- /dev/null +++ b/packages/sdk/src/signingcosmwasmclient.ts @@ -0,0 +1,210 @@ +import { Encoding } from "@iov/encoding"; + +import { CosmWasmClient, GetNonceResult, PostTxResult } from "./cosmwasmclient"; +import { makeSignBytes, marshalTx } from "./encoding"; +import { findAttribute, Log } from "./logs"; +import { + Coin, + CosmosSdkAccount, + MsgExecuteContract, + MsgInstantiateContract, + MsgSend, + MsgStoreCode, + StdFee, + StdSignature, +} from "./types"; + +export interface SigningCallback { + (signBytes: Uint8Array): Promise; +} + +export interface FeeTable { + readonly upload: StdFee; + readonly init: StdFee; + readonly exec: StdFee; + readonly send: StdFee; +} + +function singleAmount(amount: number, denom: string): readonly Coin[] { + return [{ amount: amount.toString(), denom: denom }]; +} + +const defaultFees: FeeTable = { + upload: { + amount: singleAmount(25000, "ucosm"), + gas: "1000000", // one million + }, + init: { + amount: singleAmount(12500, "ucosm"), + gas: "500000", // 500k + }, + exec: { + amount: singleAmount(5000, "ucosm"), + gas: "200000", // 200k + }, + send: { + amount: singleAmount(2000, "ucosm"), + gas: "80000", // 80k + }, +}; + +export interface ExecuteResult { + readonly logs: readonly Log[]; +} + +export class SigningCosmWasmClient extends CosmWasmClient { + public readonly senderAddress: string; + + private readonly signCallback: SigningCallback; + private readonly fees: FeeTable; + + public constructor( + url: string, + senderAddress: string, + signCallback: SigningCallback, + customFees?: Partial, + ) { + super(url); + this.senderAddress = senderAddress; + this.signCallback = signCallback; + this.fees = { ...defaultFees, ...(customFees || {}) }; + } + + public async getNonce(address?: string): Promise { + return super.getNonce(address || this.senderAddress); + } + + public async getAccount(address?: string): Promise { + return super.getAccount(address || this.senderAddress); + } + + /** Uploads code and returns a code ID */ + public async upload(wasmCode: Uint8Array, memo = ""): Promise { + 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 = this.fees.upload; + 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 { + 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 = this.fees.init; + 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 { + 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 = this.fees.exec; + 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, + }; + } + + public async sendTokens( + recipientAddress: string, + transferAmount: readonly Coin[], + memo = "", + ): Promise { + const sendMsg: MsgSend = { + type: "cosmos-sdk/MsgSend", + value: { + // eslint-disable-next-line @typescript-eslint/camelcase + from_address: this.senderAddress, + // eslint-disable-next-line @typescript-eslint/camelcase + to_address: recipientAddress, + amount: transferAmount, + }, + }; + const fee = this.fees.send; + const { accountNumber, sequence } = await this.getNonce(); + const chainId = await this.chainId(); + const signBytes = makeSignBytes([sendMsg], fee, chainId, memo, accountNumber, sequence); + const signature = await this.signCallback(signBytes); + const signedTx = { + msg: [sendMsg], + fee: fee, + memo: memo, + signatures: [signature], + }; + + return this.postTx(marshalTx(signedTx)); + } +} diff --git a/packages/sdk/src/testutils.spec.ts b/packages/sdk/src/testutils.spec.ts index 9fe37430..23dc4a3f 100644 --- a/packages/sdk/src/testutils.spec.ts +++ b/packages/sdk/src/testutils.spec.ts @@ -58,6 +58,16 @@ export const tendermintIdMatcher = /^[0-9A-F]{64}$/; export const tendermintOptionalIdMatcher = /^([0-9A-F]{64}|)$/; export const tendermintAddressMatcher = /^[0-9A-F]{40}$/; +export function cosmosEnabled(): boolean { + return !!process.env.COSMOS_ENABLED; +} + +export function pendingWithoutCosmos(): void { + if (!cosmosEnabled()) { + return pending("Set COSMOS_ENABLED to enable Cosmos node-based tests"); + } +} + describe("leb128", () => { describe("leb128Encode", () => { it("works for single byte values", () => { diff --git a/packages/sdk/types/cosmwasmclient.d.ts b/packages/sdk/types/cosmwasmclient.d.ts index fa685c44..c9f93543 100644 --- a/packages/sdk/types/cosmwasmclient.d.ts +++ b/packages/sdk/types/cosmwasmclient.d.ts @@ -1,19 +1,6 @@ import { Log } from "./logs"; -import { BlockResponse, TxsResponse } from "./restclient"; -import { Coin, CosmosSdkAccount, CosmosSdkTx, StdFee, StdSignature } from "./types"; -export interface FeeTable { - readonly upload: StdFee; - readonly init: StdFee; - readonly exec: StdFee; - readonly send: StdFee; -} -export interface SigningCallback { - (signBytes: Uint8Array): Promise; -} -export interface SigningData { - readonly senderAddress: string; - readonly signCallback: SigningCallback; -} +import { BlockResponse, RestClient, TxsResponse } from "./restclient"; +import { CosmosSdkAccount, CosmosSdkTx } from "./types"; export interface GetNonceResult { readonly accountNumber: number; readonly sequence: number; @@ -34,24 +21,10 @@ export interface SearchBySentFromOrToQuery { readonly sentFromOrTo: string; } export declare type SearchTxQuery = SearchByIdQuery | SearchByHeightQuery | SearchBySentFromOrToQuery; -export interface ExecuteResult { - readonly logs: readonly Log[]; -} export declare class CosmWasmClient { - static makeReadOnly(url: string): CosmWasmClient; - static makeWritable( - url: string, - senderAddress: string, - signCallback: SigningCallback, - feeTable?: Partial, - ): CosmWasmClient; - private readonly restClient; - private readonly signingData; - private readonly fees; - private get signCallback(); - private constructor(); + protected readonly restClient: RestClient; + constructor(url: string); chainId(): Promise; - get senderAddress(): string; /** * Returns a 32 byte upper-case hex transaction hash (typically used as the transaction ID) */ @@ -63,8 +36,8 @@ export declare class CosmWasmClient { * * @param address returns data for this address. When unset, the client's sender adddress is used. */ - getNonce(address?: string): Promise; - getAccount(address?: string): Promise; + getNonce(address: string): Promise; + getAccount(address: string): Promise; /** * Gets block header and meta * @@ -73,21 +46,6 @@ export declare class CosmWasmClient { getBlock(height?: number): Promise; searchTx(query: SearchTxQuery): Promise; postTx(tx: Uint8Array): Promise; - /** Uploads code and returns a code ID */ - upload(wasmCode: Uint8Array, memo?: string): Promise; - instantiate( - codeId: number, - initMsg: object, - memo?: string, - transferAmount?: readonly Coin[], - ): Promise; - execute( - contractAddress: string, - handleMsg: object, - memo?: string, - transferAmount?: readonly Coin[], - ): Promise; - sendTokens(recipientAddress: string, transferAmount: readonly Coin[], memo?: string): Promise; /** * Returns the data at the key if present (raw contract dependent storage data) * or null if no data at this key. diff --git a/packages/sdk/types/index.d.ts b/packages/sdk/types/index.d.ts index 2b4511d7..4b2c4d8d 100644 --- a/packages/sdk/types/index.d.ts +++ b/packages/sdk/types/index.d.ts @@ -7,15 +7,12 @@ export { makeSignBytes, marshalTx } from "./encoding"; export { RestClient, TxsResponse } from "./restclient"; export { CosmWasmClient, - ExecuteResult, GetNonceResult, PostTxResult, SearchByHeightQuery, SearchByIdQuery, SearchBySentFromOrToQuery, SearchTxQuery, - SigningCallback, - SigningData, } from "./cosmwasmclient"; export { makeCosmoshubPath, Pen, PrehashType, Secp256k1Pen } from "./pen"; export { @@ -26,3 +23,4 @@ export { } from "./pubkey"; export { findSequenceForSignedTx } from "./sequence"; export { encodeSecp256k1Signature, decodeSignature } from "./signature"; +export { SigningCallback, SigningCosmWasmClient, ExecuteResult } from "./signingcosmwasmclient"; diff --git a/packages/sdk/types/signingcosmwasmclient.d.ts b/packages/sdk/types/signingcosmwasmclient.d.ts new file mode 100644 index 00000000..0393683c --- /dev/null +++ b/packages/sdk/types/signingcosmwasmclient.d.ts @@ -0,0 +1,43 @@ +import { CosmWasmClient, GetNonceResult, PostTxResult } from "./cosmwasmclient"; +import { Log } from "./logs"; +import { Coin, CosmosSdkAccount, StdFee, StdSignature } from "./types"; +export interface SigningCallback { + (signBytes: Uint8Array): Promise; +} +export interface FeeTable { + readonly upload: StdFee; + readonly init: StdFee; + readonly exec: StdFee; + readonly send: StdFee; +} +export interface ExecuteResult { + readonly logs: readonly Log[]; +} +export declare class SigningCosmWasmClient extends CosmWasmClient { + readonly senderAddress: string; + private readonly signCallback; + private readonly fees; + constructor( + url: string, + senderAddress: string, + signCallback: SigningCallback, + customFees?: Partial, + ); + getNonce(address?: string): Promise; + getAccount(address?: string): Promise; + /** Uploads code and returns a code ID */ + upload(wasmCode: Uint8Array, memo?: string): Promise; + instantiate( + codeId: number, + initMsg: object, + memo?: string, + transferAmount?: readonly Coin[], + ): Promise; + execute( + contractAddress: string, + handleMsg: object, + memo?: string, + transferAmount?: readonly Coin[], + ): Promise; + sendTokens(recipientAddress: string, transferAmount: readonly Coin[], memo?: string): Promise; +}