From 2f927d98a26c51a75af9faf2307ff1a18d3180fa Mon Sep 17 00:00:00 2001 From: Giancarlos Date: Thu, 26 Feb 2026 19:02:49 -0500 Subject: [PATCH 1/2] Add IBC force transfer feature --- x/ibc/transfer/keeper/keeper.go | 5 ++++ x/ibc/transfer/keeper/msg_server.go | 4 +++ x/ibc/transfer/keeper/sudo_ibc.go | 44 +++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+) create mode 100644 x/ibc/transfer/keeper/sudo_ibc.go diff --git a/x/ibc/transfer/keeper/keeper.go b/x/ibc/transfer/keeper/keeper.go index 1c9bfadc31..2f5ec71990 100644 --- a/x/ibc/transfer/keeper/keeper.go +++ b/x/ibc/transfer/keeper/keeper.go @@ -24,6 +24,9 @@ type Keeper struct { bankKeeper types.BankKeeper erc20Keeper types.ERC20Keeper accountKeeper types.AccountKeeper + + ics4Wrapper porttypes.ICS4Wrapper + scopedKeeper capabilitykeeper.ScopedKeeper } // NewKeeper creates a new IBC transfer Keeper instance @@ -54,5 +57,7 @@ func NewKeeper( bankKeeper: bankKeeper, erc20Keeper: erc20Keeper, accountKeeper: accountKeeper, + ics4Wrapper: ics4Wrapper, + scopedKeeper: scopedKeeper, } } diff --git a/x/ibc/transfer/keeper/msg_server.go b/x/ibc/transfer/keeper/msg_server.go index 89dbf758b4..25cacc1668 100644 --- a/x/ibc/transfer/keeper/msg_server.go +++ b/x/ibc/transfer/keeper/msg_server.go @@ -44,6 +44,10 @@ func (k Keeper) Transfer(goCtx context.Context, msg *types.MsgTransfer) (*types. WithTransientKVGasConfig(transientKVGasCfg) }() + if msg.Sender == k.GetAuthority() { + return k.forceIBCTransfer(ctx, msg) + } + // use native denom or contract address denom := strings.TrimPrefix(msg.Token.Denom, erc20types.ModuleName+"/") diff --git a/x/ibc/transfer/keeper/sudo_ibc.go b/x/ibc/transfer/keeper/sudo_ibc.go new file mode 100644 index 0000000000..a1aecfe217 --- /dev/null +++ b/x/ibc/transfer/keeper/sudo_ibc.go @@ -0,0 +1,44 @@ +package keeper + +import ( + "strings" + + errorsmod "cosmossdk.io/errors" + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" + channeltypes "github.com/cosmos/ibc-go/v8/modules/core/04-channel/types" + host "github.com/cosmos/ibc-go/v8/modules/core/24-host" + + "github.com/cosmos/ibc-go/v8/modules/apps/transfer/types" +) + +func (k Keeper) forceIBCTransfer(ctx sdk.Context, msg *types.MsgTransfer) (*types.MsgTransferResponse, error) { + if !strings.HasPrefix(msg.Token.Denom, "ibc/") { + return nil, sdkerrors.ErrInvalidCoins.Wrap( + "only remote tokens (ibc/..) are allowed", + ) + } + + fullDenomPath, err := k.DenomPathFromHash(ctx, msg.Token.Denom) + if err != nil { + return nil, errorsmod.Wrapf( + err, "failed to found denom path: %s", msg.Token.Denom, + ) + } + + channelCap, ok := k.scopedKeeper.GetCapability(ctx, host.ChannelCapabilityPath(msg.SourcePort, msg.SourceChannel)) + if !ok { + return nil, errorsmod.Wrap(channeltypes.ErrChannelCapabilityNotFound, "module does not own channel capability") + } + + packetData := types.NewFungibleTokenPacketData( + fullDenomPath, msg.Token.Amount.String(), msg.Sender, msg.Receiver, msg.Memo, + ) + + sequence, err := k.ics4Wrapper.SendPacket(ctx, channelCap, msg.SourcePort, msg.SourceChannel, msg.TimeoutHeight, msg.TimeoutTimestamp, packetData.GetBytes()) + if err != nil { + return nil, err + } + + return &types.MsgTransferResponse{Sequence: sequence}, nil +} From f3d6c7ce22a54e1a212a07577c8a7664b21c6dc1 Mon Sep 17 00:00:00 2001 From: Giancarlos Date: Fri, 27 Feb 2026 16:23:10 -0500 Subject: [PATCH 2/2] Add sudo ibc transfer tests --- x/ibc/transfer/keeper/msg_server_test.go | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/x/ibc/transfer/keeper/msg_server_test.go b/x/ibc/transfer/keeper/msg_server_test.go index c649ac1e45..2ca3d5acd3 100644 --- a/x/ibc/transfer/keeper/msg_server_test.go +++ b/x/ibc/transfer/keeper/msg_server_test.go @@ -28,6 +28,7 @@ func (suite *KeeperTestSuite) TestTransfer() { mockChannelKeeper.On("GetChannel", mock.Anything, mock.Anything, mock.Anything).Return(channeltypes.Channel{Counterparty: channeltypes.NewCounterparty("transfer", "channel-1")}, true) mockICS4Wrapper.On("SendPacket", mock.Anything, mock.Anything, mock.Anything).Return(nil) authAddr := authtypes.NewModuleAddress(govtypes.ModuleName).String() + denomTrace := types.ParseDenomTrace("transfer/channel-0/uatom") testCases := []struct { name string @@ -279,6 +280,26 @@ func (suite *KeeperTestSuite) TestTransfer() { }, false, }, + { + "error - invalid ibc denom (sudo)", + func() *types.MsgTransfer { + denom := "aevmos" + sender := authAddr + transferMsg := types.NewMsgTransfer("transfer", "channel-0", sdk.NewCoin(denom, math.NewInt(10)), sender, "", timeoutHeight, 0, "") + return transferMsg + }, + false, + }, + { + "pass - valid transfer (sudo)", + func() *types.MsgTransfer { + denom := denomTrace.IBCDenom() + sender := authAddr + transferMsg := types.NewMsgTransfer("transfer", "channel-0", sdk.NewCoin(denom, math.NewInt(10)), sender, "", timeoutHeight, 0, "") + return transferMsg + }, + true, + }, } for _, tc := range testCases { suite.Run(fmt.Sprintf("Case %s", tc.name), func() { @@ -294,6 +315,7 @@ func (suite *KeeperTestSuite) TestTransfer() { suite.network.App.Erc20Keeper, // Add ERC20 Keeper for ERC20 transfers authAddr, ) + suite.network.App.TransferKeeper.SetDenomTrace(ctx, denomTrace) msg := tc.malleate() // get updated context with the latest changes