diff --git a/packages/cli/examples/simulate.ts b/packages/cli/examples/simulate.ts new file mode 100644 index 00000000..c01c6119 --- /dev/null +++ b/packages/cli/examples/simulate.ts @@ -0,0 +1,123 @@ +import { coins, makeCosmoshubPath } from "@cosmjs/amino"; +import { DirectSecp256k1HdWallet } from "@cosmjs/proto-signing"; +import { + assertIsBroadcastTxSuccess, + calculateFee, + GasPrice, + MsgSendEncodeObject, + SigningStargateClient, +} from "@cosmjs/stargate"; + +// Wallet +const 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"; +const path = makeCosmoshubPath(3); +const prefix = "cosmos"; +const wallet = await DirectSecp256k1HdWallet.fromMnemonic(mnemonic, { hdPaths: [path], prefix: prefix }); +const [account] = await wallet.getAccounts(); +console.log("Signer address:", account.address); + +// Network config +const rpcEndpoint = "ws://localhost:26658"; +const gasPrice = GasPrice.fromString("0.025ucosm"); + +// Setup client +const client = await SigningStargateClient.connectWithSigner(rpcEndpoint, wallet, { gasPrice: gasPrice }); + +// Send transaction (using sendTokens with auto gas) +{ + const recipient = "cosmos1xv9tklw7d82sezh9haa573wufgy59vmwe6xxe5"; + const amount = coins(1234567, "ucosm"); + const memo = "With simulate"; + const result = await client.sendTokens(account.address, recipient, amount, "auto", memo); + assertIsBroadcastTxSuccess(result); + console.log("Successfully broadcasted:", result); +} + +// Send transaction (using sendTokens with auto gas and custom muliplier) +{ + const recipient = "cosmos1xv9tklw7d82sezh9haa573wufgy59vmwe6xxe5"; + const amount = coins(1234567, "ucosm"); + const memo = "With simulate"; + const result = await client.sendTokens(account.address, recipient, amount, 1.2, memo); + assertIsBroadcastTxSuccess(result); + console.log("Successfully broadcasted:", result); +} + +// Send transaction (using sendTokens with manual gas) +{ + const recipient = "cosmos1xv9tklw7d82sezh9haa573wufgy59vmwe6xxe5"; + const amount = coins(1234567, "ucosm"); + const sendMsg: MsgSendEncodeObject = { + typeUrl: "/cosmos.bank.v1beta1.MsgSend", + value: { + fromAddress: account.address, + toAddress: recipient, + amount: amount, + }, + }; + const memo = "With simulate"; + const gasEstimation = await client.simulate(account.address, [sendMsg], memo); + const fee = calculateFee(Math.round(gasEstimation * 1.3), gasPrice); + const result = await client.sendTokens(account.address, recipient, amount, fee, memo); + assertIsBroadcastTxSuccess(result); + console.log("Successfully broadcasted:", result); +} + +// Send transaction (using signAndBroadcast with auto gas) +{ + const recipient = "cosmos1xv9tklw7d82sezh9haa573wufgy59vmwe6xxe5"; + const amount = coins(1234567, "ucosm"); + const sendMsg: MsgSendEncodeObject = { + typeUrl: "/cosmos.bank.v1beta1.MsgSend", + value: { + fromAddress: account.address, + toAddress: recipient, + amount: amount, + }, + }; + const memo = "With simulate"; + const result = await client.signAndBroadcast(account.address, [sendMsg], "auto", memo); + assertIsBroadcastTxSuccess(result); + console.log("Successfully broadcasted:", result); +} + +// Send transaction (using signAndBroadcast with auto gas and custom muliplier) +{ + const recipient = "cosmos1xv9tklw7d82sezh9haa573wufgy59vmwe6xxe5"; + const amount = coins(1234567, "ucosm"); + const sendMsg: MsgSendEncodeObject = { + typeUrl: "/cosmos.bank.v1beta1.MsgSend", + value: { + fromAddress: account.address, + toAddress: recipient, + amount: amount, + }, + }; + const memo = "With simulate"; + const result = await client.signAndBroadcast(account.address, [sendMsg], 1.4, memo); + assertIsBroadcastTxSuccess(result); + console.log("Successfully broadcasted:", result); +} + +// Send transaction (using signAndBroadcast with manual gas) +{ + const recipient = "cosmos1xv9tklw7d82sezh9haa573wufgy59vmwe6xxe5"; + const amount = coins(1234567, "ucosm"); + const sendMsg: MsgSendEncodeObject = { + typeUrl: "/cosmos.bank.v1beta1.MsgSend", + value: { + fromAddress: account.address, + toAddress: recipient, + amount: amount, + }, + }; + const memo = "With simulate"; + const gasEstimation = await client.simulate(account.address, [sendMsg], memo); + const fee = calculateFee(Math.round(gasEstimation * 1.3), gasPrice); + const result = await client.signAndBroadcast(account.address, [sendMsg], fee, memo); + assertIsBroadcastTxSuccess(result); + console.log("Successfully broadcasted:", result); +} + +client.disconnect(); diff --git a/packages/cli/examples/stargate.ts b/packages/cli/examples/stargate.ts index fabd8c11..c3fce377 100644 --- a/packages/cli/examples/stargate.ts +++ b/packages/cli/examples/stargate.ts @@ -1,4 +1,4 @@ -import { makeCosmoshubPath } from "@cosmjs/amino"; +import { coins, makeCosmoshubPath } from "@cosmjs/amino"; import { DirectSecp256k1HdWallet } from "@cosmjs/proto-signing"; import { assertIsBroadcastTxSuccess, calculateFee, GasPrice, SigningStargateClient } from "@cosmjs/stargate"; @@ -20,15 +20,12 @@ const client = await SigningStargateClient.connectWithSigner(rpcEndpoint, wallet // Send transaction const recipient = "cosmos1xv9tklw7d82sezh9haa573wufgy59vmwe6xxe5"; -const amount = { - denom: "ucosm", - amount: "1234567", -}; +const amount = coins(1234567, "ucosm"); const fee = calculateFee(200_000, gasPrice); const result = await client.sendTokens( account.address, recipient, - [amount], + amount, fee, "Have fun with your star coins", ); diff --git a/packages/cli/run_examples.sh b/packages/cli/run_examples.sh index 897442ca..c8fcd5c6 100755 --- a/packages/cli/run_examples.sh +++ b/packages/cli/run_examples.sh @@ -15,4 +15,5 @@ yarn node ./bin/cosmwasm-cli --init examples/mask.ts --code "process.exit(0)" yarn node ./bin/cosmwasm-cli --init examples/multisig_address.ts --code "process.exit(0)" if [ -n "${SIMAPP42_ENABLED:-}" ]; then yarn node ./bin/cosmwasm-cli --init examples/stargate.ts --code "process.exit(0)" + yarn node ./bin/cosmwasm-cli --init examples/simulate.ts --code "process.exit(0)" fi diff --git a/packages/cosmwasm-stargate/src/cosmwasmclient.ts b/packages/cosmwasm-stargate/src/cosmwasmclient.ts index 1c5c252e..a48d4f15 100644 --- a/packages/cosmwasm-stargate/src/cosmwasmclient.ts +++ b/packages/cosmwasm-stargate/src/cosmwasmclient.ts @@ -19,7 +19,9 @@ import { SequenceResponse, setupAuthExtension, setupBankExtension, + setupTxExtension, TimeoutError, + TxExtension, } from "@cosmjs/stargate"; import { Tendermint34Client, toRfc3339WithNanoseconds } from "@cosmjs/tendermint-rpc"; import { assert, sleep } from "@cosmjs/utils"; @@ -75,12 +77,16 @@ export interface ContractCodeHistoryEntry { /** Use for testing only */ export interface PrivateCosmWasmClient { readonly tmClient: Tendermint34Client | undefined; - readonly queryClient: (QueryClient & AuthExtension & BankExtension & WasmExtension) | undefined; + readonly queryClient: + | (QueryClient & AuthExtension & BankExtension & TxExtension & WasmExtension) + | undefined; } export class CosmWasmClient { private readonly tmClient: Tendermint34Client | undefined; - private readonly queryClient: (QueryClient & AuthExtension & BankExtension & WasmExtension) | undefined; + private readonly queryClient: + | (QueryClient & AuthExtension & BankExtension & TxExtension & WasmExtension) + | undefined; private readonly codesCache = new Map(); private chainId: string | undefined; @@ -97,6 +103,7 @@ export class CosmWasmClient { setupAuthExtension, setupBankExtension, setupWasmExtension, + setupTxExtension, ); } } @@ -114,11 +121,13 @@ export class CosmWasmClient { return this.tmClient; } - protected getQueryClient(): (QueryClient & AuthExtension & BankExtension & WasmExtension) | undefined { + protected getQueryClient(): + | (QueryClient & AuthExtension & BankExtension & TxExtension & WasmExtension) + | undefined { return this.queryClient; } - protected forceGetQueryClient(): QueryClient & AuthExtension & BankExtension & WasmExtension { + protected forceGetQueryClient(): QueryClient & AuthExtension & BankExtension & TxExtension & WasmExtension { if (!this.queryClient) { throw new Error("Query client not available. You cannot use online functionality in offline mode."); } diff --git a/packages/cosmwasm-stargate/src/signingcosmwasmclient.spec.ts b/packages/cosmwasm-stargate/src/signingcosmwasmclient.spec.ts index 0bca4ead..02a40a74 100644 --- a/packages/cosmwasm-stargate/src/signingcosmwasmclient.spec.ts +++ b/packages/cosmwasm-stargate/src/signingcosmwasmclient.spec.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/naming-convention */ import { Secp256k1HdWallet } from "@cosmjs/amino"; import { sha256 } from "@cosmjs/crypto"; -import { toHex } from "@cosmjs/encoding"; +import { toHex, toUtf8 } from "@cosmjs/encoding"; import { decodeTxRaw, DirectSecp256k1HdWallet, Registry } from "@cosmjs/proto-signing"; import { AminoMsgDelegate, @@ -17,17 +17,18 @@ import { DeepPartial, MsgSend } from "cosmjs-types/cosmos/bank/v1beta1/tx"; import { Coin } from "cosmjs-types/cosmos/base/v1beta1/coin"; import { MsgDelegate } from "cosmjs-types/cosmos/staking/v1beta1/tx"; import { AuthInfo, TxBody, TxRaw } from "cosmjs-types/cosmos/tx/v1beta1/tx"; -import { MsgStoreCode } from "cosmjs-types/cosmwasm/wasm/v1/tx"; +import { MsgExecuteContract, MsgStoreCode } from "cosmjs-types/cosmwasm/wasm/v1/tx"; import Long from "long"; import pako from "pako"; import protobuf from "protobufjs/minimal"; -import { MsgStoreCodeEncodeObject } from "./encodeobjects"; +import { MsgExecuteContractEncodeObject, MsgStoreCodeEncodeObject } from "./encodeobjects"; import { SigningCosmWasmClient } from "./signingcosmwasmclient"; import { alice, defaultClearAdminFee, defaultExecuteFee, + defaultGasPrice, defaultInstantiateFee, defaultMigrateFee, defaultSendFee, @@ -71,6 +72,30 @@ describe("SigningCosmWasmClient", () => { }); }); + describe("simulate", () => { + it("works", async () => { + pendingWithoutWasmd(); + const wallet = await DirectSecp256k1HdWallet.fromMnemonic(alice.mnemonic, { prefix: wasmd.prefix }); + const options = { ...defaultSigningClientOptions, prefix: wasmd.prefix }; + const client = await SigningCosmWasmClient.connectWithSigner(wasmd.endpoint, wallet, options); + + const executeContractMsg: MsgExecuteContractEncodeObject = { + typeUrl: "/cosmwasm.wasm.v1.MsgExecuteContract", + value: MsgExecuteContract.fromPartial({ + sender: alice.address0, + contract: deployedHackatom.instances[0].address, + msg: toUtf8(`{"release":{}}`), + funds: [], + }), + }; + const memo = "Go go go"; + const gasUsed = await client.simulate(alice.address0, [executeContractMsg], memo); + expect(gasUsed).toBeGreaterThanOrEqual(101_000); + expect(gasUsed).toBeLessThanOrEqual(150_000); + client.disconnect(); + }); + }); + describe("upload", () => { it("works", async () => { pendingWithoutWasmd(); @@ -576,6 +601,32 @@ describe("SigningCosmWasmClient", () => { client.disconnect(); }); + it("works with auto gas", async () => { + pendingWithoutWasmd(); + const wallet = await DirectSecp256k1HdWallet.fromMnemonic(alice.mnemonic, { prefix: wasmd.prefix }); + const client = await SigningCosmWasmClient.connectWithSigner(wasmd.endpoint, wallet, { + ...defaultSigningClientOptions, + prefix: wasmd.prefix, + gasPrice: defaultGasPrice, + }); + const msgDelegateTypeUrl = "/cosmos.staking.v1beta1.MsgDelegate"; + + const msg = MsgDelegate.fromPartial({ + delegatorAddress: alice.address0, + validatorAddress: validator.validatorAddress, + amount: coin(1234, "ustake"), + }); + const msgAny: MsgDelegateEncodeObject = { + typeUrl: msgDelegateTypeUrl, + value: msg, + }; + const memo = "Use your power wisely"; + const result = await client.signAndBroadcast(alice.address0, [msgAny], "auto", memo); + assertIsBroadcastTxSuccess(result); + + client.disconnect(); + }); + it("works with a modifying signer", async () => { pendingWithoutWasmd(); const wallet = await ModifyingDirectSecp256k1HdWallet.fromMnemonic(alice.mnemonic, { diff --git a/packages/cosmwasm-stargate/src/signingcosmwasmclient.ts b/packages/cosmwasm-stargate/src/signingcosmwasmclient.ts index 76175a08..0e6582b7 100644 --- a/packages/cosmwasm-stargate/src/signingcosmwasmclient.ts +++ b/packages/cosmwasm-stargate/src/signingcosmwasmclient.ts @@ -17,8 +17,10 @@ import { AminoTypes, BroadcastTxFailure, BroadcastTxResponse, + calculateFee, Coin, defaultRegistryTypes, + GasPrice, isBroadcastTxFailure, logs, MsgDelegateEncodeObject, @@ -29,7 +31,7 @@ import { StdFee, } from "@cosmjs/stargate"; import { Tendermint34Client } from "@cosmjs/tendermint-rpc"; -import { assert } from "@cosmjs/utils"; +import { assert, assertDefined } from "@cosmjs/utils"; import { MsgWithdrawDelegatorReward } from "cosmjs-types/cosmos/distribution/v1beta1/tx"; import { MsgDelegate, MsgUndelegate } from "cosmjs-types/cosmos/staking/v1beta1/tx"; import { SignMode } from "cosmjs-types/cosmos/tx/signing/v1beta1/signing"; @@ -144,6 +146,7 @@ export interface SigningCosmWasmClientOptions { readonly prefix?: string; readonly broadcastTimeoutMs?: number; readonly broadcastPollIntervalMs?: number; + readonly gasPrice?: GasPrice; } export class SigningCosmWasmClient extends CosmWasmClient { @@ -153,6 +156,7 @@ export class SigningCosmWasmClient extends CosmWasmClient { private readonly signer: OfflineSigner; private readonly aminoTypes: AminoTypes; + private readonly gasPrice: GasPrice | undefined; public static async connectWithSigner( endpoint: string, @@ -194,13 +198,33 @@ export class SigningCosmWasmClient extends CosmWasmClient { this.signer = signer; this.broadcastTimeoutMs = options.broadcastTimeoutMs; this.broadcastPollIntervalMs = options.broadcastPollIntervalMs; + this.gasPrice = options.gasPrice; + } + + public async simulate( + signerAddress: string, + messages: readonly EncodeObject[], + memo: string | undefined, + ): Promise { + const anyMsgs = messages.map((m) => this.registry.encodeAsAny(m)); + const accountFromSigner = (await this.signer.getAccounts()).find( + (account) => account.address === signerAddress, + ); + if (!accountFromSigner) { + throw new Error("Failed to retrieve account from signer"); + } + const pubkey = encodeSecp256k1Pubkey(accountFromSigner.pubkey); + const { sequence } = await this.getSequence(signerAddress); + const { gasInfo } = await this.forceGetQueryClient().tx.simulate(anyMsgs, memo, pubkey, sequence); + assertDefined(gasInfo); + return Uint53.fromString(gasInfo.gasUsed.toString()).toNumber(); } /** Uploads code and returns a receipt, including the code ID */ public async upload( senderAddress: string, wasmCode: Uint8Array, - fee: StdFee, + fee: StdFee | "auto" | number, memo = "", ): Promise { const compressed = pako.gzip(wasmCode, { level: 9 }); @@ -234,7 +258,7 @@ export class SigningCosmWasmClient extends CosmWasmClient { codeId: number, msg: Record, label: string, - fee: StdFee, + fee: StdFee | "auto" | number, options: InstantiateOptions = {}, ): Promise { const instantiateContractMsg: MsgInstantiateContractEncodeObject = { @@ -265,7 +289,7 @@ export class SigningCosmWasmClient extends CosmWasmClient { senderAddress: string, contractAddress: string, newAdmin: string, - fee: StdFee, + fee: StdFee | "auto" | number, memo = "", ): Promise { const updateAdminMsg: MsgUpdateAdminEncodeObject = { @@ -289,7 +313,7 @@ export class SigningCosmWasmClient extends CosmWasmClient { public async clearAdmin( senderAddress: string, contractAddress: string, - fee: StdFee, + fee: StdFee | "auto" | number, memo = "", ): Promise { const clearAdminMsg: MsgClearAdminEncodeObject = { @@ -314,7 +338,7 @@ export class SigningCosmWasmClient extends CosmWasmClient { contractAddress: string, codeId: number, migrateMsg: Record, - fee: StdFee, + fee: StdFee | "auto" | number, memo = "", ): Promise { const migrateContractMsg: MsgMigrateContractEncodeObject = { @@ -340,7 +364,7 @@ export class SigningCosmWasmClient extends CosmWasmClient { senderAddress: string, contractAddress: string, msg: Record, - fee: StdFee, + fee: StdFee | "auto" | number, memo = "", funds?: readonly Coin[], ): Promise { @@ -367,7 +391,7 @@ export class SigningCosmWasmClient extends CosmWasmClient { senderAddress: string, recipientAddress: string, amount: readonly Coin[], - fee: StdFee, + fee: StdFee | "auto" | number, memo = "", ): Promise { const sendMsg: MsgSendEncodeObject = { @@ -385,7 +409,7 @@ export class SigningCosmWasmClient extends CosmWasmClient { delegatorAddress: string, validatorAddress: string, amount: Coin, - fee: StdFee, + fee: StdFee | "auto" | number, memo = "", ): Promise { const delegateMsg: MsgDelegateEncodeObject = { @@ -399,7 +423,7 @@ export class SigningCosmWasmClient extends CosmWasmClient { delegatorAddress: string, validatorAddress: string, amount: Coin, - fee: StdFee, + fee: StdFee | "auto" | number, memo = "", ): Promise { const undelegateMsg: MsgUndelegateEncodeObject = { @@ -412,7 +436,7 @@ export class SigningCosmWasmClient extends CosmWasmClient { public async withdrawRewards( delegatorAddress: string, validatorAddress: string, - fee: StdFee, + fee: StdFee | "auto" | number, memo = "", ): Promise { const withdrawDelegatorRewardMsg: MsgWithdrawDelegatorRewardEncodeObject = { @@ -433,10 +457,19 @@ export class SigningCosmWasmClient extends CosmWasmClient { public async signAndBroadcast( signerAddress: string, messages: readonly EncodeObject[], - fee: StdFee, + fee: StdFee | "auto" | number, memo = "", ): Promise { - const txRaw = await this.sign(signerAddress, messages, fee, memo); + let usedFee: StdFee; + if (fee == "auto" || typeof fee === "number") { + assertDefined(this.gasPrice, "Gas price must be set in the client options when auto gas is used."); + const gasEstimation = await this.simulate(signerAddress, messages, memo); + const muliplier = typeof fee === "number" ? fee : 1.3; + usedFee = calculateFee(Math.round(gasEstimation * muliplier), this.gasPrice); + } else { + usedFee = fee; + } + const txRaw = await this.sign(signerAddress, messages, usedFee, memo); const txBytes = TxRaw.encode(txRaw).finish(); return this.broadcastTx(txBytes, this.broadcastTimeoutMs, this.broadcastPollIntervalMs); } diff --git a/packages/proto-signing/src/registry.ts b/packages/proto-signing/src/registry.ts index 7ed3cbd5..b40df4b2 100644 --- a/packages/proto-signing/src/registry.ts +++ b/packages/proto-signing/src/registry.ts @@ -117,6 +117,13 @@ export class Registry { return type; } + /** + * Takes a typeUrl/value pair and encodes the value to protobuf if + * the given type was previously registered. + * + * If the value has to be wrapped in an Any, this needs to be done + * manually after this call. Or use `encodeAsAny` instead. + */ public encode(encodeObject: EncodeObject): Uint8Array { const { value, typeUrl } = encodeObject; if (isTxBodyEncodeObject(encodeObject)) { @@ -127,14 +134,20 @@ export class Registry { return type.encode(instance).finish(); } - public encodeTxBody(txBodyFields: TxBodyValue): Uint8Array { - const wrappedMessages = txBodyFields.messages.map((message) => { - const messageBytes = this.encode(message); - return Any.fromPartial({ - typeUrl: message.typeUrl, - value: messageBytes, - }); + /** + * Takes a typeUrl/value pair and encodes the value to an Any if + * the given type was previously registered. + */ + public encodeAsAny(encodeObject: EncodeObject): Any { + const binaryValue = this.encode(encodeObject); + return Any.fromPartial({ + typeUrl: encodeObject.typeUrl, + value: binaryValue, }); + } + + public encodeTxBody(txBodyFields: TxBodyValue): Uint8Array { + const wrappedMessages = txBodyFields.messages.map((message) => this.encodeAsAny(message)); const txBody = TxBody.fromPartial({ ...txBodyFields, messages: wrappedMessages, diff --git a/packages/stargate/src/index.ts b/packages/stargate/src/index.ts index 6b11c991..ac91862d 100644 --- a/packages/stargate/src/index.ts +++ b/packages/stargate/src/index.ts @@ -78,7 +78,9 @@ export { setupGovExtension, setupIbcExtension, setupStakingExtension, + setupTxExtension, StakingExtension, + TxExtension, } from "./queries"; export { SearchByHeightQuery, diff --git a/packages/stargate/src/queries/index.ts b/packages/stargate/src/queries/index.ts index cc8b1609..8101bbb4 100644 --- a/packages/stargate/src/queries/index.ts +++ b/packages/stargate/src/queries/index.ts @@ -10,4 +10,5 @@ export { DistributionExtension, setupDistributionExtension } from "./distributio export { setupGovExtension, GovExtension, GovProposalId, GovParamsType } from "./gov"; export { IbcExtension, setupIbcExtension } from "./ibc"; export { setupStakingExtension, StakingExtension } from "./staking"; +export { setupTxExtension, TxExtension } from "./tx"; export { createPagination, createProtobufRpcClient, ProtobufRpcClient } from "./utils"; diff --git a/packages/stargate/src/queries/tx.spec.ts b/packages/stargate/src/queries/tx.spec.ts new file mode 100644 index 00000000..fa225c05 --- /dev/null +++ b/packages/stargate/src/queries/tx.spec.ts @@ -0,0 +1,104 @@ +import { coin, coins, DirectSecp256k1HdWallet, Registry } from "@cosmjs/proto-signing"; +import { Tendermint34Client } from "@cosmjs/tendermint-rpc"; +import { assertDefined, sleep } from "@cosmjs/utils"; +import { MsgDelegate } from "cosmjs-types/cosmos/staking/v1beta1/tx"; +import Long from "long"; + +import { defaultRegistryTypes, SigningStargateClient } from "../signingstargateclient"; +import { assertIsBroadcastTxSuccess, StargateClient } from "../stargateclient"; +import { + defaultSigningClientOptions, + faucet, + makeRandomAddress, + pendingWithoutSimapp, + simapp, + simappEnabled, + validator, +} from "../testutils.spec"; +import { QueryClient } from "./queryclient"; +import { setupTxExtension, TxExtension } from "./tx"; +import { longify } from "./utils"; + +async function makeClientWithTx(rpcUrl: string): Promise<[QueryClient & TxExtension, Tendermint34Client]> { + const tmClient = await Tendermint34Client.connect(rpcUrl); + return [QueryClient.withExtensions(tmClient, setupTxExtension), tmClient]; +} + +describe("TxExtension", () => { + const defaultFee = { + amount: coins(25000, "ucosm"), + gas: "1500000", // 1.5 million + }; + let txHash: string | undefined; + let memo: string | undefined; + + beforeAll(async () => { + if (simappEnabled()) { + const wallet = await DirectSecp256k1HdWallet.fromMnemonic(faucet.mnemonic); + const client = await SigningStargateClient.connectWithSigner( + simapp.tendermintUrl, + wallet, + defaultSigningClientOptions, + ); + + { + const recipient = makeRandomAddress(); + memo = `Test tx ${Date.now()}`; + const result = await client.sendTokens( + faucet.address0, + recipient, + coins(25000, "ucosm"), + defaultFee, + memo, + ); + assertIsBroadcastTxSuccess(result); + txHash = result.transactionHash; + } + + await sleep(75); // wait until transactions are indexed + } + }); + + describe("getTx", () => { + it("works", async () => { + pendingWithoutSimapp(); + assertDefined(txHash); + assertDefined(memo); + const [client, tmClient] = await makeClientWithTx(simapp.tendermintUrl); + + const response = await client.tx.getTx(txHash); + expect(response.tx?.body?.memo).toEqual(memo); + + tmClient.disconnect(); + }); + }); + + describe("simulate", () => { + it("works", async () => { + pendingWithoutSimapp(); + assertDefined(txHash); + assertDefined(memo); + const [client, tmClient] = await makeClientWithTx(simapp.tendermintUrl); + const sequenceClient = await StargateClient.connect(simapp.tendermintUrl); + + const registry = new Registry(defaultRegistryTypes); + const msg: MsgDelegate = { + delegatorAddress: faucet.address0, + validatorAddress: validator.validatorAddress, + amount: coin(25000, "ustake"), + }; + const msgAny = registry.encodeAsAny({ + typeUrl: "/cosmos.staking.v1beta1.MsgDelegate", + value: msg, + }); + + const { sequence } = await sequenceClient.getSequence(faucet.address0); + const response = await client.tx.simulate([msgAny], "foo", faucet.pubkey0, sequence); + expect(response.gasInfo?.gasUsed.toNumber()).toBeGreaterThanOrEqual(101_000); + expect(response.gasInfo?.gasUsed.toNumber()).toBeLessThanOrEqual(150_000); + expect(response.gasInfo?.gasWanted).toEqual(longify(Long.UZERO)); + + tmClient.disconnect(); + }); + }); +}); diff --git a/packages/stargate/src/queries/tx.ts b/packages/stargate/src/queries/tx.ts new file mode 100644 index 00000000..ca591d3b --- /dev/null +++ b/packages/stargate/src/queries/tx.ts @@ -0,0 +1,81 @@ +import { Pubkey } from "@cosmjs/amino"; +import { encodePubkey } from "@cosmjs/proto-signing"; +import { SignMode } from "cosmjs-types/cosmos/tx/signing/v1beta1/signing"; +import { + GetTxRequest, + GetTxResponse, + ServiceClientImpl, + SimulateRequest, + SimulateResponse, +} from "cosmjs-types/cosmos/tx/v1beta1/service"; +import { AuthInfo, Fee, Tx, TxBody } from "cosmjs-types/cosmos/tx/v1beta1/tx"; +import { Any } from "cosmjs-types/google/protobuf/any"; +import Long from "long"; + +import { QueryClient } from "./queryclient"; +import { createProtobufRpcClient } from "./utils"; + +export interface TxExtension { + readonly tx: { + getTx: (txId: string) => Promise; + simulate: ( + messages: readonly Any[], + memo: string | undefined, + signer: Pubkey, + sequence: number, + ) => Promise; + // Add here with tests: + // - broadcastTx + // - getTxsEvent + }; +} + +export function setupTxExtension(base: QueryClient): TxExtension { + // Use this service to get easy typed access to query methods + // This cannot be used for proof verification + const rpc = createProtobufRpcClient(base); + const queryService = new ServiceClientImpl(rpc); + + return { + tx: { + getTx: async (txId: string) => { + const request: GetTxRequest = { + hash: txId, + }; + const response = await queryService.GetTx(request); + return response; + }, + simulate: async ( + messages: readonly Any[], + memo: string | undefined, + signer: Pubkey, + sequence: number, + ) => { + const request = SimulateRequest.fromPartial({ + tx: Tx.fromPartial({ + authInfo: AuthInfo.fromPartial({ + fee: Fee.fromPartial({}), + signerInfos: [ + { + publicKey: encodePubkey(signer), + sequence: Long.fromNumber(sequence, true), + modeInfo: { single: { mode: SignMode.SIGN_MODE_UNSPECIFIED } }, + }, + ], + }), + body: TxBody.fromPartial({ + messages: Array.from(messages), + memo: memo, + }), + signatures: [new Uint8Array()], + }), + // Sending serialized `txBytes` is the future. But + // this is not available in Comsos SDK 0.42. + txBytes: undefined, + }); + const response = await queryService.Simulate(request); + return response; + }, + }, + }; +} diff --git a/packages/stargate/src/signingstargateclient.spec.ts b/packages/stargate/src/signingstargateclient.spec.ts index 8996a29f..dd25f997 100644 --- a/packages/stargate/src/signingstargateclient.spec.ts +++ b/packages/stargate/src/signingstargateclient.spec.ts @@ -16,6 +16,7 @@ import { MsgDelegateEncodeObject, MsgSendEncodeObject } from "./encodeobjects"; import { PrivateSigningStargateClient, SigningStargateClient } from "./signingstargateclient"; import { assertIsBroadcastTxSuccess, isBroadcastTxFailure } from "./stargateclient"; import { + defaultGasPrice, defaultSendFee, defaultSigningClientOptions, faucet, @@ -42,6 +43,34 @@ describe("SigningStargateClient", () => { }); }); + describe("simulate", () => { + it("works", async () => { + pendingWithoutSimapp(); + const wallet = await DirectSecp256k1HdWallet.fromMnemonic(faucet.mnemonic); + const client = await SigningStargateClient.connectWithSigner( + simapp.tendermintUrl, + wallet, + defaultSigningClientOptions, + ); + + const msg = MsgDelegate.fromPartial({ + delegatorAddress: faucet.address0, + validatorAddress: validator.validatorAddress, + amount: coin(1234, "ustake"), + }); + const msgAny: MsgDelegateEncodeObject = { + typeUrl: "/cosmos.staking.v1beta1.MsgDelegate", + value: msg, + }; + const memo = "Use your power wisely"; + const gasUsed = await client.simulate(faucet.address0, [msgAny], memo); + expect(gasUsed).toBeGreaterThanOrEqual(101_000); + expect(gasUsed).toBeLessThanOrEqual(150_000); + + client.disconnect(); + }); + }); + describe("sendTokens", () => { it("works with direct signer", async () => { pendingWithoutSimapp(); @@ -245,6 +274,27 @@ describe("SigningStargateClient", () => { assertIsBroadcastTxSuccess(result); }); + it("works with auto gas", async () => { + pendingWithoutSimapp(); + const wallet = await DirectSecp256k1HdWallet.fromMnemonic(faucet.mnemonic); + const client = await SigningStargateClient.connectWithSigner(simapp.tendermintUrl, wallet, { + ...defaultSigningClientOptions, + gasPrice: defaultGasPrice, + }); + + const msg = MsgDelegate.fromPartial({ + delegatorAddress: faucet.address0, + validatorAddress: validator.validatorAddress, + amount: coin(1234, "ustake"), + }); + const msgAny: MsgDelegateEncodeObject = { + typeUrl: "/cosmos.staking.v1beta1.MsgDelegate", + value: msg, + }; + const result = await client.signAndBroadcast(faucet.address0, [msgAny], "auto"); + assertIsBroadcastTxSuccess(result); + }); + it("works with a modifying signer", async () => { pendingWithoutSimapp(); const wallet = await ModifyingDirectSecp256k1HdWallet.fromMnemonic(faucet.mnemonic); diff --git a/packages/stargate/src/signingstargateclient.ts b/packages/stargate/src/signingstargateclient.ts index 607cc212..bb8895b0 100644 --- a/packages/stargate/src/signingstargateclient.ts +++ b/packages/stargate/src/signingstargateclient.ts @@ -1,6 +1,6 @@ import { encodeSecp256k1Pubkey, makeSignDoc as makeSignDocAmino, StdFee } from "@cosmjs/amino"; import { fromBase64 } from "@cosmjs/encoding"; -import { Int53 } from "@cosmjs/math"; +import { Int53, Uint53 } from "@cosmjs/math"; import { EncodeObject, encodePubkey, @@ -13,7 +13,7 @@ import { TxBodyEncodeObject, } from "@cosmjs/proto-signing"; import { Tendermint34Client } from "@cosmjs/tendermint-rpc"; -import { assert } from "@cosmjs/utils"; +import { assert, assertDefined } from "@cosmjs/utils"; import { MsgMultiSend } from "cosmjs-types/cosmos/bank/v1beta1/tx"; import { Coin } from "cosmjs-types/cosmos/base/v1beta1/coin"; import { @@ -68,6 +68,7 @@ import { MsgUndelegateEncodeObject, MsgWithdrawDelegatorRewardEncodeObject, } from "./encodeobjects"; +import { calculateFee, GasPrice } from "./fee"; import { BroadcastTxResponse, StargateClient } from "./stargateclient"; export const defaultRegistryTypes: ReadonlyArray<[string, GeneratedType]> = [ @@ -131,6 +132,7 @@ export interface SigningStargateClientOptions { readonly prefix?: string; readonly broadcastTimeoutMs?: number; readonly broadcastPollIntervalMs?: number; + readonly gasPrice?: GasPrice; } export class SigningStargateClient extends StargateClient { @@ -140,6 +142,7 @@ export class SigningStargateClient extends StargateClient { private readonly signer: OfflineSigner; private readonly aminoTypes: AminoTypes; + private readonly gasPrice: GasPrice | undefined; public static async connectWithSigner( endpoint: string, @@ -179,13 +182,33 @@ export class SigningStargateClient extends StargateClient { this.signer = signer; this.broadcastTimeoutMs = options.broadcastTimeoutMs; this.broadcastPollIntervalMs = options.broadcastPollIntervalMs; + this.gasPrice = options.gasPrice; + } + + public async simulate( + signerAddress: string, + messages: readonly EncodeObject[], + memo: string | undefined, + ): Promise { + const anyMsgs = messages.map((m) => this.registry.encodeAsAny(m)); + const accountFromSigner = (await this.signer.getAccounts()).find( + (account) => account.address === signerAddress, + ); + if (!accountFromSigner) { + throw new Error("Failed to retrieve account from signer"); + } + const pubkey = encodeSecp256k1Pubkey(accountFromSigner.pubkey); + const { sequence } = await this.getSequence(signerAddress); + const { gasInfo } = await this.forceGetQueryClient().tx.simulate(anyMsgs, memo, pubkey, sequence); + assertDefined(gasInfo); + return Uint53.fromString(gasInfo.gasUsed.toString()).toNumber(); } public async sendTokens( senderAddress: string, recipientAddress: string, amount: readonly Coin[], - fee: StdFee, + fee: StdFee | "auto" | number, memo = "", ): Promise { const sendMsg: MsgSendEncodeObject = { @@ -203,7 +226,7 @@ export class SigningStargateClient extends StargateClient { delegatorAddress: string, validatorAddress: string, amount: Coin, - fee: StdFee, + fee: StdFee | "auto" | number, memo = "", ): Promise { const delegateMsg: MsgDelegateEncodeObject = { @@ -221,7 +244,7 @@ export class SigningStargateClient extends StargateClient { delegatorAddress: string, validatorAddress: string, amount: Coin, - fee: StdFee, + fee: StdFee | "auto" | number, memo = "", ): Promise { const undelegateMsg: MsgUndelegateEncodeObject = { @@ -238,7 +261,7 @@ export class SigningStargateClient extends StargateClient { public async withdrawRewards( delegatorAddress: string, validatorAddress: string, - fee: StdFee, + fee: StdFee | "auto" | number, memo = "", ): Promise { const withdrawMsg: MsgWithdrawDelegatorRewardEncodeObject = { @@ -260,7 +283,7 @@ export class SigningStargateClient extends StargateClient { timeoutHeight: Height | undefined, /** timeout in seconds */ timeoutTimestamp: number | undefined, - fee: StdFee, + fee: StdFee | "auto" | number, memo = "", ): Promise { const timeoutTimestampNanoseconds = timeoutTimestamp @@ -284,10 +307,19 @@ export class SigningStargateClient extends StargateClient { public async signAndBroadcast( signerAddress: string, messages: readonly EncodeObject[], - fee: StdFee, + fee: StdFee | "auto" | number, memo = "", ): Promise { - const txRaw = await this.sign(signerAddress, messages, fee, memo); + let usedFee: StdFee; + if (fee == "auto" || typeof fee === "number") { + assertDefined(this.gasPrice, "Gas price must be set in the client options when auto gas is used."); + const gasEstimation = await this.simulate(signerAddress, messages, memo); + const muliplier = typeof fee === "number" ? fee : 1.3; + usedFee = calculateFee(Math.round(gasEstimation * muliplier), this.gasPrice); + } else { + usedFee = fee; + } + const txRaw = await this.sign(signerAddress, messages, usedFee, memo); const txBytes = TxRaw.encode(txRaw).finish(); return this.broadcastTx(txBytes, this.broadcastTimeoutMs, this.broadcastPollIntervalMs); } diff --git a/packages/stargate/src/stargateclient.ts b/packages/stargate/src/stargateclient.ts index dde63ba6..0f1ebf3e 100644 --- a/packages/stargate/src/stargateclient.ts +++ b/packages/stargate/src/stargateclient.ts @@ -14,7 +14,9 @@ import { setupAuthExtension, setupBankExtension, setupStakingExtension, + setupTxExtension, StakingExtension, + TxExtension, } from "./queries"; import { isSearchByHeightQuery, @@ -146,7 +148,9 @@ export interface PrivateStargateClient { export class StargateClient { private readonly tmClient: Tendermint34Client | undefined; - private readonly queryClient: (QueryClient & AuthExtension & BankExtension & StakingExtension) | undefined; + private readonly queryClient: + | (QueryClient & AuthExtension & BankExtension & StakingExtension & TxExtension) + | undefined; private chainId: string | undefined; public static async connect(endpoint: string): Promise { @@ -162,6 +166,7 @@ export class StargateClient { setupAuthExtension, setupBankExtension, setupStakingExtension, + setupTxExtension, ); } } @@ -179,11 +184,17 @@ export class StargateClient { return this.tmClient; } - protected getQueryClient(): (QueryClient & AuthExtension & BankExtension & StakingExtension) | undefined { + protected getQueryClient(): + | (QueryClient & AuthExtension & BankExtension & StakingExtension & TxExtension) + | undefined { return this.queryClient; } - protected forceGetQueryClient(): QueryClient & AuthExtension & BankExtension & StakingExtension { + protected forceGetQueryClient(): QueryClient & + AuthExtension & + BankExtension & + StakingExtension & + TxExtension { if (!this.queryClient) { throw new Error("Query client not available. You cannot use online functionality in offline mode."); }