diff --git a/CHANGELOG.md b/CHANGELOG.md index 44bbe903..5aa30424 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,8 @@ and this project adheres to - @cosmjs/proto-signing: Added new `Coin`, `coin`, `coins` and `parseCoins` exports which have the same functionality as already existed in @cosmjs/launchpad. +- @cosmjs/stargate: Add `SigningStargateClient.sign`, which allows you to create + signed transactions without broadcasting them directly. ### Changed diff --git a/packages/stargate/src/signingstargateclient.spec.ts b/packages/stargate/src/signingstargateclient.spec.ts index 0ba4e867..e826c554 100644 --- a/packages/stargate/src/signingstargateclient.spec.ts +++ b/packages/stargate/src/signingstargateclient.spec.ts @@ -8,7 +8,7 @@ import { AminoTypes } from "./aminotypes"; import { MsgSend } from "./codec/cosmos/bank/v1beta1/tx"; import { Coin } from "./codec/cosmos/base/v1beta1/coin"; import { DeepPartial, MsgDelegate } from "./codec/cosmos/staking/v1beta1/tx"; -import { Tx } from "./codec/cosmos/tx/v1beta1/tx"; +import { AuthInfo, Tx, TxBody, TxRaw } from "./codec/cosmos/tx/v1beta1/tx"; import { GasPrice } from "./fee"; import { PrivateSigningStargateClient, SigningStargateClient } from "./signingstargateclient"; import { assertIsBroadcastTxSuccess } from "./stargateclient"; @@ -427,4 +427,275 @@ describe("SigningStargateClient", () => { }); }); }); + + describe("sign", () => { + describe("direct mode", () => { + it("works", async () => { + pendingWithoutSimapp(); + const wallet = await DirectSecp256k1HdWallet.fromMnemonic(faucet.mnemonic); + const client = await SigningStargateClient.connectWithSigner(simapp.tendermintUrl, wallet); + + const msg = MsgDelegate.fromPartial({ + delegatorAddress: faucet.address0, + validatorAddress: validator.validatorAddress, + amount: coin(1234, "ustake"), + }); + const msgAny = { + typeUrl: "/cosmos.staking.v1beta1.MsgDelegate", + value: msg, + }; + const fee = { + amount: coins(2000, "ucosm"), + gas: "180000", // 180k + }; + const memo = "Use your power wisely"; + const signed = await client.sign(faucet.address0, [msgAny], fee, memo); + + // ensure signature is valid + const result = await client.broadcastTx(Uint8Array.from(TxRaw.encode(signed).finish())); + assertIsBroadcastTxSuccess(result); + }); + + it("works with a modifying signer", async () => { + pendingWithoutSimapp(); + const wallet = await ModifyingDirectSecp256k1HdWallet.fromMnemonic(faucet.mnemonic); + const client = await SigningStargateClient.connectWithSigner(simapp.tendermintUrl, wallet); + + const msg = MsgDelegate.fromPartial({ + delegatorAddress: faucet.address0, + validatorAddress: validator.validatorAddress, + amount: coin(1234, "ustake"), + }); + const msgAny = { + typeUrl: "/cosmos.staking.v1beta1.MsgDelegate", + value: msg, + }; + const fee = { + amount: coins(2000, "ucosm"), + gas: "180000", // 180k + }; + const memo = "Use your power wisely"; + const signed = await client.sign(faucet.address0, [msgAny], fee, memo); + + const body = TxBody.decode(signed.bodyBytes); + const authInfo = AuthInfo.decode(signed.authInfoBytes); + // From ModifyingDirectSecp256k1HdWallet + expect(body.memo).toEqual("This was modified"); + expect({ ...authInfo.fee!.amount[0] }).toEqual(coin(3000, "ucosm")); + expect(authInfo.fee!.gasLimit.toNumber()).toEqual(333333); + + // ensure signature is valid + const result = await client.broadcastTx(Uint8Array.from(TxRaw.encode(signed).finish())); + assertIsBroadcastTxSuccess(result); + }); + }); + + describe("legacy Amino mode", () => { + it("works with bank MsgSend", async () => { + pendingWithoutSimapp(); + const wallet = await Secp256k1HdWallet.fromMnemonic(faucet.mnemonic); + const client = await SigningStargateClient.connectWithSigner(simapp.tendermintUrl, wallet); + + const msgSend: MsgSend = { + fromAddress: faucet.address0, + toAddress: makeRandomAddress(), + amount: coins(1234, "ucosm"), + }; + const msgAny = { + typeUrl: "/cosmos.bank.v1beta1.MsgSend", + value: msgSend, + }; + const fee = { + amount: coins(2000, "ucosm"), + gas: "200000", + }; + const memo = "Use your tokens wisely"; + const signed = await client.sign(faucet.address0, [msgAny], fee, memo); + + // ensure signature is valid + const result = await client.broadcastTx(Uint8Array.from(TxRaw.encode(signed).finish())); + assertIsBroadcastTxSuccess(result); + }); + + it("works with staking MsgDelegate", async () => { + pendingWithoutSimapp(); + const wallet = await Secp256k1HdWallet.fromMnemonic(faucet.mnemonic); + const client = await SigningStargateClient.connectWithSigner(simapp.tendermintUrl, wallet); + + const msgDelegate: MsgDelegate = { + delegatorAddress: faucet.address0, + validatorAddress: validator.validatorAddress, + amount: coin(1234, "ustake"), + }; + const msgAny = { + typeUrl: "/cosmos.staking.v1beta1.MsgDelegate", + value: msgDelegate, + }; + const fee = { + amount: coins(2000, "ustake"), + gas: "200000", + }; + const memo = "Use your tokens wisely"; + const signed = await client.sign(faucet.address0, [msgAny], fee, memo); + + // ensure signature is valid + const result = await client.broadcastTx(Uint8Array.from(TxRaw.encode(signed).finish())); + assertIsBroadcastTxSuccess(result); + }); + + it("works with a custom registry and custom message", async () => { + pendingWithoutSimapp(); + const wallet = await Secp256k1HdWallet.fromMnemonic(faucet.mnemonic); + + const customRegistry = new Registry(); + const msgDelegateTypeUrl = "/cosmos.staking.v1beta1.MsgDelegate"; + interface CustomMsgDelegate { + customDelegatorAddress?: string; + customValidatorAddress?: string; + customAmount?: Coin; + } + const baseCustomMsgDelegate: CustomMsgDelegate = { + customDelegatorAddress: "", + customValidatorAddress: "", + }; + const CustomMsgDelegate = { + // Adapted from autogenerated MsgDelegate implementation + encode( + message: CustomMsgDelegate, + writer: protobuf.Writer = protobuf.Writer.create(), + ): protobuf.Writer { + writer.uint32(10).string(message.customDelegatorAddress ?? ""); + writer.uint32(18).string(message.customValidatorAddress ?? ""); + if (message.customAmount !== undefined && message.customAmount !== undefined) { + Coin.encode(message.customAmount, writer.uint32(26).fork()).ldelim(); + } + return writer; + }, + + decode(): CustomMsgDelegate { + throw new Error("decode method should not be required"); + }, + + fromJSON(): CustomMsgDelegate { + throw new Error("fromJSON method should not be required"); + }, + + fromPartial(object: DeepPartial): CustomMsgDelegate { + const message = { ...baseCustomMsgDelegate } as CustomMsgDelegate; + if (object.customDelegatorAddress !== undefined && object.customDelegatorAddress !== null) { + message.customDelegatorAddress = object.customDelegatorAddress; + } else { + message.customDelegatorAddress = ""; + } + if (object.customValidatorAddress !== undefined && object.customValidatorAddress !== null) { + message.customValidatorAddress = object.customValidatorAddress; + } else { + message.customValidatorAddress = ""; + } + if (object.customAmount !== undefined && object.customAmount !== null) { + message.customAmount = Coin.fromPartial(object.customAmount); + } else { + message.customAmount = undefined; + } + return message; + }, + + toJSON(): unknown { + throw new Error("toJSON method should not be required"); + }, + }; + customRegistry.register(msgDelegateTypeUrl, CustomMsgDelegate); + const customAminoTypes = new AminoTypes({ + additions: { + "/cosmos.staking.v1beta1.MsgDelegate": { + aminoType: "cosmos-sdk/MsgDelegate", + toAmino: ({ + customDelegatorAddress, + customValidatorAddress, + customAmount, + }: CustomMsgDelegate): LaunchpadMsgDelegate["value"] => { + assert(customDelegatorAddress, "missing customDelegatorAddress"); + assert(customValidatorAddress, "missing validatorAddress"); + assert(customAmount, "missing amount"); + assert(customAmount.amount, "missing amount.amount"); + assert(customAmount.denom, "missing amount.denom"); + return { + delegator_address: customDelegatorAddress, + validator_address: customValidatorAddress, + amount: { + amount: customAmount.amount, + denom: customAmount.denom, + }, + }; + }, + fromAmino: ({ + delegator_address, + validator_address, + amount, + }: LaunchpadMsgDelegate["value"]): CustomMsgDelegate => ({ + customDelegatorAddress: delegator_address, + customValidatorAddress: validator_address, + customAmount: Coin.fromPartial(amount), + }), + }, + }, + }); + const options = { registry: customRegistry, aminoTypes: customAminoTypes }; + const client = await SigningStargateClient.connectWithSigner(simapp.tendermintUrl, wallet, options); + + const msg: CustomMsgDelegate = { + customDelegatorAddress: faucet.address0, + customValidatorAddress: validator.validatorAddress, + customAmount: coin(1234, "ustake"), + }; + const msgAny = { + typeUrl: "/cosmos.staking.v1beta1.MsgDelegate", + value: msg, + }; + const fee = { + amount: coins(2000, "ucosm"), + gas: "200000", + }; + const memo = "Use your power wisely"; + const signed = await client.sign(faucet.address0, [msgAny], fee, memo); + + // ensure signature is valid + const result = await client.broadcastTx(Uint8Array.from(TxRaw.encode(signed).finish())); + assertIsBroadcastTxSuccess(result); + }); + + it("works with a modifying signer", async () => { + pendingWithoutSimapp(); + const wallet = await ModifyingSecp256k1HdWallet.fromMnemonic(faucet.mnemonic); + const client = await SigningStargateClient.connectWithSigner(simapp.tendermintUrl, wallet); + + const msg: MsgDelegate = { + delegatorAddress: faucet.address0, + validatorAddress: validator.validatorAddress, + amount: coin(1234, "ustake"), + }; + const msgAny = { + typeUrl: "/cosmos.staking.v1beta1.MsgDelegate", + value: msg, + }; + const fee = { + amount: coins(2000, "ucosm"), + gas: "200000", + }; + const memo = "Use your power wisely"; + const signed = await client.sign(faucet.address0, [msgAny], fee, memo); + + const body = TxBody.decode(signed.bodyBytes); + const authInfo = AuthInfo.decode(signed.authInfoBytes); + // From ModifyingSecp256k1HdWallet + expect(body.memo).toEqual("This was modified"); + expect({ ...authInfo.fee!.amount[0] }).toEqual(coin(3000, "ucosm")); + expect(authInfo.fee!.gasLimit.toNumber()).toEqual(333333); + + // ensure signature is valid + const result = await client.broadcastTx(Uint8Array.from(TxRaw.encode(signed).finish())); + assertIsBroadcastTxSuccess(result); + }); + }); + }); }); diff --git a/packages/stargate/src/signingstargateclient.ts b/packages/stargate/src/signingstargateclient.ts index b34b7d39..aaf4020d 100644 --- a/packages/stargate/src/signingstargateclient.ts +++ b/packages/stargate/src/signingstargateclient.ts @@ -169,18 +169,34 @@ export class SigningStargateClient extends StargateClient { fee: StdFee, memo = "", ): Promise { - const signedTx = isOfflineDirectSigner(this.signer) - ? await this.signDirect(signerAddress, messages, fee, memo) - : await this.signAmino(signerAddress, messages, fee, memo); + const txRaw = await this.sign(signerAddress, messages, fee, memo); + const signedTx = Uint8Array.from(TxRaw.encode(txRaw).finish()); return this.broadcastTx(signedTx); } + /** + * Gets account number and sequence from the API, creates a sign doc, + * creates a single signature and assembles the signed transaction. + * + * The sign mode (SIGN_MODE_DIRECT or SIGN_MODE_LEGACY_AMINO_JSON) is determined by this client's signer. + */ + public async sign( + signerAddress: string, + messages: readonly EncodeObject[], + fee: StdFee, + memo: string, + ): Promise { + return isOfflineDirectSigner(this.signer) + ? this.signDirect(signerAddress, messages, fee, memo) + : this.signAmino(signerAddress, messages, fee, memo); + } + private async signAmino( signerAddress: string, messages: readonly EncodeObject[], fee: StdFee, memo: string, - ): Promise { + ): Promise { assert(!isOfflineDirectSigner(this.signer)); const accountFromSigner = (await this.signer.getAccounts()).find( (account) => account.address === signerAddress, @@ -216,13 +232,11 @@ export class SigningStargateClient extends StargateClient { signedSequence, signMode, ); - const txRaw = TxRaw.fromPartial({ + return TxRaw.fromPartial({ bodyBytes: signedTxBodyBytes, authInfoBytes: signedAuthInfoBytes, signatures: [fromBase64(signature.signature)], }); - const signedTx = Uint8Array.from(TxRaw.encode(txRaw).finish()); - return signedTx; } private async signDirect( @@ -230,7 +244,7 @@ export class SigningStargateClient extends StargateClient { messages: readonly EncodeObject[], fee: StdFee, memo: string, - ): Promise { + ): Promise { assert(isOfflineDirectSigner(this.signer)); const accountFromSigner = (await this.signer.getAccounts()).find( (account) => account.address === signerAddress, @@ -257,11 +271,10 @@ export class SigningStargateClient extends StargateClient { const authInfoBytes = makeAuthInfoBytes([pubkey], fee.amount, gasLimit, sequence); const signDoc = makeSignDoc(txBodyBytes, authInfoBytes, chainId, accountNumber); const { signature, signed } = await this.signer.signDirect(signerAddress, signDoc); - const txRaw = TxRaw.fromPartial({ + return TxRaw.fromPartial({ bodyBytes: signed.bodyBytes, authInfoBytes: signed.authInfoBytes, signatures: [fromBase64(signature.signature)], }); - return Uint8Array.from(TxRaw.encode(txRaw).finish()); } }