diff --git a/packages/cosmwasm-stargate/src/cosmwasmclient.ts b/packages/cosmwasm-stargate/src/cosmwasmclient.ts index 9864ce661f..44217b77ac 100644 --- a/packages/cosmwasm-stargate/src/cosmwasmclient.ts +++ b/packages/cosmwasm-stargate/src/cosmwasmclient.ts @@ -295,13 +295,8 @@ export class CosmWasmClient { : pollForTx(txId); }; - const broadcasted = await this.forceGetTmClient().broadcastTxSync({ tx }); - if (broadcasted.code) { - return Promise.reject( - new BroadcastTxError(broadcasted.code, broadcasted.codespace ?? "", broadcasted.log), - ); - } - const transactionId = toHex(broadcasted.hash).toUpperCase(); + const transactionId = await this.broadcastTxSync(tx); + return new Promise((resolve, reject) => pollForTx(transactionId).then( (value) => { @@ -316,6 +311,31 @@ export class CosmWasmClient { ); } + /** + * Broadcasts a signed transaction to the network without monitoring it. + * + * If broadcasting is rejected by the node for some reason (e.g. because of a CheckTx failure), + * an error is thrown. + * + * If the transaction is broadcasted, a `string` containing the hash of the transaction is returned. The caller then + * usually needs to check if the transaction was included in a block and was successful. + * + * @returns Returns the hash of the transaction + */ + public async broadcastTxSync(tx: Uint8Array): Promise { + const broadcasted = await this.forceGetTmClient().broadcastTxSync({ tx }); + + if (broadcasted.code) { + return Promise.reject( + new BroadcastTxError(broadcasted.code, broadcasted.codespace ?? "", broadcasted.log), + ); + } + + const transactionId = toHex(broadcasted.hash).toUpperCase(); + + return transactionId; + } + /** * getCodes() returns all codes and is just looping through all pagination pages. * diff --git a/packages/cosmwasm-stargate/src/signingcosmwasmclient.ts b/packages/cosmwasm-stargate/src/signingcosmwasmclient.ts index b3c15acab7..e7b219d48d 100644 --- a/packages/cosmwasm-stargate/src/signingcosmwasmclient.ts +++ b/packages/cosmwasm-stargate/src/signingcosmwasmclient.ts @@ -611,6 +611,40 @@ export class SigningCosmWasmClient extends CosmWasmClient { return this.broadcastTx(txBytes, this.broadcastTimeoutMs, this.broadcastPollIntervalMs); } + /** + * Creates a transaction with the given messages, fee and memo. Then signs and broadcasts the transaction. + * + * This method is useful if you want to send a transaction in broadcast, + * without waiting for it to be placed inside a block, because for example + * I would like to receive the hash to later track the transaction with another tool. + * + * @param signerAddress The address that will sign transactions using this instance. The signer must be able to sign with this address. + * @param messages + * @param fee + * @param memo + * + * @returns Returns the hash of the transaction + */ + public async signAndBroadcastSync( + signerAddress: string, + messages: readonly EncodeObject[], + fee: StdFee | "auto" | number, + memo = "", + ): Promise { + 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 multiplier = typeof fee === "number" ? fee : 1.3; + usedFee = calculateFee(Math.round(gasEstimation * multiplier), this.gasPrice); + } else { + usedFee = fee; + } + const txRaw = await this.sign(signerAddress, messages, usedFee, memo); + const txBytes = TxRaw.encode(txRaw).finish(); + return this.broadcastTxSync(txBytes); + } + public async sign( signerAddress: string, messages: readonly EncodeObject[], diff --git a/packages/stargate/src/signingstargateclient.spec.ts b/packages/stargate/src/signingstargateclient.spec.ts index 2e072f2479..b76abc3600 100644 --- a/packages/stargate/src/signingstargateclient.spec.ts +++ b/packages/stargate/src/signingstargateclient.spec.ts @@ -690,6 +690,160 @@ describe("SigningStargateClient", () => { }); }); + describe("signAndBroadcastSync", () => { + describe("direct mode", () => { + it("works", async () => { + pendingWithoutSimapp(); + const wallet = await DirectSecp256k1HdWallet.fromMnemonic(faucet.mnemonic); + const client = await SigningStargateClient.connectWithSigner( + simapp.tendermintUrl, + wallet, + defaultSigningClientOptions, + ); + + const msgSend: MsgSend = { + fromAddress: faucet.address0, + toAddress: makeRandomAddress(), + amount: coins(1234, "ucosm"), + }; + + const msgAny: MsgSendEncodeObject = { + typeUrl: "/cosmos.bank.v1beta1.MsgSend", + value: msgSend, + }; + const fee = { + amount: coins(2000, "ucosm"), + gas: "222000", // 222k + }; + const memo = "Use your power wisely"; + const transactionHash = await client.signAndBroadcastSync(faucet.address0, [msgAny], fee, memo); + + expect(transactionHash).toMatch(/^[0-9A-F]{64}$/); + + await sleep(simapp.blockTime * 1.5); + }); + + 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 msgSend: MsgSend = { + fromAddress: faucet.address0, + toAddress: makeRandomAddress(), + amount: coins(1234, "ucosm"), + }; + + const msgAny: MsgSendEncodeObject = { + typeUrl: "/cosmos.bank.v1beta1.MsgSend", + value: msgSend, + }; + const transactionHash = await client.signAndBroadcastSync(faucet.address0, [msgAny], "auto"); + + expect(transactionHash).toMatch(/^[0-9A-F]{64}$/); + + await sleep(simapp.blockTime * 1.5); + }); + + it("works with a modifying signer", async () => { + pendingWithoutSimapp(); + const wallet = await ModifyingDirectSecp256k1HdWallet.fromMnemonic(faucet.mnemonic); + const client = await SigningStargateClient.connectWithSigner( + simapp.tendermintUrl, + wallet, + defaultSigningClientOptions, + ); + + const msgSend: MsgSend = { + fromAddress: faucet.address0, + toAddress: makeRandomAddress(), + amount: coins(1234, "ucosm"), + }; + + const msgAny: MsgSendEncodeObject = { + typeUrl: "/cosmos.bank.v1beta1.MsgSend", + value: msgSend, + }; + + const fee = { + amount: coins(2000, "ucosm"), + gas: "222000", // 222k + }; + const memo = "Use your power wisely"; + const transactionHash = await client.signAndBroadcastSync(faucet.address0, [msgAny], fee, memo); + + expect(transactionHash).toMatch(/^[0-9A-F]{64}$/); + + await sleep(simapp.blockTime * 1.5); + }); + }); + + describe("legacy Amino mode", () => { + it("works with special characters in memo", async () => { + pendingWithoutSimapp(); + const wallet = await Secp256k1HdWallet.fromMnemonic(faucet.mnemonic); + const client = await SigningStargateClient.connectWithSigner( + simapp.tendermintUrl, + wallet, + defaultSigningClientOptions, + ); + + const msgSend: MsgSend = { + fromAddress: faucet.address0, + toAddress: makeRandomAddress(), + amount: coins(1234, "ucosm"), + }; + const msgAny: MsgSendEncodeObject = { + typeUrl: "/cosmos.bank.v1beta1.MsgSend", + value: msgSend, + }; + const fee = { + amount: coins(2000, "ucosm"), + gas: "200000", + }; + const memo = "ampersand:&,lt:<,gt:>"; + const transactionHash = await client.signAndBroadcastSync(faucet.address0, [msgAny], fee, memo); + + expect(transactionHash).toMatch(/^[0-9A-F]{64}$/); + + await sleep(simapp.blockTime * 1.5); + }); + + it("works with bank MsgSend", async () => { + pendingWithoutSimapp(); + const wallet = await Secp256k1HdWallet.fromMnemonic(faucet.mnemonic); + const client = await SigningStargateClient.connectWithSigner( + simapp.tendermintUrl, + wallet, + defaultSigningClientOptions, + ); + + const msgSend: MsgSend = { + fromAddress: faucet.address0, + toAddress: makeRandomAddress(), + amount: coins(1234, "ucosm"), + }; + const msgAny: MsgSendEncodeObject = { + typeUrl: "/cosmos.bank.v1beta1.MsgSend", + value: msgSend, + }; + const fee = { + amount: coins(2000, "ucosm"), + gas: "200000", + }; + const memo = "Use your tokens wisely"; + const transactionHash = await client.signAndBroadcastSync(faucet.address0, [msgAny], fee, memo); + + expect(transactionHash).toMatch(/^[0-9A-F]{64}$/); + + await sleep(simapp.blockTime * 1.5); + }); + }); + }); + describe("sign", () => { describe("direct mode", () => { it("works", async () => { diff --git a/packages/stargate/src/signingstargateclient.ts b/packages/stargate/src/signingstargateclient.ts index 1216e0ebf2..0c089fc7ac 100644 --- a/packages/stargate/src/signingstargateclient.ts +++ b/packages/stargate/src/signingstargateclient.ts @@ -326,6 +326,32 @@ export class SigningStargateClient extends StargateClient { return this.broadcastTx(txBytes, this.broadcastTimeoutMs, this.broadcastPollIntervalMs); } + /** + * This method is useful if you want to send a transaction in broadcast, + * without waiting for it to be placed inside a block, because for example + * I would like to receive the hash to later track the transaction with another tool. + * @returns Returns the hash of the transaction + */ + public async signAndBroadcastSync( + signerAddress: string, + messages: readonly EncodeObject[], + fee: StdFee | "auto" | number, + memo = "", + ): Promise { + 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 multiplier = typeof fee === "number" ? fee : 1.3; + usedFee = calculateFee(Math.round(gasEstimation * multiplier), this.gasPrice); + } else { + usedFee = fee; + } + const txRaw = await this.sign(signerAddress, messages, usedFee, memo); + const txBytes = TxRaw.encode(txRaw).finish(); + return this.broadcastTxSync(txBytes); + } + /** * Gets account number and sequence from the API, creates a sign doc, * creates a single signature and assembles the signed transaction. diff --git a/packages/stargate/src/stargateclient.spec.ts b/packages/stargate/src/stargateclient.spec.ts index c7f0df94b2..52b97eb80c 100644 --- a/packages/stargate/src/stargateclient.spec.ts +++ b/packages/stargate/src/stargateclient.spec.ts @@ -554,4 +554,68 @@ describe("StargateClient", () => { client.disconnect(); }, 30_000); }); + + describe("broadcastTxSync", () => { + it("broadcasts sync a transaction, to get transaction hash", async () => { + pendingWithoutSimapp(); + const client = await StargateClient.connect(simapp.tendermintUrl); + const wallet = await DirectSecp256k1HdWallet.fromMnemonic(faucet.mnemonic); + const [{ address, pubkey: pubkeyBytes }] = await wallet.getAccounts(); + const pubkey = encodePubkey({ + type: "tendermint/PubKeySecp256k1", + value: toBase64(pubkeyBytes), + }); + const registry = new Registry(); + const txBodyFields: TxBodyEncodeObject = { + typeUrl: "/cosmos.tx.v1beta1.TxBody", + value: { + messages: [ + { + typeUrl: "/cosmos.bank.v1beta1.MsgSend", + value: { + fromAddress: address, + toAddress: makeRandomAddress(), + amount: [ + { + denom: "ucosm", + amount: "1234567", + }, + ], + }, + }, + ], + }, + }; + const txBodyBytes = registry.encode(txBodyFields); + const { accountNumber, sequence } = (await client.getSequence(address))!; + const feeAmount = coins(2000, "ucosm"); + const gasLimit = 200000; + const feeGranter = undefined; + const feePayer = undefined; + const authInfoBytes = makeAuthInfoBytes( + [{ pubkey, sequence }], + feeAmount, + gasLimit, + feeGranter, + feePayer, + ); + + const chainId = await client.getChainId(); + const signDoc = makeSignDoc(txBodyBytes, authInfoBytes, chainId, accountNumber); + const { signature } = await wallet.signDirect(address, signDoc); + const txRaw = TxRaw.fromPartial({ + bodyBytes: txBodyBytes, + authInfoBytes: authInfoBytes, + signatures: [fromBase64(signature.signature)], + }); + const txRawBytes = Uint8Array.from(TxRaw.encode(txRaw).finish()); + const transactionHash = await client.broadcastTxSync(txRawBytes); + + expect(transactionHash).toMatch(/^[0-9A-F]{64}$/); + + await sleep(simapp.blockTime * 1.5); + + client.disconnect(); + }); + }); }); diff --git a/packages/stargate/src/stargateclient.ts b/packages/stargate/src/stargateclient.ts index 7b21cbe058..e463a2e326 100644 --- a/packages/stargate/src/stargateclient.ts +++ b/packages/stargate/src/stargateclient.ts @@ -464,13 +464,8 @@ export class StargateClient { : pollForTx(txId); }; - const broadcasted = await this.forceGetTmClient().broadcastTxSync({ tx }); - if (broadcasted.code) { - return Promise.reject( - new BroadcastTxError(broadcasted.code, broadcasted.codespace ?? "", broadcasted.log), - ); - } - const transactionId = toHex(broadcasted.hash).toUpperCase(); + const transactionId = await this.broadcastTxSync(tx); + return new Promise((resolve, reject) => pollForTx(transactionId).then( (value) => { @@ -485,6 +480,31 @@ export class StargateClient { ); } + /** + * Broadcasts a signed transaction to the network without monitoring it. + * + * If broadcasting is rejected by the node for some reason (e.g. because of a CheckTx failure), + * an error is thrown. + * + * If the transaction is broadcasted, a `string` containing the hash of the transaction is returned. The caller then + * usually needs to check if the transaction was included in a block and was successful. + * + * @returns Returns the hash of the transaction + */ + public async broadcastTxSync(tx: Uint8Array): Promise { + const broadcasted = await this.forceGetTmClient().broadcastTxSync({ tx }); + + if (broadcasted.code) { + return Promise.reject( + new BroadcastTxError(broadcasted.code, broadcasted.codespace ?? "", broadcasted.log), + ); + } + + const transactionId = toHex(broadcasted.hash).toUpperCase(); + + return transactionId; + } + private async txsQuery(query: string): Promise { const results = await this.forceGetTmClient().txSearchAll({ query: query }); return results.txs.map((tx): IndexedTx => {