diff --git a/modules/apps/27-interchain-accounts/types/packet.go b/modules/apps/27-interchain-accounts/types/packet.go index f7d1c6648be..71551671c3c 100644 --- a/modules/apps/27-interchain-accounts/types/packet.go +++ b/modules/apps/27-interchain-accounts/types/packet.go @@ -1,11 +1,13 @@ package types import ( + "encoding/json" "time" errorsmod "cosmossdk.io/errors" codectypes "github.com/cosmos/cosmos-sdk/codec/types" sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/ibc-go/v7/modules/core/exported" ) // MaxMemoCharLength defines the maximum length for the InterchainAccountPacketData memo field @@ -24,6 +26,8 @@ var ( DefaultRelativePacketTimeoutTimestamp = uint64((time.Duration(10) * time.Minute).Nanoseconds()) ) +var _ exported.CallbackPacketData = (*InterchainAccountPacketData)(nil) + // ValidateBasic performs basic validation of the interchain account packet data. // The memo may be empty. func (iapd InterchainAccountPacketData) ValidateBasic() error { @@ -47,6 +51,77 @@ func (iapd InterchainAccountPacketData) GetBytes() []byte { return sdk.MustSortJSON(ModuleCdc.MustMarshalJSON(&iapd)) } +/* + +ADR-8 CallbackPacketData implementation + +InterchainAccountPacketData implements CallbackPacketData interface. This will allow middlewares targeting specific VMs +to retrieve the desired callback address for the ICA packet on the source chain. Destination callback addresses are not +supported for ICS 27. + +The Memo is used to set the desired callback addresses. + +The Memo format is defined like so: + +```json +{ + // ... other memo fields we don't care about + "callbacks": { + "src_callback_address": {contractAddrOnSourceChain}, + + // optional fields + "src_callback_msg": {jsonObjectForSourceChainCallback}, + } +} +``` + +*/ + +// GetSourceCallbackAddress returns the source callback address provided in the packet data memo. +// If no callback address is specified, an empty string is returned. +// +// The memo is expected to specify the callback address in the following format: +// { "callbacks": { "src_callback_address": {contractAddrOnSourceChain}} +// +// ADR-8 middleware should callback on the returned address if it is a PacketActor +// (i.e. smart contract that accepts IBC callbacks). +func (iapd InterchainAccountPacketData) GetSourceCallbackAddress() string { + if len(iapd.Memo) == 0 { + return "" + } + + jsonObject := make(map[string]interface{}) + err := json.Unmarshal([]byte(iapd.Memo), &jsonObject) + if err != nil { + return "" + } + + callbackData, ok := jsonObject["callbacks"].(map[string]interface{}) + if !ok { + return "" + } + + callbackAddr, ok := callbackData["src_callback_address"].(string) + if !ok { + return "" + } + + return callbackAddr +} + +// GetDestCallbackAddress returns an empty string. Destination callback addresses +// are not supported for ICS 27. This feature is natively supported by +// interchain accounts host submodule transaction execution. +func (iapd InterchainAccountPacketData) GetDestCallbackAddress() string { + return "" +} + +// UserDefinedGasLimit returns 0 (no-op). The gas limit of the executing +// transaction will be used. +func (iapd InterchainAccountPacketData) UserDefinedGasLimit() uint64 { + return 0 +} + // GetBytes returns the JSON marshalled interchain account CosmosTx. func (ct CosmosTx) GetBytes() []byte { return sdk.MustSortJSON(ModuleCdc.MustMarshalJSON(&ct)) diff --git a/modules/apps/27-interchain-accounts/types/packet_test.go b/modules/apps/27-interchain-accounts/types/packet_test.go index 329e5a837f4..5c2f000fd8e 100644 --- a/modules/apps/27-interchain-accounts/types/packet_test.go +++ b/modules/apps/27-interchain-accounts/types/packet_test.go @@ -1,6 +1,8 @@ package types_test import ( + "fmt" + "github.com/cosmos/ibc-go/v7/modules/apps/27-interchain-accounts/types" ) @@ -82,3 +84,123 @@ func (suite *TypesTestSuite) TestValidateBasic() { }) } } + +func (suite *TypesTestSuite) TestGetSourceCallbackAddress() { + const expSrcCbAddr = "srcCbAddr" + + testCases := []struct { + name string + packetData types.InterchainAccountPacketData + expPass bool + }{ + { + "memo is empty", + types.InterchainAccountPacketData{ + Type: types.EXECUTE_TX, + Data: []byte("data"), + Memo: "", + }, + false, + }, + { + "memo is not json string", + types.InterchainAccountPacketData{ + Type: types.EXECUTE_TX, + Data: []byte("data"), + Memo: "memo", + }, + false, + }, + { + "memo does not have callbacks in json struct", + types.InterchainAccountPacketData{ + Type: types.EXECUTE_TX, + Data: []byte("data"), + Memo: `{"Key": 10}`, + }, + false, + }, + { + "memo has callbacks in json struct but does not have src_callback_address key", + types.InterchainAccountPacketData{ + Type: types.EXECUTE_TX, + Data: []byte("data"), + Memo: `{"callbacks": {"Key": 10}}`, + }, + false, + }, + { + "memo has callbacks in json struct but does not have string value for src_callback_address key", + types.InterchainAccountPacketData{ + Type: types.EXECUTE_TX, + Data: []byte("data"), + Memo: `{"callbacks": {"src_callback_address": 10}}`, + }, + false, + }, + { + "memo has callbacks in json struct and properly formatted src_callback_address", + types.InterchainAccountPacketData{ + Type: types.EXECUTE_TX, + Data: []byte("data"), + Memo: fmt.Sprintf(`{"callbacks": {"src_callback_address": "%s"}}`, expSrcCbAddr), + }, + true, + }, + } + + for _, tc := range testCases { + tc := tc + suite.Run(tc.name, func() { + srcCbAddr := tc.packetData.GetSourceCallbackAddress() + + if tc.expPass { + suite.Require().Equal(expSrcCbAddr, srcCbAddr) + } else { + suite.Require().Equal("", srcCbAddr) + } + }) + } +} + +func (suite *TypesTestSuite) TestGetDestCallbackAddress() { + testCases := []struct { + name string + packetData types.InterchainAccountPacketData + }{ + { + "memo is empty", + types.InterchainAccountPacketData{ + Type: types.EXECUTE_TX, + Data: []byte("data"), + Memo: "", + }, + }, + { + "memo has dest callback address specified in json struct", + types.InterchainAccountPacketData{ + Type: types.EXECUTE_TX, + Data: []byte("data"), + Memo: `{"callbacks": {"dest_callback_address": "testAddress"}}`, + }, + }, + } + + for _, tc := range testCases { + tc := tc + suite.Run(tc.name, func() { + destCbAddr := tc.packetData.GetDestCallbackAddress() + suite.Require().Equal("", destCbAddr) + }) + } +} + +func (suite *TypesTestSuite) TestUserDefinedGasLimit() { + packetData := types.InterchainAccountPacketData{ + Type: types.EXECUTE_TX, + Data: []byte("data"), + Memo: `{"callbacks": {"user_defined_gas_limit": 100}}`, + } + + suite.Require().Equal(uint64(0), packetData.UserDefinedGasLimit(), "user defined gas limit does not return 0") +} diff --git a/modules/apps/transfer/types/codec.go b/modules/apps/transfer/types/codec.go index 92ed91ef58a..9692acdbae6 100644 --- a/modules/apps/transfer/types/codec.go +++ b/modules/apps/transfer/types/codec.go @@ -3,14 +3,13 @@ package types import ( "bytes" - "github.com/cosmos/cosmos-sdk/x/authz" - "github.com/cosmos/gogoproto/jsonpb" - "github.com/cosmos/gogoproto/proto" - "github.com/cosmos/cosmos-sdk/codec" codectypes "github.com/cosmos/cosmos-sdk/codec/types" sdk "github.com/cosmos/cosmos-sdk/types" "github.com/cosmos/cosmos-sdk/types/msgservice" + "github.com/cosmos/cosmos-sdk/x/authz" + "github.com/cosmos/gogoproto/jsonpb" + "github.com/cosmos/gogoproto/proto" ) // RegisterLegacyAminoCodec registers the necessary x/ibc transfer interfaces and concrete types diff --git a/modules/apps/transfer/types/packet.go b/modules/apps/transfer/types/packet.go index ec795fa2482..20080e623f8 100644 --- a/modules/apps/transfer/types/packet.go +++ b/modules/apps/transfer/types/packet.go @@ -1,6 +1,7 @@ package types import ( + "encoding/json" "strings" "time" @@ -8,6 +9,7 @@ import ( sdk "github.com/cosmos/cosmos-sdk/types" ibcerrors "github.com/cosmos/ibc-go/v7/internal/errors" + "github.com/cosmos/ibc-go/v7/modules/core/exported" ) var ( @@ -23,6 +25,8 @@ var ( DefaultRelativePacketTimeoutTimestamp = uint64((time.Duration(10) * time.Minute).Nanoseconds()) ) +var _ exported.CallbackPacketData = (*FungibleTokenPacketData)(nil) + // NewFungibleTokenPacketData contructs a new FungibleTokenPacketData instance func NewFungibleTokenPacketData( denom string, amount string, @@ -62,3 +66,109 @@ func (ftpd FungibleTokenPacketData) ValidateBasic() error { func (ftpd FungibleTokenPacketData) GetBytes() []byte { return sdk.MustSortJSON(mustProtoMarshalJSON(&ftpd)) } + +/* + +ADR-8 CallbackPacketData implementation + +FungibleTokenPacketData implements CallbackPacketData interface. This will allow middlewares targeting specific VMs +to retrieve the desired callback addresses for the ICS20 packet on the source and destination chains. + +The Memo is used to ensure that the callback is desired by the user. This allows a user to send an ICS20 packet +to a contract with ADR-8 enabled without automatically triggering the callback logic which may lead to unexpected +behaviour. + +The Memo format is defined like so: + +```json +{ + // ... other memo fields we don't care about + "callbacks": { + "src_callback_address": {contractAddrOnSourceChain}, + "dest_callback_address": {contractAddrOnDestChain}, + + // optional fields + "src_callback_msg": {jsonObjectForSourceChainCallback}, + "dest_callback_msg": {jsonObjectForDestChainCallback}, + } +} +``` + +For transfer, we will enforce that the src_callback_address is the same as sender and dest_callback_address is the same as receiver. +However, we may remove this restriction at a later date if it proves useful. + +*/ + +// GetSourceCallbackAddress returns the sender address if it is also specified in +// the packet data memo. The desired callback address must be confirmed in the +// memo under the "callbacks" key. This ensures that the callback is explicitly +// desired by the user and not called automatically. If no callback address is +// specified, an empty string is returned. +// +// The memo is expected to contain the source callback address in the following format: +// { "callbacks": { "src_callback_address": {contractAddrOnSourceChain}} +// +// ADR-8 middleware should callback on the returned address if it is a PacketActor +// (i.e. smart contract that accepts IBC callbacks). +func (ftpd FungibleTokenPacketData) GetSourceCallbackAddress() string { + if len(ftpd.Memo) == 0 { + return "" + } + + jsonObject := make(map[string]interface{}) + err := json.Unmarshal([]byte(ftpd.Memo), &jsonObject) + if err != nil { + return "" + } + + callbackData, ok := jsonObject["callbacks"].(map[string]interface{}) + if !ok { + return "" + } + + if callbackData["src_callback_address"] == ftpd.Sender { + return ftpd.Sender + } + + return "" +} + +// GetDestCallbackAddress returns the receiving address if it is also specified in +// the packet data memo. The desired callback address must be confirmed in the +// memo under the "callbacks" key. This ensures that the callback is explicitly +// desired by the user and not called automatically. If no callback address is +// specified, an empty string is returned. +// +// The memo is expected to contain the destination callback address in the following format: +// { "callbacks": { "dest_callback_address": {contractAddrOnDestChain}} +// +// ADR-8 middleware should callback on the returned address if it is a PacketActor +// (i.e. smart contract that accepts IBC callbacks). +func (ftpd FungibleTokenPacketData) GetDestCallbackAddress() string { + if len(ftpd.Memo) == 0 { + return "" + } + + jsonObject := make(map[string]interface{}) + err := json.Unmarshal([]byte(ftpd.Memo), &jsonObject) + if err != nil { + return "" + } + + callbackData, ok := jsonObject["callbacks"].(map[string]interface{}) + if !ok { + return "" + } + + if callbackData["dest_callback_address"] == ftpd.Receiver { + return ftpd.Receiver + } + + return "" +} + +// UserDefinedGasLimit returns 0 (no-op). The gas limit of the executing +// transaction will be used. +func (ftpd FungibleTokenPacketData) UserDefinedGasLimit() uint64 { + return 0 +} diff --git a/modules/apps/transfer/types/packet_test.go b/modules/apps/transfer/types/packet_test.go index 6c877d4fcb0..05ee8ec74a2 100644 --- a/modules/apps/transfer/types/packet_test.go +++ b/modules/apps/transfer/types/packet_test.go @@ -1,6 +1,7 @@ package types_test import ( + "fmt" "testing" "github.com/stretchr/testify/require" @@ -43,3 +44,213 @@ func TestFungibleTokenPacketDataValidateBasic(t *testing.T) { } } } + +func (suite *TypesTestSuite) TestGetSourceCallbackAddress() { + testCases := []struct { + name string + packetData types.FungibleTokenPacketData + expPass bool + }{ + { + "memo is empty", + types.FungibleTokenPacketData{ + Denom: denom, + Amount: amount, + Sender: sender, + Receiver: receiver, + Memo: "", + }, + false, + }, + { + "memo is not json string", + types.FungibleTokenPacketData{ + Denom: denom, + Amount: amount, + Sender: sender, + Receiver: receiver, + Memo: "memo", + }, + false, + }, + { + "memo does not have callbacks in json struct", + types.FungibleTokenPacketData{ + Denom: denom, + Amount: amount, + Sender: sender, + Receiver: receiver, + Memo: `{"Key": 10}`, + }, + false, + }, + { + "memo has callbacks in json struct but does not have src_callback_address key", + types.FungibleTokenPacketData{ + Denom: denom, + Amount: amount, + Sender: sender, + Receiver: receiver, + Memo: `{"callbacks": {"Key": 10}}`, + }, + false, + }, + { + "memo has callbacks in json struct but does not have string value for src_callback_address key", + types.FungibleTokenPacketData{ + Denom: denom, + Amount: amount, + Sender: sender, + Receiver: receiver, + Memo: `{"callbacks": {"src_callback_address": 10}}`, + }, + false, + }, + { + "memo has callbacks in json struct and properly formatted src_callback_address which does not match packet sender", + types.FungibleTokenPacketData{ + Denom: denom, + Amount: amount, + Sender: sender, + Receiver: receiver, + Memo: `{"callbacks": {"src_callback_address": "testAddress"}}`, + }, + false, + }, + { + "valid src_callback_address specified in memo that matches sender", + types.FungibleTokenPacketData{ + Denom: denom, + Amount: amount, + Sender: sender, + Receiver: receiver, + Memo: fmt.Sprintf(`{"callbacks": {"src_callback_address": "%s"}}`, sender), + }, + true, + }, + } + + for _, tc := range testCases { + tc := tc + suite.Run(tc.name, func() { + srcCbAddr := tc.packetData.GetSourceCallbackAddress() + + if tc.expPass { + suite.Require().Equal(sender, srcCbAddr) + } else { + suite.Require().Equal("", srcCbAddr) + } + }) + } +} + +func (suite *TypesTestSuite) TestGetDestCallbackAddress() { + testCases := []struct { + name string + packetData types.FungibleTokenPacketData + expPass bool + }{ + { + "memo is empty", + types.FungibleTokenPacketData{ + Denom: denom, + Amount: amount, + Sender: sender, + Receiver: receiver, + Memo: "", + }, + false, + }, + { + "memo is not json string", + types.FungibleTokenPacketData{ + Denom: denom, + Amount: amount, + Sender: sender, + Receiver: receiver, + Memo: "memo", + }, + false, + }, + { + "memo does not have callbacks in json struct", + types.FungibleTokenPacketData{ + Denom: denom, + Amount: amount, + Sender: sender, + Receiver: receiver, + Memo: `{"Key": 10}`, + }, + false, + }, + { + "memo has callbacks in json struct but does not have dest_callback_address key", + types.FungibleTokenPacketData{ + Denom: denom, + Amount: amount, + Sender: sender, + Receiver: receiver, + Memo: `{"callbacks": {"Key": 10}}`, + }, + false, + }, + { + "memo has callbacks in json struct but does not have string value for dest_callback_address key", + types.FungibleTokenPacketData{ + Denom: denom, + Amount: amount, + Sender: sender, + Receiver: receiver, + Memo: `{"callbacks": {"dest_callback_address": 10}}`, + }, + false, + }, + { + "memo has callbacks in json struct and properly formatted dest_callback_address which does not match packet sender", + types.FungibleTokenPacketData{ + Denom: denom, + Amount: amount, + Sender: sender, + Receiver: receiver, + Memo: `{"callbacks": {"dest_callback_address": "testAddress"}}`, + }, + false, + }, + { + "valid dest_callback_address specified in memo that matches sender", + types.FungibleTokenPacketData{ + Denom: denom, + Amount: amount, + Sender: sender, + Receiver: receiver, + Memo: fmt.Sprintf(`{"callbacks": {"dest_callback_address": "%s"}}`, receiver), + }, + true, + }, + } + + for _, tc := range testCases { + tc := tc + suite.Run(tc.name, func() { + destCbAddr := tc.packetData.GetDestCallbackAddress() + + if tc.expPass { + suite.Require().Equal(receiver, destCbAddr) + } else { + suite.Require().Equal("", destCbAddr) + } + }) + } +} + +func (suite *TypesTestSuite) TestUserDefinedGasLimit() { + packetData := types.FungibleTokenPacketData{ + Denom: denom, + Amount: amount, + Sender: sender, + Receiver: receiver, + Memo: `{"callbacks": {"user_defined_gas_limit": 100}}`, + } + + suite.Require().Equal(uint64(0), packetData.UserDefinedGasLimit(), "user defined gas limit does not return 0") +} diff --git a/modules/core/exported/channel.go b/modules/core/exported/channel.go index f6393393513..a6ec511880b 100644 --- a/modules/core/exported/channel.go +++ b/modules/core/exported/channel.go @@ -17,23 +17,3 @@ type CounterpartyChannelI interface { GetChannelID() string ValidateBasic() error } - -// PacketI defines the standard interface for IBC packets -type PacketI interface { - GetSequence() uint64 - GetTimeoutHeight() Height - GetTimeoutTimestamp() uint64 - GetSourcePort() string - GetSourceChannel() string - GetDestPort() string - GetDestChannel() string - GetData() []byte - ValidateBasic() error -} - -// Acknowledgement defines the interface used to return -// acknowledgements in the OnRecvPacket callback. -type Acknowledgement interface { - Success() bool - Acknowledgement() []byte -} diff --git a/modules/core/exported/packet.go b/modules/core/exported/packet.go new file mode 100644 index 00000000000..39c9dc3fdb6 --- /dev/null +++ b/modules/core/exported/packet.go @@ -0,0 +1,48 @@ +package exported + +// PacketI defines the standard interface for IBC packets +type PacketI interface { + GetSequence() uint64 + GetTimeoutHeight() Height + GetTimeoutTimestamp() uint64 + GetSourcePort() string + GetSourceChannel() string + GetDestPort() string + GetDestChannel() string + GetData() []byte + ValidateBasic() error +} + +// Acknowledgement defines the interface used to return +// acknowledgements in the OnRecvPacket callback. +type Acknowledgement interface { + Success() bool + Acknowledgement() []byte +} + +// CallbackPacketData defines the interface used by ADR 008 implementations +// to obtain callback addresses associated with a specific packet data type. +// This is an optional interface which indicates support for ADR 8 implementations. +// See https://github.com/cosmos/ibc-go/tree/main/docs/architecture/adr-008-app-caller-cbs +// for more information. +type CallbackPacketData interface { + // GetSourceCallbackAddress should return the callback address of a packet data on the source chain. + // This may or may not be the sender of the packet. If no source callback address exists for the packet, + // an empty string may be returned. + GetSourceCallbackAddress() string + + // GetDestCallbackAddress should return the callback address of a packet data on the destination chain. + // This may or may not be the receiver of the packet. If no dest callback address exists for the packet, + // an empty string may be returned. + GetDestCallbackAddress() string + + // UserDefinedGasLimit allows the sender of the packet to define inside the packet data + // a gas limit for how much the ADR-8 callbacks can consume. If defined, this will be passed + // in as the gas limit so that the callback is guaranteed to complete within a specific limit. + // On recvPacket, a gas-overflow will just fail the transaction allowing it to timeout on the sender side. + // On ackPacket and timeoutPacket, a gas-overflow will reject state changes made during callback but still + // commit the transaction. This ensures the packet lifecycle can always complete. + // If the packet data returns 0, the remaining gas limit will be passed in (modulo any chain-defined limit) + // Otherwise, we will set the gas limit passed into the callback to the `min(ctx.GasLimit, UserDefinedGasLimit())` + UserDefinedGasLimit() uint64 +}