diff --git a/packages/amino/src/signdoc.ts b/packages/amino/src/signdoc.ts index 3a6f56c1dc..fe5f1fb780 100644 --- a/packages/amino/src/signdoc.ts +++ b/packages/amino/src/signdoc.ts @@ -30,6 +30,7 @@ export interface StdSignDoc { readonly fee: StdFee; readonly msgs: readonly AminoMsg[]; readonly memo: string; + readonly timeout_height?: string; } function sortedObject(obj: any): any { @@ -61,6 +62,7 @@ export function makeSignDoc( memo: string | undefined, accountNumber: number | string, sequence: number | string, + timeout_height?: bigint, ): StdSignDoc { return { chain_id: chainId, @@ -69,6 +71,7 @@ export function makeSignDoc( fee: fee, msgs: msgs, memo: memo || "", + ...(timeout_height && { timeout_height: timeout_height.toString() }), }; } diff --git a/packages/cosmwasm-stargate/src/signingcosmwasmclient.spec.ts b/packages/cosmwasm-stargate/src/signingcosmwasmclient.spec.ts index f12e0934ad..307fc626a4 100644 --- a/packages/cosmwasm-stargate/src/signingcosmwasmclient.spec.ts +++ b/packages/cosmwasm-stargate/src/signingcosmwasmclient.spec.ts @@ -1191,6 +1191,72 @@ describe("SigningCosmWasmClient", () => { client.disconnect(); }); + + it("works with a custom timeoutHeight", async () => { + pendingWithoutWasmd(); + const wallet = await DirectSecp256k1HdWallet.fromMnemonic(alice.mnemonic, { prefix: wasmd.prefix }); + const client = await SigningCosmWasmClient.connectWithSigner(wasmd.endpoint, wallet, { + ...defaultSigningClientOptions, + }); + + const msg = MsgSend.fromPartial({ + fromAddress: alice.address0, + toAddress: alice.address0, + amount: [coin(1, "ucosm")], + }); + const msgAny: MsgSendEncodeObject = { + typeUrl: "/cosmos.bank.v1beta1.MsgSend", + value: msg, + }; + const fee = { + amount: coins(2000, "ucosm"), + gas: "222000", // 222k + }; + const memo = "Use your power wisely"; + const height = await client.getHeight(); + const signed = await client.sign(alice.address0, [msgAny], fee, memo, undefined, BigInt(height + 3)); + + // ensure signature is valid + const result = await client.broadcastTx(Uint8Array.from(TxRaw.encode(signed).finish())); + assertIsDeliverTxSuccess(result); + + client.disconnect(); + }); + + it("fails with past timeoutHeight", async () => { + pendingWithoutWasmd(); + const wallet = await DirectSecp256k1HdWallet.fromMnemonic(alice.mnemonic, { prefix: wasmd.prefix }); + const client = await SigningCosmWasmClient.connectWithSigner(wasmd.endpoint, wallet, { + ...defaultSigningClientOptions, + }); + + const msg = MsgSend.fromPartial({ + fromAddress: alice.address0, + toAddress: alice.address0, + amount: [coin(1, "ucosm")], + }); + const msgAny: MsgSendEncodeObject = { + typeUrl: "/cosmos.bank.v1beta1.MsgSend", + value: msg, + }; + const fee = { + amount: coins(2000, "ucosm"), + gas: "222000", // 222k + }; + const memo = "Use your power wisely"; + const height = await client.getHeight(); + const signed = await client.sign(alice.address0, [msgAny], fee, memo, undefined, BigInt(height - 1)); + + await expectAsync( + client.broadcastTx(Uint8Array.from(TxRaw.encode(signed).finish())), + ).toBeRejectedWith( + jasmine.objectContaining({ + code: 30, + }), + ); + + client.disconnect(); + }); }); describe("legacy Amino mode", () => { @@ -1407,6 +1473,72 @@ describe("SigningCosmWasmClient", () => { client.disconnect(); }); + + it("works with custom timeoutHeight", async () => { + pendingWithoutWasmd(); + const wallet = await Secp256k1HdWallet.fromMnemonic(alice.mnemonic, { prefix: wasmd.prefix }); + const client = await SigningCosmWasmClient.connectWithSigner(wasmd.endpoint, wallet, { + ...defaultSigningClientOptions, + }); + + const msg = MsgSend.fromPartial({ + fromAddress: alice.address0, + toAddress: alice.address0, + amount: [coin(1, "ucosm")], + }); + const msgAny: MsgSendEncodeObject = { + typeUrl: "/cosmos.bank.v1beta1.MsgSend", + value: msg, + }; + const fee = { + amount: coins(2000, "ucosm"), + gas: "200000", + }; + const memo = "Use your tokens wisely"; + const height = await client.getHeight(); + const signed = await client.sign(alice.address0, [msgAny], fee, memo, undefined, BigInt(height + 3)); + + // ensure signature is valid + const result = await client.broadcastTx(Uint8Array.from(TxRaw.encode(signed).finish())); + assertIsDeliverTxSuccess(result); + + client.disconnect(); + }); + + it("fails with past timeoutHeight", async () => { + pendingWithoutWasmd(); + const wallet = await Secp256k1HdWallet.fromMnemonic(alice.mnemonic, { prefix: wasmd.prefix }); + const client = await SigningCosmWasmClient.connectWithSigner(wasmd.endpoint, wallet, { + ...defaultSigningClientOptions, + }); + + const msg = MsgSend.fromPartial({ + fromAddress: alice.address0, + toAddress: alice.address0, + amount: [coin(1, "ucosm")], + }); + const msgAny: MsgSendEncodeObject = { + typeUrl: "/cosmos.bank.v1beta1.MsgSend", + value: msg, + }; + const fee = { + amount: coins(2000, "ucosm"), + gas: "200000", + }; + const memo = "Use your tokens wisely"; + const height = await client.getHeight(); + const signed = await client.sign(alice.address0, [msgAny], fee, memo, undefined, BigInt(height - 1)); + + await expectAsync( + client.broadcastTx(Uint8Array.from(TxRaw.encode(signed).finish())), + ).toBeRejectedWith( + jasmine.objectContaining({ + code: 30, + }), + ); + + client.disconnect(); + }); }); }); }); diff --git a/packages/cosmwasm-stargate/src/signingcosmwasmclient.ts b/packages/cosmwasm-stargate/src/signingcosmwasmclient.ts index 6de59caf6e..12430cc1f2 100644 --- a/packages/cosmwasm-stargate/src/signingcosmwasmclient.ts +++ b/packages/cosmwasm-stargate/src/signingcosmwasmclient.ts @@ -574,18 +574,20 @@ export class SigningCosmWasmClient extends CosmWasmClient { } /** - * Creates a transaction with the given messages, fee and memo. Then signs and broadcasts the transaction. + * Creates a transaction with the given messages, fee, memo and timeout height. Then signs and broadcasts the transaction. * * @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 + * @param timeoutHeight (optional) timeout height to prevent the tx from being committed past a certain height */ public async signAndBroadcast( signerAddress: string, messages: readonly EncodeObject[], fee: StdFee | "auto" | number, memo = "", + timeoutHeight?: bigint, ): Promise { let usedFee: StdFee; if (fee == "auto" || typeof fee === "number") { @@ -598,13 +600,13 @@ export class SigningCosmWasmClient extends CosmWasmClient { } else { usedFee = fee; } - const txRaw = await this.sign(signerAddress, messages, usedFee, memo); + const txRaw = await this.sign(signerAddress, messages, usedFee, memo, undefined, timeoutHeight); const txBytes = TxRaw.encode(txRaw).finish(); return this.broadcastTx(txBytes, this.broadcastTimeoutMs, this.broadcastPollIntervalMs); } /** - * Creates a transaction with the given messages, fee and memo. Then signs and broadcasts the transaction. + * Creates a transaction with the given messages, fee, memo and timeout height. 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 @@ -614,6 +616,7 @@ export class SigningCosmWasmClient extends CosmWasmClient { * @param messages * @param fee * @param memo + * @param timeoutHeight (optional) timeout height to prevent the tx from being committed past a certain height * * @returns Returns the hash of the transaction */ @@ -622,6 +625,7 @@ export class SigningCosmWasmClient extends CosmWasmClient { messages: readonly EncodeObject[], fee: StdFee | "auto" | number, memo = "", + timeoutHeight?: bigint, ): Promise { let usedFee: StdFee; if (fee == "auto" || typeof fee === "number") { @@ -632,7 +636,7 @@ export class SigningCosmWasmClient extends CosmWasmClient { } else { usedFee = fee; } - const txRaw = await this.sign(signerAddress, messages, usedFee, memo); + const txRaw = await this.sign(signerAddress, messages, usedFee, memo, undefined, timeoutHeight); const txBytes = TxRaw.encode(txRaw).finish(); return this.broadcastTxSync(txBytes); } @@ -643,6 +647,7 @@ export class SigningCosmWasmClient extends CosmWasmClient { fee: StdFee, memo: string, explicitSignerData?: SignerData, + timeoutHeight?: bigint, ): Promise { let signerData: SignerData; if (explicitSignerData) { @@ -658,8 +663,8 @@ export class SigningCosmWasmClient extends CosmWasmClient { } return isOfflineDirectSigner(this.signer) - ? this.signDirect(signerAddress, messages, fee, memo, signerData) - : this.signAmino(signerAddress, messages, fee, memo, signerData); + ? this.signDirect(signerAddress, messages, fee, memo, signerData, timeoutHeight) + : this.signAmino(signerAddress, messages, fee, memo, signerData, timeoutHeight); } private async signAmino( @@ -668,6 +673,7 @@ export class SigningCosmWasmClient extends CosmWasmClient { fee: StdFee, memo: string, { accountNumber, sequence, chainId }: SignerData, + timeoutHeight?: bigint, ): Promise { assert(!isOfflineDirectSigner(this.signer)); const accountFromSigner = (await this.signer.getAccounts()).find( @@ -679,13 +685,14 @@ export class SigningCosmWasmClient extends CosmWasmClient { const pubkey = encodePubkey(encodeSecp256k1Pubkey(accountFromSigner.pubkey)); const signMode = SignMode.SIGN_MODE_LEGACY_AMINO_JSON; const msgs = messages.map((msg) => this.aminoTypes.toAmino(msg)); - const signDoc = makeSignDocAmino(msgs, fee, chainId, memo, accountNumber, sequence); + const signDoc = makeSignDocAmino(msgs, fee, chainId, memo, accountNumber, sequence, timeoutHeight); const { signature, signed } = await this.signer.signAmino(signerAddress, signDoc); const signedTxBody: TxBodyEncodeObject = { typeUrl: "/cosmos.tx.v1beta1.TxBody", value: { messages: signed.msgs.map((msg) => this.aminoTypes.fromAmino(msg)), memo: signed.memo, + timeoutHeight: timeoutHeight, }, }; const signedTxBodyBytes = this.registry.encode(signedTxBody); @@ -712,6 +719,7 @@ export class SigningCosmWasmClient extends CosmWasmClient { fee: StdFee, memo: string, { accountNumber, sequence, chainId }: SignerData, + timeoutHeight?: bigint, ): Promise { assert(isOfflineDirectSigner(this.signer)); const accountFromSigner = (await this.signer.getAccounts()).find( @@ -726,6 +734,7 @@ export class SigningCosmWasmClient extends CosmWasmClient { value: { messages: messages, memo: memo, + timeoutHeight: timeoutHeight, }, }; const txBodyBytes = this.registry.encode(txBody); diff --git a/packages/stargate/src/signingstargateclient.spec.ts b/packages/stargate/src/signingstargateclient.spec.ts index 0699ad9198..a676631e46 100644 --- a/packages/stargate/src/signingstargateclient.spec.ts +++ b/packages/stargate/src/signingstargateclient.spec.ts @@ -911,6 +911,74 @@ describe("SigningStargateClient", () => { const result = await client.broadcastTx(Uint8Array.from(TxRaw.encode(signed).finish())); assertIsDeliverTxSuccess(result); }); + + it("works with custom timeoutHeight", async () => { + pendingWithoutSimapp(); + const wallet = await DirectSecp256k1HdWallet.fromMnemonic(faucet.mnemonic); + const client = await SigningStargateClient.connectWithSigner( + simapp.tendermintUrl, + wallet, + defaultSigningClientOptions, + ); + + const msg = MsgSend.fromPartial({ + fromAddress: faucet.address0, + toAddress: faucet.address0, + amount: [coin(1, "ucosm")], + }); + const msgAny: MsgSendEncodeObject = { + typeUrl: "/cosmos.bank.v1beta1.MsgSend", + value: msg, + }; + const fee = { + amount: coins(2000, "ucosm"), + gas: "222000", // 222k + }; + const memo = "Use your power wisely"; + const height = await client.getHeight(); + const signed = await client.sign(faucet.address0, [msgAny], fee, memo, undefined, BigInt(height + 3)); + + // ensure signature is valid + const result = await client.broadcastTx(Uint8Array.from(TxRaw.encode(signed).finish())); + assertIsDeliverTxSuccess(result); + }); + + it("fails with past timeoutHeight", async () => { + pendingWithoutSimapp(); + const wallet = await DirectSecp256k1HdWallet.fromMnemonic(faucet.mnemonic); + const client = await SigningStargateClient.connectWithSigner( + simapp.tendermintUrl, + wallet, + defaultSigningClientOptions, + ); + + const msg = MsgSend.fromPartial({ + fromAddress: faucet.address0, + toAddress: faucet.address0, + amount: [coin(1, "ucosm")], + }); + const msgAny: MsgSendEncodeObject = { + typeUrl: "/cosmos.bank.v1beta1.MsgSend", + value: msg, + }; + const fee = { + amount: coins(2000, "ucosm"), + gas: "222000", // 222k + }; + const memo = "Use your power wisely"; + const height = await client.getHeight(); + const signed = await client.sign(faucet.address0, [msgAny], fee, memo, undefined, BigInt(height - 1)); + + await expectAsync( + client.broadcastTx(Uint8Array.from(TxRaw.encode(signed).finish())), + ).toBeRejectedWith( + jasmine.objectContaining({ + code: 30, + }), + ); + + client.disconnect(); + }); }); describe("legacy Amino mode", () => { @@ -1121,6 +1189,74 @@ describe("SigningStargateClient", () => { const result = await client.broadcastTx(Uint8Array.from(TxRaw.encode(signed).finish())); assertIsDeliverTxSuccess(result); }); + + it("works with custom timeoutHeight", async () => { + pendingWithoutSimapp(); + const wallet = await Secp256k1HdWallet.fromMnemonic(faucet.mnemonic); + const client = await SigningStargateClient.connectWithSigner( + simapp.tendermintUrl, + wallet, + defaultSigningClientOptions, + ); + + const msg = MsgSend.fromPartial({ + fromAddress: faucet.address0, + toAddress: faucet.address0, + amount: [coin(1, "ucosm")], + }); + const msgAny: MsgSendEncodeObject = { + typeUrl: "/cosmos.bank.v1beta1.MsgSend", + value: msg, + }; + const fee = { + amount: coins(2000, "ucosm"), + gas: "200000", + }; + const memo = "Use your tokens wisely"; + const height = await client.getHeight(); + const signed = await client.sign(faucet.address0, [msgAny], fee, memo, undefined, BigInt(height + 3)); + + // ensure signature is valid + const result = await client.broadcastTx(Uint8Array.from(TxRaw.encode(signed).finish())); + assertIsDeliverTxSuccess(result); + }); + + it("fails with past timeoutHeight", async () => { + pendingWithoutSimapp(); + const wallet = await Secp256k1HdWallet.fromMnemonic(faucet.mnemonic); + const client = await SigningStargateClient.connectWithSigner( + simapp.tendermintUrl, + wallet, + defaultSigningClientOptions, + ); + + const msg = MsgSend.fromPartial({ + fromAddress: faucet.address0, + toAddress: faucet.address0, + amount: [coin(1, "ucosm")], + }); + const msgAny: MsgSendEncodeObject = { + typeUrl: "/cosmos.bank.v1beta1.MsgSend", + value: msg, + }; + const fee = { + amount: coins(2000, "ucosm"), + gas: "200000", + }; + const memo = "Use your tokens wisely"; + const height = await client.getHeight(); + const signed = await client.sign(faucet.address0, [msgAny], fee, memo, undefined, BigInt(height - 1)); + + await expectAsync( + client.broadcastTx(Uint8Array.from(TxRaw.encode(signed).finish())), + ).toBeRejectedWith( + jasmine.objectContaining({ + code: 30, + }), + ); + + client.disconnect(); + }); }); }); }); diff --git a/packages/stargate/src/signingstargateclient.ts b/packages/stargate/src/signingstargateclient.ts index c468502fe2..7d8d75e018 100644 --- a/packages/stargate/src/signingstargateclient.ts +++ b/packages/stargate/src/signingstargateclient.ts @@ -312,6 +312,7 @@ export class SigningStargateClient extends StargateClient { messages: readonly EncodeObject[], fee: StdFee | "auto" | number, memo = "", + timeoutHeight?: bigint, ): Promise { let usedFee: StdFee; if (fee == "auto" || typeof fee === "number") { @@ -324,7 +325,7 @@ export class SigningStargateClient extends StargateClient { } else { usedFee = fee; } - const txRaw = await this.sign(signerAddress, messages, usedFee, memo); + const txRaw = await this.sign(signerAddress, messages, usedFee, memo, undefined, timeoutHeight); const txBytes = TxRaw.encode(txRaw).finish(); return this.broadcastTx(txBytes, this.broadcastTimeoutMs, this.broadcastPollIntervalMs); } @@ -340,6 +341,7 @@ export class SigningStargateClient extends StargateClient { messages: readonly EncodeObject[], fee: StdFee | "auto" | number, memo = "", + timeoutHeight?: bigint, ): Promise { let usedFee: StdFee; if (fee == "auto" || typeof fee === "number") { @@ -350,7 +352,7 @@ export class SigningStargateClient extends StargateClient { } else { usedFee = fee; } - const txRaw = await this.sign(signerAddress, messages, usedFee, memo); + const txRaw = await this.sign(signerAddress, messages, usedFee, memo, undefined, timeoutHeight); const txBytes = TxRaw.encode(txRaw).finish(); return this.broadcastTxSync(txBytes); } @@ -371,6 +373,7 @@ export class SigningStargateClient extends StargateClient { fee: StdFee, memo: string, explicitSignerData?: SignerData, + timeoutHeight?: bigint, ): Promise { let signerData: SignerData; if (explicitSignerData) { @@ -386,8 +389,8 @@ export class SigningStargateClient extends StargateClient { } return isOfflineDirectSigner(this.signer) - ? this.signDirect(signerAddress, messages, fee, memo, signerData) - : this.signAmino(signerAddress, messages, fee, memo, signerData); + ? this.signDirect(signerAddress, messages, fee, memo, signerData, timeoutHeight) + : this.signAmino(signerAddress, messages, fee, memo, signerData, timeoutHeight); } private async signAmino( @@ -396,6 +399,7 @@ export class SigningStargateClient extends StargateClient { fee: StdFee, memo: string, { accountNumber, sequence, chainId }: SignerData, + timeoutHeight?: bigint, ): Promise { assert(!isOfflineDirectSigner(this.signer)); const accountFromSigner = (await this.signer.getAccounts()).find( @@ -407,11 +411,12 @@ export class SigningStargateClient extends StargateClient { const pubkey = encodePubkey(encodeSecp256k1Pubkey(accountFromSigner.pubkey)); const signMode = SignMode.SIGN_MODE_LEGACY_AMINO_JSON; const msgs = messages.map((msg) => this.aminoTypes.toAmino(msg)); - const signDoc = makeSignDocAmino(msgs, fee, chainId, memo, accountNumber, sequence); + const signDoc = makeSignDocAmino(msgs, fee, chainId, memo, accountNumber, sequence, timeoutHeight); const { signature, signed } = await this.signer.signAmino(signerAddress, signDoc); const signedTxBody = { messages: signed.msgs.map((msg) => this.aminoTypes.fromAmino(msg)), memo: signed.memo, + timeoutHeight: timeoutHeight, }; const signedTxBodyEncodeObject: TxBodyEncodeObject = { typeUrl: "/cosmos.tx.v1beta1.TxBody", @@ -441,6 +446,7 @@ export class SigningStargateClient extends StargateClient { fee: StdFee, memo: string, { accountNumber, sequence, chainId }: SignerData, + timeoutHeight?: bigint, ): Promise { assert(isOfflineDirectSigner(this.signer)); const accountFromSigner = (await this.signer.getAccounts()).find( @@ -455,6 +461,7 @@ export class SigningStargateClient extends StargateClient { value: { messages: messages, memo: memo, + timeoutHeight: timeoutHeight, }, }; const txBodyBytes = this.registry.encode(txBodyEncodeObject);