diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d9ef4b6a..3659f63b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,7 +21,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v4 with: - go-version: 1.18 + go-version: 1.21 - uses: technote-space/get-diff-action@v6.1.0 id: git_diff with: @@ -69,7 +69,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v4 with: - go-version: 1.18 + go-version: 1.21 - uses: technote-space/get-diff-action@v6.1.0 with: PATTERNS: | diff --git a/modules/apps/transfer/ibc_module.go b/modules/apps/transfer/ibc_module.go index a80c5182..80137dd4 100644 --- a/modules/apps/transfer/ibc_module.go +++ b/modules/apps/transfer/ibc_module.go @@ -1,6 +1,7 @@ package transfer import ( + "bytes" "fmt" "math" @@ -218,7 +219,10 @@ func (im IBCModule) OnAcknowledgementPacket( if err := types.ModuleCdc.UnmarshalJSON(packet.GetData(), &data); err != nil { return sdkerrors.Wrapf(sdkerrors.ErrUnknownRequest, "cannot unmarshal ICS-20 transfer packet data: %s", err.Error()) } - + bz := types.ModuleCdc.MustMarshalJSON(&ack) + if !bytes.Equal(bz, acknowledgement) { + return sdkerrors.Wrapf(sdkerrors.ErrInvalidType, "acknowledgement did not marshal to expected bytes: %X ≠ %X", bz, acknowledgement) + } if err := im.keeper.OnAcknowledgementPacket(ctx, packet, data, ack); err != nil { return err } diff --git a/modules/apps/transfer/ibc_module_test.go b/modules/apps/transfer/ibc_module_test.go index 7f53e575..5fb44767 100644 --- a/modules/apps/transfer/ibc_module_test.go +++ b/modules/apps/transfer/ibc_module_test.go @@ -1,8 +1,13 @@ package transfer_test import ( + "errors" "math" + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" + "github.com/cosmos/ibc-go/v3/modules/core/exported" + capabilitytypes "github.com/cosmos/cosmos-sdk/x/capability/types" "github.com/cosmos/ibc-go/v3/modules/apps/transfer/types" @@ -233,3 +238,341 @@ func (suite *TransferTestSuite) TestOnChanOpenAck() { }) } } + +func (suite *TransferTestSuite) TestOnRecvPacket() { + var ( + packet channeltypes.Packet + expectedAttributes []sdk.Attribute + path *ibctesting.Path + packetData types.FungibleTokenPacketData + ) + testCases := []struct { + name string + malleate func() + expAck exported.Acknowledgement + expEventErrorMsg string + }{ + { + "success", + func() { + coin := sdk.NewCoin(sdk.DefaultBondDenom, sdk.NewInt(100)) + packetData = types.NewFungibleTokenPacketData( + coin.Denom, + coin.Amount.String(), + suite.chainA.SenderAccount.GetAddress().String(), + suite.chainB.SenderAccount.GetAddress().String(), + ) + expectedAttributes = []sdk.Attribute{ + sdk.NewAttribute(sdk.AttributeKeyModule, types.ModuleName), + sdk.NewAttribute(sdk.AttributeKeySender, packetData.Sender), + sdk.NewAttribute(types.AttributeKeyReceiver, packetData.Receiver), + sdk.NewAttribute(types.AttributeKeyDenom, packetData.Denom), + sdk.NewAttribute(types.AttributeKeyAmount, packetData.Amount), + sdk.NewAttribute(types.AttributeKeyMemo, ""), + sdk.NewAttribute(types.AttributeKeyAckSuccess, "true"), + } + }, + channeltypes.NewResultAcknowledgement([]byte{byte(1)}), + "", + }, + { + "failure: invalid packet data bytes", + func() { + packet.Data = []byte("invalid data") + expectedAttributes = []sdk.Attribute{ + sdk.NewAttribute(sdk.AttributeKeyModule, types.ModuleName), + sdk.NewAttribute(sdk.AttributeKeySender, ""), + sdk.NewAttribute(types.AttributeKeyReceiver, ""), + sdk.NewAttribute(types.AttributeKeyDenom, ""), + sdk.NewAttribute(types.AttributeKeyAmount, ""), + sdk.NewAttribute(types.AttributeKeyMemo, ""), + sdk.NewAttribute(types.AttributeKeyAckSuccess, "false"), + } + }, + channeltypes.NewErrorAcknowledgement("cannot unmarshal ICS-20 transfer packet data"), + "cannot unmarshal ICS-20 transfer packet data", + }, + { + "failure: receive disabled", + func() { + suite.chainB.GetSimApp().TransferKeeper.SetParams(suite.chainB.GetContext(), types.Params{ReceiveEnabled: false}) + expectedAttributes = []sdk.Attribute{ + sdk.NewAttribute(sdk.AttributeKeyModule, types.ModuleName), + sdk.NewAttribute(sdk.AttributeKeySender, packetData.Sender), + sdk.NewAttribute(types.AttributeKeyReceiver, packetData.Receiver), + sdk.NewAttribute(types.AttributeKeyDenom, packetData.Denom), + sdk.NewAttribute(types.AttributeKeyAmount, packetData.Amount), + sdk.NewAttribute(types.AttributeKeyMemo, ""), + sdk.NewAttribute(types.AttributeKeyAckSuccess, "false"), + } + }, + channeltypes.NewErrorAcknowledgement("ABCI code: 8: error handling packet on destination chain: see events for details"), + "ABCI code: 8: error handling packet on destination chain: see events for details", + }, + } + + for _, tc := range testCases { + suite.Run(tc.name, func() { + suite.SetupTest() // reset + + path = NewTransferPath(suite.chainA, suite.chainB) + suite.coordinator.Setup(path) + + coin := sdk.NewCoin(sdk.DefaultBondDenom, sdk.NewInt(100)) + packetData = types.NewFungibleTokenPacketData( + coin.Denom, + coin.Amount.String(), + suite.chainA.SenderAccount.GetAddress().String(), + suite.chainB.SenderAccount.GetAddress().String(), + ) + + expectedAttributes = []sdk.Attribute{ + sdk.NewAttribute(sdk.AttributeKeyModule, types.ModuleName), + sdk.NewAttribute(sdk.AttributeKeySender, packetData.Sender), + sdk.NewAttribute(types.AttributeKeyReceiver, packetData.Receiver), + sdk.NewAttribute(types.AttributeKeyDenom, packetData.Denom), + sdk.NewAttribute(types.AttributeKeyAmount, packetData.Amount), + sdk.NewAttribute(types.AttributeKeyMemo, packetData.Memo), + } + if tc.expAck == nil || tc.expAck.Success() { + expectedAttributes = append(expectedAttributes, sdk.NewAttribute(types.AttributeKeyAckSuccess, "true")) + } else { + expectedAttributes = append(expectedAttributes, + sdk.NewAttribute(types.AttributeKeyAckSuccess, "false"), + sdk.NewAttribute(types.AttributeKeyAckError, tc.expEventErrorMsg), + ) + } + + seq := uint64(1) + timeout := suite.chainA.GetTimeoutHeight() + packet = channeltypes.NewPacket(packetData.GetBytes(), seq, path.EndpointA.ChannelConfig.PortID, path.EndpointA.ChannelID, path.EndpointB.ChannelConfig.PortID, path.EndpointB.ChannelID, timeout, 0) + + ctx := suite.chainB.GetContext() + cbs, ok := suite.chainB.App.GetIBCKeeper().PortKeeper.Router.GetRoute(ibctesting.TransferPort) + suite.Require().True(ok) + + tc.malleate() // change fields in packet + + ack := cbs.OnRecvPacket(ctx, packet, suite.chainB.SenderAccount.GetAddress()) + + suite.Require().Equal(tc.expAck, ack) + + expectedEvents := sdk.Events{ + sdk.NewEvent( + types.EventTypePacket, + expectedAttributes..., + ), + }.ToABCIEvents() + + ibctesting.AssertEvents(suite.T(), expectedEvents, ctx.EventManager().Events().ToABCIEvents()) + }) + } +} + +func (suite *TransferTestSuite) TestOnAcknowledgePacket() { + var ( + path *ibctesting.Path + packet channeltypes.Packet + ack []byte + ) + + testCases := []struct { + name string + malleate func() + expError error + expRefund bool + }{ + { + "success", + func() {}, + nil, + false, + }, + { + "success: refund coins", + func() { + ack = channeltypes.NewErrorAcknowledgement(types.ErrInvalidAmount.Error()).Acknowledgement() + }, + nil, + true, + }, + { + "cannot refund ack on non-existent channel", + func() { + ack = channeltypes.NewErrorAcknowledgement(types.ErrInvalidAmount.Error()).Acknowledgement() + + packet.SourceChannel = "channel-100" + }, + errors.New("unable to unescrow tokens"), + false, + }, + { + "invalid packet data", + func() { + packet.Data = []byte("invalid data") + }, + sdkerrors.ErrUnknownRequest, + false, + }, + { + "invalid acknowledgement", + func() { + ack = []byte("invalid ack") + }, + sdkerrors.ErrUnknownRequest, + false, + }, + { + "cannot refund already acknowledged packet", + func() { + ack = channeltypes.NewErrorAcknowledgement(sdkerrors.ErrInsufficientFunds.Error()).Acknowledgement() + + cbs, ok := suite.chainA.App.GetIBCKeeper().PortKeeper.Router.GetRoute(ibctesting.TransferPort) + suite.Require().True(ok) + + suite.Require().NoError(cbs.OnAcknowledgementPacket(suite.chainA.GetContext(), packet, ack, suite.chainA.SenderAccount.GetAddress())) + }, + errors.New("unable to unescrow tokens"), + false, + }, + } + + for _, tc := range testCases { + tc := tc + suite.Run(tc.name, func() { + suite.SetupTest() // reset + + path = NewTransferPath(suite.chainA, suite.chainB) + suite.coordinator.Setup(path) + + timeoutHeight := suite.chainA.GetTimeoutHeight() + msg := types.NewMsgTransfer( + path.EndpointA.ChannelConfig.PortID, + path.EndpointA.ChannelID, + ibctesting.TestCoin, + suite.chainA.SenderAccount.GetAddress().String(), + suite.chainB.SenderAccount.GetAddress().String(), + timeoutHeight, + 0, + ) + res, err := suite.chainA.SendMsgs(msg) + suite.Require().NoError(err) // message committed + + packet, err = ibctesting.ParsePacketFromEvents(res.GetEvents()) + suite.Require().NoError(err) + + cbs, ok := suite.chainA.App.GetIBCKeeper().PortKeeper.Router.GetRoute(ibctesting.TransferPort) + suite.Require().True(ok) + + ack = channeltypes.NewResultAcknowledgement([]byte{byte(1)}).Acknowledgement() + + tc.malleate() // change fields in packet + + err = cbs.OnAcknowledgementPacket(suite.chainA.GetContext(), packet, ack, suite.chainA.SenderAccount.GetAddress()) + + if tc.expError == nil { + suite.Require().NoError(err) + + if tc.expRefund { + escrowAddress := types.GetEscrowAddress(packet.GetSourcePort(), packet.GetSourceChannel()) + escrowBalanceAfter := suite.chainA.GetSimApp().BankKeeper.GetBalance(suite.chainA.GetContext(), escrowAddress, sdk.DefaultBondDenom) + suite.Require().Equal(sdk.NewInt(0), escrowBalanceAfter.Amount) + } + } else { + suite.Require().Error(err) + suite.Require().Contains(err.Error(), tc.expError.Error()) + } + }) + } +} + +func (suite *TransferTestSuite) TestOnTimeoutPacket() { + var path *ibctesting.Path + var packet channeltypes.Packet + + testCases := []struct { + name string + coinsToSendToB sdk.Coin + malleate func() + expError error + }{ + { + "success", + ibctesting.TestCoin, + func() {}, + nil, + }, + { + "non-existent channel", + ibctesting.TestCoin, + func() { + packet.SourceChannel = "channel-100" + }, + errors.New("unable to unescrow tokens"), + }, + { + "invalid packet data", + ibctesting.TestCoin, + func() { + packet.Data = []byte("invalid data") + }, + errors.New("cannot unmarshal ICS-20 transfer packet data"), + }, + { + "already timed-out packet", + ibctesting.TestCoin, + func() { + // First timeout the packet + cbs, ok := suite.chainA.App.GetIBCKeeper().PortKeeper.Router.GetRoute(ibctesting.TransferPort) + suite.Require().True(ok) + err := cbs.OnTimeoutPacket(suite.chainA.GetContext(), packet, suite.chainA.SenderAccount.GetAddress()) + suite.Require().NoError(err) + }, + errors.New("unable to unescrow tokens"), + }, + } + + for _, tc := range testCases { + tc := tc + suite.Run(tc.name, func() { + suite.SetupTest() // reset + + path = NewTransferPath(suite.chainA, suite.chainB) + suite.coordinator.Setup(path) + + timeoutHeight := suite.chainA.GetTimeoutHeight() + msg := types.NewMsgTransfer( + path.EndpointA.ChannelConfig.PortID, + path.EndpointA.ChannelID, + tc.coinsToSendToB, + suite.chainA.SenderAccount.GetAddress().String(), + suite.chainB.SenderAccount.GetAddress().String(), + timeoutHeight, + 0, + ) + res, err := suite.chainA.SendMsgs(msg) + suite.Require().NoError(err) // message committed + packet, err = ibctesting.ParsePacketFromEvents(res.GetEvents()) + + suite.Require().NoError(err) + + cbs, ok := suite.chainA.App.GetIBCKeeper().PortKeeper.Router.GetRoute(ibctesting.TransferPort) + suite.Require().True(ok) + + tc.malleate() // change fields in packet + + err = cbs.OnTimeoutPacket(suite.chainA.GetContext(), packet, suite.chainA.SenderAccount.GetAddress()) + + if tc.expError == nil { + suite.Require().NoError(err) + + escrowAddress := types.GetEscrowAddress(packet.GetSourcePort(), packet.GetSourceChannel()) + escrowBalanceAfter := suite.chainA.GetSimApp().BankKeeper.GetBalance(suite.chainA.GetContext(), escrowAddress, sdk.DefaultBondDenom) + suite.Require().Equal(sdk.NewInt(0), escrowBalanceAfter.Amount) + } else { + suite.Require().Error(err) + suite.Require().Contains(err.Error(), tc.expError.Error()) + } + }) + } +} diff --git a/modules/core/04-channel/keeper/packet.go b/modules/core/04-channel/keeper/packet.go index 5883ac64..ed43148a 100644 --- a/modules/core/04-channel/keeper/packet.go +++ b/modules/core/04-channel/keeper/packet.go @@ -458,6 +458,15 @@ func (k Keeper) AcknowledgePacket( packetCommitment := types.CommitPacket(k.cdc, packet) + var ack types.Acknowledgement + err := types.SubModuleCdc.UnmarshalJSON(acknowledgement, &ack) + if err == nil { + ackBz := ack.Acknowledgement() + if !bytes.Equal(ackBz, acknowledgement) { + return sdkerrors.Wrap(types.ErrInvalidAcknowledgement, "acknowledgement marshalling error") + } + } + // verify we sent the packet and haven't cleared it out yet if !bytes.Equal(commitment, packetCommitment) { return sdkerrors.Wrapf(types.ErrInvalidPacket, "commitment bytes are not equal: got (%v), expected (%v)", packetCommitment, commitment) diff --git a/proto/ibc/core/client/v1/client.proto b/proto/ibc/core/client/v1/client.proto index f97263c4..45b4fe4d 100644 --- a/proto/ibc/core/client/v1/client.proto +++ b/proto/ibc/core/client/v1/client.proto @@ -91,9 +91,9 @@ message Height { option (gogoproto.goproto_stringer) = false; // the revision that the client is currently on - uint64 revision_number = 1 [(gogoproto.moretags) = "yaml:\"revision_number\""]; + uint64 revision_number = 1; // the height within the given revision - uint64 revision_height = 2 [(gogoproto.moretags) = "yaml:\"revision_height\""]; + uint64 revision_height = 2; } // Params defines the set of IBC light client parameters. diff --git a/testing/events.go b/testing/events.go index 8beaa0a2..6dfaaece 100644 --- a/testing/events.go +++ b/testing/events.go @@ -2,6 +2,9 @@ package ibctesting import ( "fmt" + "github.com/stretchr/testify/assert" + abci "github.com/tendermint/tendermint/abci/types" + "slices" "strconv" sdk "github.com/cosmos/cosmos-sdk/types" @@ -129,3 +132,71 @@ func ParseAckFromEvents(events sdk.Events) ([]byte, error) { } return nil, fmt.Errorf("acknowledgement event attribute not found") } + +// AssertEvents asserts that expected events are present in the actual events. +func AssertEvents( + t assert.TestingT, + expected []abci.Event, + actual []abci.Event, +) { + foundEvents := make(map[int]bool) + + for i, expectedEvent := range expected { + for _, actualEvent := range actual { + if shouldProcessEvent(expectedEvent, actualEvent) { + attributeMatch := true + for _, expectedAttr := range expectedEvent.Attributes { + // any expected attributes that are not contained in the actual events will cause this event + // not to match + attributeMatch = attributeMatch && containsAttribute(actualEvent.Attributes, string(expectedAttr.Key), string(expectedAttr.Value)) + } + + if attributeMatch { + foundEvents[i] = true + } + } + } + } + + for i, expectedEvent := range expected { + assert.True(t, foundEvents[i], "event: %s was not found in events", expectedEvent.Type) + } +} + +// shouldProcessEvent returns true if the given expected event should be processed based on event type. +func shouldProcessEvent(expectedEvent abci.Event, actualEvent abci.Event) bool { + if expectedEvent.Type != actualEvent.Type { + return false + } + // the actual event will have an extra attribute added automatically + // by Cosmos SDK since v0.50, that's why we subtract 1 when comparing + // with the number of attributes in the expected event. + if containsAttributeKey(actualEvent.Attributes, "msg_index") { + return len(expectedEvent.Attributes) == len(actualEvent.Attributes)-1 + } + + return len(expectedEvent.Attributes) == len(actualEvent.Attributes) +} + +// containsAttribute returns true if the given key/value pair is contained in the given attributes. +// NOTE: this ignores the indexed field, which can be set or unset depending on how the events are retrieved. +func containsAttribute(attrs []abci.EventAttribute, key, value string) bool { + return slices.ContainsFunc(attrs, func(attr abci.EventAttribute) bool { + return string(attr.Key) == key && string(attr.Value) == value + }) +} + +// containsAttributeKey returns true if the given key is contained in the given attributes. +func containsAttributeKey(attrs []abci.EventAttribute, key string) bool { + _, found := attributeByKey(attrs, key) + return found +} + +// attributeByKey returns the event attribute's value keyed by the given key and a boolean indicating its presence in the given attributes. +func attributeByKey(attributes []abci.EventAttribute, key string) (abci.EventAttribute, bool) { + idx := slices.IndexFunc(attributes, func(a abci.EventAttribute) bool { return string(a.Key) == key }) + if idx == -1 { + return abci.EventAttribute{}, false + } + return attributes[idx], true +}