diff --git a/CHANGELOG.md b/CHANGELOG.md index cba6f3a344c..ee907b1e199 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -101,6 +101,7 @@ Ref: https://keepachangelog.com/en/1.0.0/ * (apps/27-interchain-accounts) [\#2290](https://github.com/cosmos/ibc-go/pull/2290) Changed `DefaultParams` function in `host` submodule to allow all messages by default. Defined a constant named `AllowAllHostMsgs` for `host` module to keep wildcard "*" string which allows all messages. * (apps/27-interchain-accounts) [\#2248](https://github.com/cosmos/ibc-go/pull/2248) Adding call to underlying app in `OnChanCloseConfirm` callback of the controller submodule and adding relevant unit tests. * (apps/27-interchain-accounts) [\#2251](https://github.com/cosmos/ibc-go/pull/2251) Adding `msgServer` struct to controller submodule that embeds the `Keeper` struct. +* (apps/27-interchain-accounts) [\#2297](https://github.com/cosmos/ibc-go/pull/2297) Adding cli command to generate ICS27 packet data. ### Features diff --git a/modules/apps/27-interchain-accounts/client/cli/cli.go b/modules/apps/27-interchain-accounts/client/cli/cli.go index 9811c9d1df6..53d6ed0b2f5 100644 --- a/modules/apps/27-interchain-accounts/client/cli/cli.go +++ b/modules/apps/27-interchain-accounts/client/cli/cli.go @@ -37,6 +37,7 @@ func NewTxCmd() *cobra.Command { icaTxCmd.AddCommand( controllercli.NewTxCmd(), + hostcli.NewTxCmd(), ) return icaTxCmd diff --git a/modules/apps/27-interchain-accounts/host/client/cli/cli.go b/modules/apps/27-interchain-accounts/host/client/cli/cli.go index 1eb9b7643b1..23d2dc2e83a 100644 --- a/modules/apps/27-interchain-accounts/host/client/cli/cli.go +++ b/modules/apps/27-interchain-accounts/host/client/cli/cli.go @@ -1,6 +1,7 @@ package cli import ( + "github.com/cosmos/cosmos-sdk/client" "github.com/spf13/cobra" ) @@ -20,3 +21,20 @@ func GetQueryCmd() *cobra.Command { return queryCmd } + +// NewTxCmd creates and returns the tx command +func NewTxCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "host", + Short: "IBC interchain accounts host transaction subcommands", + DisableFlagParsing: true, + SuggestionsMinimumDistance: 2, + RunE: client.ValidateCmd, + } + + cmd.AddCommand( + generatePacketDataCmd(), + ) + + return cmd +} diff --git a/modules/apps/27-interchain-accounts/host/client/cli/tx.go b/modules/apps/27-interchain-accounts/host/client/cli/tx.go new file mode 100644 index 00000000000..b72769bcda1 --- /dev/null +++ b/modules/apps/27-interchain-accounts/host/client/cli/tx.go @@ -0,0 +1,147 @@ +package cli + +import ( + "encoding/json" + "fmt" + + "github.com/cosmos/cosmos-sdk/client" + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/version" + "github.com/spf13/cobra" + + icatypes "github.com/cosmos/ibc-go/v6/modules/apps/27-interchain-accounts/types" +) + +const ( + memoFlag string = "memo" +) + +func generatePacketDataCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "generate-packet-data [message]", + Short: "Generates ICA packet data.", + Long: `generate-packet-data accepts a message string and serializes it +into packet data which is outputted to stdout. It can be used in conjunction with send-tx" +which submits pre-built packet data containing messages to be executed on the host chain. +`, + Example: fmt.Sprintf(`%s tx interchain-accounts host generate-packet-data '{ + "@type":"/cosmos.bank.v1beta1.MsgSend", + "from_address":"cosmos15ccshhmp0gsx29qpqq6g4zmltnnvgmyu9ueuadh9y2nc5zj0szls5gtddz", + "to_address":"cosmos10h9stc5v6ntgeygf5xf945njqq5h32r53uquvw", + "amount": [ + { + "denom": "stake", + "amount": "1000" + } + ] +}' --memo memo + + +%s tx interchain-accounts host generate-packet-data '[{ + "@type":"/cosmos.bank.v1beta1.MsgSend", + "from_address":"cosmos15ccshhmp0gsx29qpqq6g4zmltnnvgmyu9ueuadh9y2nc5zj0szls5gtddz", + "to_address":"cosmos10h9stc5v6ntgeygf5xf945njqq5h32r53uquvw", + "amount": [ + { + "denom": "stake", + "amount": "1000" + } + ] +}, +{ + "@type": "/cosmos.staking.v1beta1.MsgDelegate", + "delegator_address": "cosmos15ccshhmp0gsx29qpqq6g4zmltnnvgmyu9ueuadh9y2nc5zj0szls5gtddz", + "validator_address": "cosmosvaloper1qnk2n4nlkpw9xfqntladh74w6ujtulwnmxnh3k", + "amount": { + "denom": "stake", + "amount": "1000" + } +}]'`, version.AppName, version.AppName), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + clientCtx, err := client.GetClientTxContext(cmd) + if err != nil { + return err + } + + cdc := codec.NewProtoCodec(clientCtx.InterfaceRegistry) + + memo, err := cmd.Flags().GetString(memoFlag) + if err != nil { + return err + } + + packetDataBytes, err := generatePacketData(cdc, []byte(args[0]), memo) + if err != nil { + return err + } + + cmd.Println(string(packetDataBytes)) + + return nil + }, + } + + cmd.Flags().String(memoFlag, "", "an optional memo to be included in the interchain account packet data") + return cmd +} + +// generatePacketData takes in message bytes and a memo and serializes the message into an +// instance of InterchainAccountPacketData which is returned as bytes. +func generatePacketData(cdc *codec.ProtoCodec, msgBytes []byte, memo string) ([]byte, error) { + sdkMessages, err := convertBytesIntoSdkMessages(cdc, msgBytes) + if err != nil { + return nil, err + } + + return generateIcaPacketDataFromSdkMessages(cdc, sdkMessages, memo) +} + +// convertBytesIntoSdkMessages returns a list of sdk messages from bytes. The bytes can be in the form of a single +// message, or a json array of messages. +func convertBytesIntoSdkMessages(cdc *codec.ProtoCodec, msgBytes []byte) ([]sdk.Msg, error) { + var rawMessages []json.RawMessage + if err := json.Unmarshal(msgBytes, &rawMessages); err != nil { + // if we fail to unmarshal a list of messages, we assume we are just dealing with a single message. + // in this case we return a list of a single item. + var msg sdk.Msg + if err := cdc.UnmarshalInterfaceJSON(msgBytes, &msg); err != nil { + return nil, err + } + + return []sdk.Msg{msg}, nil + } + + sdkMessages := make([]sdk.Msg, len(rawMessages)) + for i, anyJSON := range rawMessages { + var msg sdk.Msg + if err := cdc.UnmarshalInterfaceJSON(anyJSON, &msg); err != nil { + return nil, err + } + + sdkMessages[i] = msg + } + + return sdkMessages, nil +} + +// generateIcaPacketDataFromSdkMessages generates ica packet data as bytes from a given set of sdk messages and a memo. +func generateIcaPacketDataFromSdkMessages(cdc *codec.ProtoCodec, sdkMessages []sdk.Msg, memo string) ([]byte, error) { + icaPacketDataBytes, err := icatypes.SerializeCosmosTx(cdc, sdkMessages) + if err != nil { + return nil, err + } + + icaPacketData := icatypes.InterchainAccountPacketData{ + Type: icatypes.EXECUTE_TX, + Data: icaPacketDataBytes, + Memo: memo, + } + + if err := icaPacketData.ValidateBasic(); err != nil { + return nil, err + } + + return cdc.MarshalJSON(&icaPacketData) +} diff --git a/modules/apps/27-interchain-accounts/host/client/cli/tx_test.go b/modules/apps/27-interchain-accounts/host/client/cli/tx_test.go new file mode 100644 index 00000000000..127e89fd784 --- /dev/null +++ b/modules/apps/27-interchain-accounts/host/client/cli/tx_test.go @@ -0,0 +1,155 @@ +package cli + +import ( + "fmt" + "testing" + + "github.com/cosmos/cosmos-sdk/codec" + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + sdk "github.com/cosmos/cosmos-sdk/types" + banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" + stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" + "github.com/stretchr/testify/require" + + icatypes "github.com/cosmos/ibc-go/v6/modules/apps/27-interchain-accounts/types" +) + +const msgDelegateMessage = `{ + "@type": "/cosmos.staking.v1beta1.MsgDelegate", + "delegator_address": "cosmos15ccshhmp0gsx29qpqq6g4zmltnnvgmyu9ueuadh9y2nc5zj0szls5gtddz", + "validator_address": "cosmosvaloper1qnk2n4nlkpw9xfqntladh74w6ujtulwnmxnh3k", + "amount": { + "denom": "stake", + "amount": "1000" + } +}` + +const bankSendMessage = `{ + "@type":"/cosmos.bank.v1beta1.MsgSend", + "from_address":"cosmos15ccshhmp0gsx29qpqq6g4zmltnnvgmyu9ueuadh9y2nc5zj0szls5gtddz", + "to_address":"cosmos10h9stc5v6ntgeygf5xf945njqq5h32r53uquvw", + "amount": [ + { + "denom": "stake", + "amount": "1000" + } + ] +}` + +var multiMsg = fmt.Sprintf("[ %s, %s ]", msgDelegateMessage, bankSendMessage) + +func TestGeneratePacketData(t *testing.T) { + tests := []struct { + name string + memo string + expectedPass bool + message string + registerInterfaceFn func(registry codectypes.InterfaceRegistry) + assertionFn func(t *testing.T, msgs []sdk.Msg) + }{ + { + name: "packet data generation succeeds (MsgDelegate & MsgSend)", + memo: "", + expectedPass: true, + message: multiMsg, + registerInterfaceFn: func(registry codectypes.InterfaceRegistry) { + stakingtypes.RegisterInterfaces(registry) + banktypes.RegisterInterfaces(registry) + }, + assertionFn: func(t *testing.T, msgs []sdk.Msg) { + assertMsgDelegate(t, msgs[0]) + assertMsgBankSend(t, msgs[1]) + }, + }, + { + name: "packet data generation succeeds (MsgDelegate)", + memo: "non-empty-memo", + expectedPass: true, + message: msgDelegateMessage, + registerInterfaceFn: stakingtypes.RegisterInterfaces, + assertionFn: func(t *testing.T, msgs []sdk.Msg) { + assertMsgDelegate(t, msgs[0]) + }, + }, + { + name: "packet data generation succeeds (MsgSend)", + memo: "non-empty-memo", + expectedPass: true, + message: bankSendMessage, + registerInterfaceFn: banktypes.RegisterInterfaces, + assertionFn: func(t *testing.T, msgs []sdk.Msg) { + assertMsgBankSend(t, msgs[0]) + }, + }, + { + name: "empty memo is valid", + memo: "", + expectedPass: true, + message: msgDelegateMessage, + registerInterfaceFn: stakingtypes.RegisterInterfaces, + assertionFn: nil, + }, + { + name: "invalid message string", + expectedPass: false, + message: "", + }, + } + + for _, tc := range tests { + tc := tc + ir := codectypes.NewInterfaceRegistry() + if tc.registerInterfaceFn != nil { + tc.registerInterfaceFn(ir) + } + + cdc := codec.NewProtoCodec(ir) + + t.Run(tc.name, func(t *testing.T) { + bz, err := generatePacketData(cdc, []byte(tc.message), tc.memo) + + if tc.expectedPass { + require.NoError(t, err) + require.NotNil(t, bz) + + packetData := icatypes.InterchainAccountPacketData{} + err = cdc.UnmarshalJSON(bz, &packetData) + require.NoError(t, err) + + require.Equal(t, icatypes.EXECUTE_TX, packetData.Type) + require.Equal(t, tc.memo, packetData.Memo) + + data := packetData.Data + messages, err := icatypes.DeserializeCosmosTx(cdc, data) + + require.NoError(t, err) + require.NotNil(t, messages) + + if tc.assertionFn != nil { + tc.assertionFn(t, messages) + } + } else { + require.Error(t, err) + require.Nil(t, bz) + } + }) + } +} + +func assertMsgBankSend(t *testing.T, msg sdk.Msg) { + bankSendMsg, ok := msg.(*banktypes.MsgSend) + require.True(t, ok) + require.Equal(t, "cosmos15ccshhmp0gsx29qpqq6g4zmltnnvgmyu9ueuadh9y2nc5zj0szls5gtddz", bankSendMsg.FromAddress) + require.Equal(t, "cosmos10h9stc5v6ntgeygf5xf945njqq5h32r53uquvw", bankSendMsg.ToAddress) + require.Equal(t, "stake", bankSendMsg.Amount.GetDenomByIndex(0)) + require.Equal(t, uint64(1000), bankSendMsg.Amount[0].Amount.Uint64()) +} + +func assertMsgDelegate(t *testing.T, msg sdk.Msg) { + msgDelegate, ok := msg.(*stakingtypes.MsgDelegate) + require.True(t, ok) + require.Equal(t, "cosmos15ccshhmp0gsx29qpqq6g4zmltnnvgmyu9ueuadh9y2nc5zj0szls5gtddz", msgDelegate.DelegatorAddress) + require.Equal(t, "cosmosvaloper1qnk2n4nlkpw9xfqntladh74w6ujtulwnmxnh3k", msgDelegate.ValidatorAddress) + require.Equal(t, "stake", msgDelegate.Amount.Denom) + require.Equal(t, uint64(1000), msgDelegate.Amount.Amount.Uint64()) +}