diff --git a/app/apptesting/test_helpers.go b/app/apptesting/test_helpers.go index 8d9ce9b54..9aa953cbb 100644 --- a/app/apptesting/test_helpers.go +++ b/app/apptesting/test_helpers.go @@ -19,7 +19,6 @@ import ( upgradetypes "github.com/cosmos/cosmos-sdk/x/upgrade/types" "github.com/cosmos/gogoproto/proto" icatypes "github.com/cosmos/ibc-go/v7/modules/apps/27-interchain-accounts/types" - ibctransfertypes "github.com/cosmos/ibc-go/v7/modules/apps/transfer/types" transfertypes "github.com/cosmos/ibc-go/v7/modules/apps/transfer/types" clienttypes "github.com/cosmos/ibc-go/v7/modules/core/02-client/types" connectiontypes "github.com/cosmos/ibc-go/v7/modules/core/03-connection/types" @@ -469,7 +468,7 @@ func (s *AppTestHelper) CreateAndStoreIBCDenom(baseDenom string) (ibcDenom strin } func (s *AppTestHelper) MarshalledICS20PacketData() sdk.AccAddress { - data := ibctransfertypes.FungibleTokenPacketData{} + data := transfertypes.FungibleTokenPacketData{} return data.GetBytes() } diff --git a/x/autopilot/keeper/fallback.go b/x/autopilot/keeper/fallback.go new file mode 100644 index 000000000..8ef3d5967 --- /dev/null +++ b/x/autopilot/keeper/fallback.go @@ -0,0 +1,39 @@ +package keeper + +import ( + "github.com/cosmos/cosmos-sdk/store/prefix" + sdk "github.com/cosmos/cosmos-sdk/types" + + "github.com/Stride-Labs/stride/v16/x/autopilot/types" +) + +// Stores a fallback address for an outbound transfer +func (k Keeper) SetTransferFallbackAddress(ctx sdk.Context, channelId string, sequence uint64, address string) { + store := prefix.NewStore(ctx.KVStore(k.storeKey), types.TransferFallbackAddressPrefix) + key := types.GetTransferFallbackAddressKey(channelId, sequence) + value := []byte(address) + store.Set(key, value) +} + +// Removes a fallback address from the store +// This is used after the ack or timeout for a packet has been received +func (k Keeper) RemoveTransferFallbackAddress(ctx sdk.Context, channelId string, sequence uint64) { + store := prefix.NewStore(ctx.KVStore(k.storeKey), types.TransferFallbackAddressPrefix) + key := types.GetTransferFallbackAddressKey(channelId, sequence) + store.Delete(key) +} + +// Returns a fallback address, given the channel ID and sequence number of the packet +// If no fallback address has been stored, return false +func (k Keeper) GetTransferFallbackAddress(ctx sdk.Context, channelId string, sequence uint64) (address string, found bool) { + store := prefix.NewStore(ctx.KVStore(k.storeKey), types.TransferFallbackAddressPrefix) + + key := types.GetTransferFallbackAddressKey(channelId, sequence) + valueBz := store.Get(key) + + if len(valueBz) == 0 { + return "", false + } + + return string(valueBz), true +} diff --git a/x/autopilot/keeper/fallback_test.go b/x/autopilot/keeper/fallback_test.go new file mode 100644 index 000000000..a70c5900f --- /dev/null +++ b/x/autopilot/keeper/fallback_test.go @@ -0,0 +1,22 @@ +package keeper_test + +// Tests Get/Set/RemoveTransferFallbackAddress +func (s *KeeperTestSuite) TestTransferFallbackAddress() { + channelId := "channel-0" + sequence := uint64(100) + expectedAddress := "stride1xjp08gxef09fck6yj2lg0vrgpcjhqhp055ffhj" + + // Add a new fallback address + s.App.AutopilotKeeper.SetTransferFallbackAddress(s.Ctx, channelId, sequence, expectedAddress) + + // Confirm we can retrieve it + actualAddress, found := s.App.AutopilotKeeper.GetTransferFallbackAddress(s.Ctx, channelId, sequence) + s.Require().True(found, "address should have been found") + s.Require().Equal(expectedAddress, actualAddress, "fallback addres") + + // Remove it and confirm we can no longer retrieve it + s.App.AutopilotKeeper.RemoveTransferFallbackAddress(s.Ctx, channelId, sequence) + + _, found = s.App.AutopilotKeeper.GetTransferFallbackAddress(s.Ctx, channelId, sequence) + s.Require().False(found, "address should have been removed") +} diff --git a/x/autopilot/keeper/ibc.go b/x/autopilot/keeper/ibc.go new file mode 100644 index 000000000..159cb5b77 --- /dev/null +++ b/x/autopilot/keeper/ibc.go @@ -0,0 +1,97 @@ +package keeper + +import ( + "fmt" + + errorsmod "cosmossdk.io/errors" + sdkmath "cosmossdk.io/math" + sdk "github.com/cosmos/cosmos-sdk/types" + transfertypes "github.com/cosmos/ibc-go/v7/modules/apps/transfer/types" + channeltypes "github.com/cosmos/ibc-go/v7/modules/core/04-channel/types" + + "github.com/Stride-Labs/stride/v16/x/icacallbacks" + icacallbacktypes "github.com/Stride-Labs/stride/v16/x/icacallbacks/types" +) + +// In the event of an ack error after a outbound transfer, we'll have to bank send to a fallback address +func (k Keeper) SendToFallbackAddress(ctx sdk.Context, packetData []byte, fallbackAddress string) error { + // First unmarshal the transfer metadata to get the sender/reciever, and token amount/denom + var transferMetadata transfertypes.FungibleTokenPacketData + if err := transfertypes.ModuleCdc.UnmarshalJSON(packetData, &transferMetadata); err != nil { + return err + } + + // Pull out the original sender of the transfer which will also be the bank sender + sender := transferMetadata.Sender + senderAccount, err := sdk.AccAddressFromBech32(sender) + if err != nil { + return errorsmod.Wrapf(err, "invalid sender address") + } + fallbackAccount, err := sdk.AccAddressFromBech32(fallbackAddress) + if err != nil { + return errorsmod.Wrapf(err, "invalid fallback address") + } + + // Build the token from the transfer metadata + amount, ok := sdkmath.NewIntFromString(transferMetadata.Amount) + if !ok { + return fmt.Errorf("unable to parse amount from transfer packet: %v", transferMetadata) + } + token := sdk.NewCoin(transferMetadata.Denom, amount) + + // Finally send to the fallback account + if err := k.bankKeeper.SendCoins(ctx, senderAccount, fallbackAccount, sdk.NewCoins(token)); err != nil { + return err + } + + return nil +} + +// If there was a timeout or failed ack from an outbound transfer of one of the autopilot actions, +// we'll need to check if there was a fallback address. If one was stored, bank send to that address +// If the ack was successful, we should delete the address (if it exists) +func (k Keeper) HandleFallbackAddress(ctx sdk.Context, packet channeltypes.Packet, acknowledgement []byte, packetTimedOut bool) error { + // Retrieve the fallback address for the given packet + // We use the packet source channel here since this will correspond with the channel on Stride + channelId := packet.SourceChannel + sequence := packet.Sequence + fallbackAddress, fallbackAddressFound := k.GetTransferFallbackAddress(ctx, channelId, sequence) + + // If there was no fallback address, there's nothing else to do + if !fallbackAddressFound { + return nil + } + + // Remove the fallback address since the packet is no longer pending + k.RemoveTransferFallbackAddress(ctx, channelId, sequence) + + // If the packet timed out, send to the fallback address + if packetTimedOut { + return k.SendToFallbackAddress(ctx, packet.Data, fallbackAddress) + } + + // If the packet did not timeout, check whether the ack was successful or was an ack error + isICATx := false + ackResponse, err := icacallbacks.UnpackAcknowledgementResponse(ctx, k.Logger(ctx), acknowledgement, isICATx) + if err != nil { + return err + } + + // If successful, no additional action is necessary + if ackResponse.Status == icacallbacktypes.AckResponseStatus_SUCCESS { + return nil + } + + // If there was an ack error, we'll need to bank send to the fallback address + return k.SendToFallbackAddress(ctx, packet.Data, fallbackAddress) +} + +// OnTimeoutPacket should always send to the fallback address +func (k Keeper) OnTimeoutPacket(ctx sdk.Context, packet channeltypes.Packet) error { + return k.HandleFallbackAddress(ctx, packet, []byte{}, true) +} + +// OnAcknowledgementPacket should send to the fallback address if the ack is an ack error +func (k Keeper) OnAcknowledgementPacket(ctx sdk.Context, packet channeltypes.Packet, acknowledgement []byte) error { + return k.HandleFallbackAddress(ctx, packet, acknowledgement, false) +} diff --git a/x/autopilot/keeper/ibc_test.go b/x/autopilot/keeper/ibc_test.go new file mode 100644 index 000000000..408a51980 --- /dev/null +++ b/x/autopilot/keeper/ibc_test.go @@ -0,0 +1,240 @@ +package keeper_test + +import ( + sdkmath "cosmossdk.io/math" + sdk "github.com/cosmos/cosmos-sdk/types" + transfertypes "github.com/cosmos/ibc-go/v7/modules/apps/transfer/types" + channeltypes "github.com/cosmos/ibc-go/v7/modules/core/04-channel/types" +) + +type PacketCallbackTestCase struct { + ChannelId string + OriginalSequence uint64 + RetrySequence uint64 + Token sdk.Coin + Packet channeltypes.Packet + SenderAccount sdk.AccAddress + FallbackAccount sdk.AccAddress +} + +func (s *KeeperTestSuite) SetupTestHandleFallbackPacket() PacketCallbackTestCase { + senderAccount := s.TestAccs[0] + fallbackAccount := s.TestAccs[1] + + sequence := uint64(1) + channelId := "channel-0" + denom := "denom" + amount := sdk.NewInt(10000) + token := sdk.NewCoin(denom, amount) + + // Set a fallback addresses + s.App.AutopilotKeeper.SetTransferFallbackAddress(s.Ctx, channelId, sequence, fallbackAccount.String()) + + // Fund the sender account + s.FundAccount(senderAccount, token) + + // Build the IBC packet + transferMetadata := transfertypes.FungibleTokenPacketData{ + Denom: "denom", + Amount: amount.String(), + Sender: senderAccount.String(), + } + packet := channeltypes.Packet{ + Sequence: sequence, + SourceChannel: channelId, + Data: transfertypes.ModuleCdc.MustMarshalJSON(&transferMetadata), + } + + return PacketCallbackTestCase{ + ChannelId: channelId, + OriginalSequence: sequence, + Token: token, + Packet: packet, + SenderAccount: senderAccount, + FallbackAccount: fallbackAccount, + } +} + +// -------------------------------------------------------------- +// IBC Callback Helpers +// -------------------------------------------------------------- + +func (s *KeeperTestSuite) TestSendToFallbackAddress() { + senderAccount := s.TestAccs[0] + fallbackAccount := s.TestAccs[1] + + denom := "denom" + amount := sdk.NewInt(10000) + + // Fund the sender + zeroCoin := sdk.NewCoin(denom, sdkmath.ZeroInt()) + balanceCoin := sdk.NewCoin(denom, amount) + s.FundAccount(senderAccount, balanceCoin) + + // Send to the fallback address with a valid input + packetDataBz := transfertypes.ModuleCdc.MustMarshalJSON(&transfertypes.FungibleTokenPacketData{ + Denom: denom, + Amount: amount.String(), + Sender: senderAccount.String(), + }) + err := s.App.AutopilotKeeper.SendToFallbackAddress(s.Ctx, packetDataBz, fallbackAccount.String()) + s.Require().NoError(err, "no error expected when sending to fallback address") + + // Check that the funds were transferred + senderBalance := s.App.BankKeeper.GetBalance(s.Ctx, senderAccount, denom) + s.CompareCoins(zeroCoin, senderBalance, "sender should have lost tokens") + + fallbackBalance := s.App.BankKeeper.GetBalance(s.Ctx, fallbackAccount, denom) + s.CompareCoins(balanceCoin, fallbackBalance, "fallback should have gained tokens") + + // Test with an invalid sender address - it should error + invalidPacketDataBz := transfertypes.ModuleCdc.MustMarshalJSON(&transfertypes.FungibleTokenPacketData{ + Denom: denom, + Amount: amount.String(), + Sender: "invalid_sender", + }) + err = s.App.AutopilotKeeper.SendToFallbackAddress(s.Ctx, invalidPacketDataBz, fallbackAccount.String()) + s.Require().ErrorContains(err, "invalid sender address") + + // Test with an invalid fallback address - it should error + err = s.App.AutopilotKeeper.SendToFallbackAddress(s.Ctx, packetDataBz, "invalid_fallback") + s.Require().ErrorContains(err, "invalid fallback address") + + // Test with an invalid amount - it should error + invalidPacketDataBz = transfertypes.ModuleCdc.MustMarshalJSON(&transfertypes.FungibleTokenPacketData{ + Denom: denom, + Amount: "", + Sender: senderAccount.String(), + }) + err = s.App.AutopilotKeeper.SendToFallbackAddress(s.Ctx, invalidPacketDataBz, fallbackAccount.String()) + s.Require().ErrorContains(err, "unable to parse amount") + + // Finally, try to call the send function again with a valid input, + // it should fail since the sender now has an insufficient balance + err = s.App.AutopilotKeeper.SendToFallbackAddress(s.Ctx, packetDataBz, fallbackAccount.String()) + s.Require().ErrorContains(err, "insufficient funds") +} + +// -------------------------------------------------------------- +// OnAcknowledgementPacket +// -------------------------------------------------------------- + +func (s *KeeperTestSuite) TestOnAcknowledgementPacket_AckSuccess() { + tc := s.SetupTestHandleFallbackPacket() + + // Build a successful ack + ackSuccess := transfertypes.ModuleCdc.MustMarshalJSON(&channeltypes.Acknowledgement{ + Response: &channeltypes.Acknowledgement_Result{ + Result: []byte{1}, // just has to be non-empty + }, + }) + + // Call OnAckPacket with the successful ack + err := s.App.AutopilotKeeper.OnAcknowledgementPacket(s.Ctx, tc.Packet, ackSuccess) + s.Require().NoError(err, "no error expected during OnAckPacket") + + // Confirm the fallback address was removed + _, found := s.App.AutopilotKeeper.GetTransferFallbackAddress(s.Ctx, tc.ChannelId, tc.OriginalSequence) + s.Require().False(found, "fallback address should have been removed") + + // Confirm the fallback address has not received any coins + zeroCoin := sdk.NewCoin(tc.Token.Denom, sdk.ZeroInt()) + fallbackBalance := s.App.BankKeeper.GetBalance(s.Ctx, tc.FallbackAccount, tc.Token.Denom) + s.CompareCoins(zeroCoin, fallbackBalance, "fallback account should not have received funds") +} + +func (s *KeeperTestSuite) TestOnAcknowledgementPacket_AckFailure() { + tc := s.SetupTestHandleFallbackPacket() + + // Build an error ack + ackFailure := transfertypes.ModuleCdc.MustMarshalJSON(&channeltypes.Acknowledgement{ + Response: &channeltypes.Acknowledgement_Error{}, + }) + + // Call OnAckPacket with the successful ack + err := s.App.AutopilotKeeper.OnAcknowledgementPacket(s.Ctx, tc.Packet, ackFailure) + s.Require().NoError(err, "no error expected during OnAckPacket") + + // Confirm tokens were sent to the fallback address + zeroCoin := sdk.NewCoin(tc.Token.Denom, sdk.ZeroInt()) + senderBalance := s.App.BankKeeper.GetBalance(s.Ctx, tc.SenderAccount, tc.Token.Denom) + fallbackBalance := s.App.BankKeeper.GetBalance(s.Ctx, tc.FallbackAccount, tc.Token.Denom) + s.CompareCoins(zeroCoin, senderBalance, "sender account should have lost funds") + s.CompareCoins(tc.Token, fallbackBalance, "fallback account should have received funds") + + // Confirm the fallback address was removed + _, found := s.App.AutopilotKeeper.GetTransferFallbackAddress(s.Ctx, tc.ChannelId, tc.OriginalSequence) + s.Require().False(found, "fallback address should have been removed") +} + +func (s *KeeperTestSuite) TestOnAcknowledgementPacket_InvalidAck() { + tc := s.SetupTestHandleFallbackPacket() + + // Build an invalid ack to force an error + invalidAck := transfertypes.ModuleCdc.MustMarshalJSON(&channeltypes.Acknowledgement{ + Response: &channeltypes.Acknowledgement_Result{ + Result: []byte{}, // empty result causes an error + }, + }) + + // Call OnAckPacket with the invalid ack + err := s.App.AutopilotKeeper.OnAcknowledgementPacket(s.Ctx, tc.Packet, invalidAck) + s.Require().ErrorContains(err, "invalid acknowledgement") +} + +func (s *KeeperTestSuite) TestOnAcknowledgementPacket_NoOp() { + tc := s.SetupTestHandleFallbackPacket() + + // Remove the fallback address so that there is no action necessary in the callback + s.App.AutopilotKeeper.RemoveTransferFallbackAddress(s.Ctx, tc.ChannelId, tc.OriginalSequence) + + // Call OnAckPacket and confirm there was no error + // The ack argument here doesn't matter cause the no-op check is upstream + err := s.App.AutopilotKeeper.OnAcknowledgementPacket(s.Ctx, tc.Packet, []byte{}) + s.Require().NoError(err, "no error expected during on ack packet") + + // Check that no funds were moved + zeroCoin := sdk.NewCoin(tc.Token.Denom, sdk.ZeroInt()) + senderBalance := s.App.BankKeeper.GetBalance(s.Ctx, tc.SenderAccount, tc.Token.Denom) + fallbackBalance := s.App.BankKeeper.GetBalance(s.Ctx, tc.FallbackAccount, tc.Token.Denom) + s.CompareCoins(tc.Token, senderBalance, "sender account should have lost funds") + s.CompareCoins(zeroCoin, fallbackBalance, "fallback account should have received funds") +} + +// -------------------------------------------------------------- +// OnTimeoutPacket +// -------------------------------------------------------------- + +func (s *KeeperTestSuite) TestOnTimeoutPacket_Successful() { + tc := s.SetupTestHandleFallbackPacket() + + // Call OnTimeoutPacket + err := s.App.AutopilotKeeper.OnTimeoutPacket(s.Ctx, tc.Packet) + s.Require().NoError(err, "no error expected when calling OnTimeoutPacket") + + // Confirm tokens were sent to the fallback address + zeroCoin := sdk.NewCoin(tc.Token.Denom, sdk.ZeroInt()) + senderBalance := s.App.BankKeeper.GetBalance(s.Ctx, tc.SenderAccount, tc.Token.Denom) + fallbackBalance := s.App.BankKeeper.GetBalance(s.Ctx, tc.FallbackAccount, tc.Token.Denom) + s.CompareCoins(zeroCoin, senderBalance, "sender account should have lost funds") + s.CompareCoins(tc.Token, fallbackBalance, "fallback account should have received funds") + + // Confirm the fallback address was removed + _, found := s.App.AutopilotKeeper.GetTransferFallbackAddress(s.Ctx, tc.ChannelId, tc.OriginalSequence) + s.Require().False(found, "fallback address should have been removed") +} + +func (s *KeeperTestSuite) TestOnTimeoutPacket_NoOp() { + tc := s.SetupTestHandleFallbackPacket() + + // Remove the fallback address + s.App.AutopilotKeeper.RemoveTransferFallbackAddress(s.Ctx, tc.ChannelId, tc.OriginalSequence) + + // Call OnTimeoutPacket - this should be a no-op since there's no fallback data + err := s.App.AutopilotKeeper.OnTimeoutPacket(s.Ctx, tc.Packet) + s.Require().NoError(err, "no error expected when calling OnTimeoutPacket") + + // Confirm the sender still has his original tokens (since the retry was not submitted) + senderBalance := s.App.BankKeeper.GetBalance(s.Ctx, tc.SenderAccount, tc.Token.Denom) + s.CompareCoins(tc.Token, senderBalance, "the sender balance should not have changed") +} diff --git a/x/autopilot/keeper/keeper_test.go b/x/autopilot/keeper/keeper_test.go index 11649e25c..8f0445a60 100644 --- a/x/autopilot/keeper/keeper_test.go +++ b/x/autopilot/keeper/keeper_test.go @@ -14,6 +14,10 @@ const ( HostBechPrefix = "cosmos" HostAddress = "cosmos16plylpsgxechajltx9yeseqexzdzut9g8vla4k" HostDenom = "uatom" + + Atom = "uatom" + Strd = "ustrd" + Osmo = "uosmo" ) type KeeperTestSuite struct { diff --git a/x/autopilot/keeper/liquidstake.go b/x/autopilot/keeper/liquidstake.go index ee1cec54d..379062485 100644 --- a/x/autopilot/keeper/liquidstake.go +++ b/x/autopilot/keeper/liquidstake.go @@ -47,7 +47,7 @@ func (k Keeper) TryLiquidStaking( // In this case, we can't process a liquid staking transaction, because we're dealing with native tokens (e.g. STRD, stATOM) if transfertypes.ReceiverChainIsSource(packet.GetSourcePort(), packet.GetSourceChannel(), transferMetadata.Denom) { - return errors.New("the native token is not supported for liquid staking") + return fmt.Errorf("native token is not supported for liquid staking (%s)", transferMetadata.Denom) } // Note: the denom in the packet is the base denom e.g. uatom - not ibc/xxx @@ -57,7 +57,7 @@ func (k Keeper) TryLiquidStaking( hostZone, err := k.stakeibcKeeper.GetHostZoneFromHostDenom(ctx, transferMetadata.Denom) if err != nil { - return fmt.Errorf("host zone not found for denom (%s)", transferMetadata.Denom) + return err } // Verify the IBC denom of the packet matches the host zone, to confirm the packet @@ -137,10 +137,14 @@ func (k Keeper) IBCTransferStToken( TimeoutTimestamp: timeoutTimestamp, Memo: "autopilot-liquid-stake-and-forward", } - _, err = k.transferKeeper.Transfer(sdk.WrapSDKContext(ctx), transferMsg) + + transferResponse, err := k.transferKeeper.Transfer(sdk.WrapSDKContext(ctx), transferMsg) if err != nil { return errorsmod.Wrapf(err, "failed to submit transfer during autopilot liquid stake and forward") } + // Store the original receiver as the fallback address in case the transfer fails + k.SetTransferFallbackAddress(ctx, channelId, transferResponse.Sequence, autopilotMetadata.StrideAddress) + return err } diff --git a/x/autopilot/keeper/liquidstake_test.go b/x/autopilot/keeper/liquidstake_test.go index 24642d783..31338c42f 100644 --- a/x/autopilot/keeper/liquidstake_test.go +++ b/x/autopilot/keeper/liquidstake_test.go @@ -2,254 +2,515 @@ package keeper_test import ( "fmt" - "time" - "github.com/cometbft/cometbft/crypto/ed25519" + sdkmath "cosmossdk.io/math" + sdk "github.com/cosmos/cosmos-sdk/types" "github.com/cosmos/ibc-go/v7/modules/apps/transfer" transfertypes "github.com/cosmos/ibc-go/v7/modules/apps/transfer/types" - clienttypes "github.com/cosmos/ibc-go/v7/modules/core/02-client/types" channeltypes "github.com/cosmos/ibc-go/v7/modules/core/04-channel/types" - - recordsmodule "github.com/Stride-Labs/stride/v16/x/records" - - sdk "github.com/cosmos/cosmos-sdk/types" + ibctesting "github.com/cosmos/ibc-go/v7/testing" "github.com/Stride-Labs/stride/v16/x/autopilot" "github.com/Stride-Labs/stride/v16/x/autopilot/types" epochtypes "github.com/Stride-Labs/stride/v16/x/epochs/types" - minttypes "github.com/Stride-Labs/stride/v16/x/mint/types" + recordsmodule "github.com/Stride-Labs/stride/v16/x/records" recordstypes "github.com/Stride-Labs/stride/v16/x/records/types" stakeibctypes "github.com/Stride-Labs/stride/v16/x/stakeibc/types" ) -func getStakeibcPacketMetadata(address, action string) string { +var ( + // Arbitrary channel ID on the non-stride zone + SourceChannelOnHost = "channel-1000" + + // Building a mapping to of base denom to expected denom traces for the transfer packet data + // This is all assuming the packet has been sent to stride (the FungibleTokenPacketData has + // a denom-trace for the Denom field, instead of an IBC hash) + ReceivePacketDenomTraces = map[string]string{ + // For host zone tokens, since stride is the first hop, there's no port/channel in the denom trace path + Atom: Atom, + Osmo: Osmo, + // For strd, the other zone's channel ID is appended to the denom trace + Strd: transfertypes.GetPrefixedDenom(transfertypes.PortID, SourceChannelOnHost, Strd), + } +) + +// Helper function to create the autopilot JSON payload for a liquid stake +func getLiquidStakePacketMetadata(receiver, ibcReceiver, transferChannelId string) string { return fmt.Sprintf(` { "autopilot": { "receiver": "%[1]s", - "stakeibc": { "action": "%[2]s" } + "stakeibc": { "action": "LiquidStake", "ibc_receiver": "%[2]s", "transfer_channel": "%[3]s" } } - }`, address, action) + }`, receiver, ibcReceiver, transferChannelId) } -func (suite *KeeperTestSuite) TestLiquidStakeOnRecvPacket() { - now := time.Now() - - packet := channeltypes.Packet{ - Sequence: 1, - SourcePort: "transfer", - SourceChannel: "channel-0", - DestinationPort: "transfer", - DestinationChannel: "channel-0", - Data: []byte{}, - TimeoutHeight: clienttypes.Height{}, - TimeoutTimestamp: 0, +// Helper function to mock out all the state needed to test autopilot liquid stake +// A transfer channel-0 is created, and the state is mocked out with an atom host zone +// +// Note: The testing framework is limited to one transfer channel per test, which is channel-0. +// If there's an outbound transfer, it must be on channel-0. So when testing a transfer along +// a non-host-zone channel (e.g. a transfer of statom to Osmosis), a different `strideToHostChannelId` +// channel ID must be passed to this function +// +// Returns the ibc denom of the native token +func (s *KeeperTestSuite) SetupAutopilotLiquidStake( + featureEnabled bool, + strideToHostChannelId string, + depositAddress sdk.AccAddress, + liquidStaker sdk.AccAddress, +) (nativeTokenIBCDenom string) { + // Create a transfer channel on channel-0 for the outbound transfer + // Note: We pass a dummy chain ID cause all that matters here is + // that channel-0 exists, it does not have to line up with the host zone + s.CreateTransferChannel("chain-0") + + // Set whether the feature is active + params := s.App.AutopilotKeeper.GetParams(s.Ctx) + params.StakeibcActive = featureEnabled + s.App.AutopilotKeeper.SetParams(s.Ctx, params) + + // Set the epoch tracker to lookup the deposit record + s.App.StakeibcKeeper.SetEpochTracker(s.Ctx, stakeibctypes.EpochTracker{ + EpochIdentifier: epochtypes.STRIDE_EPOCH, + EpochNumber: 1, + }) + + // Set deposit record to store the new liquid stake + s.App.RecordsKeeper.SetDepositRecord(s.Ctx, recordstypes.DepositRecord{ + Id: 1, + DepositEpochNumber: 1, + Amount: sdk.ZeroInt(), + HostZoneId: HostChainId, + Status: recordstypes.DepositRecord_TRANSFER_QUEUE, + }) + + // Set the host zone - this should have the actual IBC denom + prefixedDenom := transfertypes.GetPrefixedDenom(transfertypes.PortID, strideToHostChannelId, HostDenom) + nativeTokenIBCDenom = transfertypes.ParseDenomTrace(prefixedDenom).IBCDenom() + + s.App.StakeibcKeeper.SetHostZone(s.Ctx, stakeibctypes.HostZone{ + ChainId: HostChainId, + HostDenom: HostDenom, + RedemptionRate: sdk.NewDec(1), // used to determine the stAmount + DepositAddress: depositAddress.String(), + IbcDenom: nativeTokenIBCDenom, + TransferChannelId: strideToHostChannelId, + }) + + return nativeTokenIBCDenom +} + +func (s *KeeperTestSuite) CheckLiquidStakeSucceeded( + liquidStakeAmount sdkmath.Int, + liquidStakerAddress sdk.AccAddress, + depositAddress sdk.AccAddress, + nativeTokenIBCDenom string, + expectedForwardChannelId string, +) { + // If there was a forwarding step, the stTokens will end up in the escrow account + // Otherwise, they'll be in the liquid staker's account + stTokenRecipient := liquidStakerAddress + if expectedForwardChannelId != "" { + escrowAddress := transfertypes.GetEscrowAddress(transfertypes.PortID, expectedForwardChannelId) + stTokenRecipient = escrowAddress + } + + // Confirm the liquid staker has lost his native tokens + stakerBalance := s.App.BankKeeper.GetBalance(s.Ctx, liquidStakerAddress, nativeTokenIBCDenom) + s.Require().Zero(stakerBalance.Amount.Int64(), "liquid staker should have lost host tokens") + + // Confirm the deposit address now has the native tokens + depositBalance := s.App.BankKeeper.GetBalance(s.Ctx, depositAddress, nativeTokenIBCDenom) + s.Require().Equal(liquidStakeAmount.Int64(), depositBalance.Amount.Int64(), "deposit address should have gained host tokens") + + // Confirm the stToken's were minted and sent to the recipient + recipientBalance := s.App.BankKeeper.GetBalance(s.Ctx, stTokenRecipient, "st"+HostDenom) + s.Require().Equal(liquidStakeAmount.Int64(), recipientBalance.Amount.Int64(), "st token recipient balance") + + // If there was a forwarding step, confirm the fallback address was stored + // The fallback address in all these tests is the same as the liquid staker + if expectedForwardChannelId != "" { + address, found := s.App.AutopilotKeeper.GetTransferFallbackAddress(s.Ctx, expectedForwardChannelId, 1) + s.Require().True(found, "fallback address should have been found") + s.Require().Equal(liquidStakerAddress.String(), address, "fallback address") } +} - atomHostDenom := "uatom" - prefixedDenom := transfertypes.GetPrefixedDenom(packet.GetDestPort(), packet.GetDestChannel(), atomHostDenom) - atomIbcDenom := transfertypes.ParseDenomTrace(prefixedDenom).IBCDenom() - prefixedDenom2 := transfertypes.GetPrefixedDenom(packet.GetDestPort(), "channel-1000", atomHostDenom) - atomIbcDenom2 := transfertypes.ParseDenomTrace(prefixedDenom2).IBCDenom() +// Tests TryLiquidStake directly - beginning after the inbound autopilot transfer has passed down the stack +func (s *KeeperTestSuite) TestTryLiquidStake() { + liquidStakerOnStride := s.TestAccs[0] + depositAddress := s.TestAccs[1] + forwardRecipientOnHost := HostAddress - strdDenom := "ustrd" - prefixedDenom = transfertypes.GetPrefixedDenom(packet.GetSourcePort(), packet.GetSourceChannel(), strdDenom) - strdIbcDenom := transfertypes.ParseDenomTrace(prefixedDenom).IBCDenom() + stakeAmount := sdk.NewInt(1000000) - addr1 := sdk.AccAddress(ed25519.GenPrivKey().PubKey().Address().Bytes()) testCases := []struct { - forwardingActive bool - recvDenom string - packetData transfertypes.FungibleTokenPacketData - destChannel string - expSuccess bool - expLiquidStake bool + name string + enabled bool + liquidStakeDenom string + liquidStakeAmount string + autopilotMetadata types.StakeibcPacketMetadata + hostZoneChannelID string // defaults to channel-0 if not specified + inboundTransferChannnelId string // defaults to channel-0 if not specified + expectedForwardChannelId string // defaults to empty (no forwarding) + expectedError string }{ - { // params not enabled - forwardingActive: false, - packetData: transfertypes.FungibleTokenPacketData{ - Denom: "uatom", - Amount: "1000000", - Sender: "cosmos16plylpsgxechajltx9yeseqexzdzut9g8vla4k", - Receiver: getStakeibcPacketMetadata(addr1.String(), "LiquidStake"), - Memo: "", - }, - destChannel: "channel-0", - recvDenom: atomIbcDenom, - expSuccess: false, - expLiquidStake: false, + { + // Normal autopilot liquid stake with no transfer + name: "successful liquid stake with atom", + enabled: true, + liquidStakeDenom: Atom, + liquidStakeAmount: stakeAmount.String(), }, - { // strd denom - forwardingActive: true, - packetData: transfertypes.FungibleTokenPacketData{ - Denom: strdIbcDenom, - Amount: "1000000", - Sender: "cosmos16plylpsgxechajltx9yeseqexzdzut9g8vla4k", - Receiver: getStakeibcPacketMetadata(addr1.String(), "LiquidStake"), - Memo: "", + { + // Liquid stake and forward, using the default host channel ID + name: "successful liquid stake and forward atom to the hub", + enabled: true, + liquidStakeDenom: Atom, + liquidStakeAmount: stakeAmount.String(), + autopilotMetadata: types.StakeibcPacketMetadata{ + StrideAddress: liquidStakerOnStride.String(), // fallback address + IbcReceiver: forwardRecipientOnHost, }, - destChannel: "channel-0", - recvDenom: "ustrd", - expSuccess: false, - expLiquidStake: false, + expectedForwardChannelId: ibctesting.FirstChannelID, // default for host zone }, - { // all okay - forwardingActive: true, - packetData: transfertypes.FungibleTokenPacketData{ - Denom: "uatom", - Amount: "1000000", - Sender: "cosmos16plylpsgxechajltx9yeseqexzdzut9g8vla4k", - Receiver: getStakeibcPacketMetadata(addr1.String(), "LiquidStake"), - Memo: "", + { + // Liquid stake and forward, using a custom channel ID + // Host Zone Channel: channel-1, Outbound Transfer Channel: channel-0 + name: "successful liquid stake and forward atom to osmo", + enabled: true, + liquidStakeDenom: Atom, + liquidStakeAmount: stakeAmount.String(), + autopilotMetadata: types.StakeibcPacketMetadata{ + StrideAddress: liquidStakerOnStride.String(), // fallback address + IbcReceiver: forwardRecipientOnHost, + TransferChannel: "channel-0", // custom channel (different than host channel below) }, - destChannel: "channel-0", - recvDenom: atomIbcDenom, - expSuccess: true, - expLiquidStake: true, + inboundTransferChannnelId: "channel-1", + hostZoneChannelID: "channel-1", + expectedForwardChannelId: "channel-0", }, - { // ibc denom uatom from different channel - forwardingActive: true, - packetData: transfertypes.FungibleTokenPacketData{ - Denom: "uatom", - Amount: "1000000", - Sender: "cosmos16plylpsgxechajltx9yeseqexzdzut9g8vla4k", - Receiver: getStakeibcPacketMetadata(addr1.String(), "LiquidStake"), - Memo: "", - }, - destChannel: "channel-1000", - recvDenom: atomIbcDenom2, - expSuccess: false, - expLiquidStake: false, + { + // Error caused by autopilot disabled + name: "autopilot disabled", + enabled: false, + liquidStakeDenom: Atom, + liquidStakeAmount: stakeAmount.String(), + expectedError: "autopilot stakeibc routing is inactive", }, - { // all okay with memo liquidstaking since ibc-go v5.1.0 - forwardingActive: true, - packetData: transfertypes.FungibleTokenPacketData{ - Denom: "uatom", - Amount: "1000000", - Sender: "cosmos16plylpsgxechajltx9yeseqexzdzut9g8vla4k", - Receiver: addr1.String(), - Memo: getStakeibcPacketMetadata(addr1.String(), "LiquidStake"), - }, - destChannel: "channel-0", - recvDenom: atomIbcDenom, - expSuccess: true, - expLiquidStake: true, + { + // Error caused an invalid amount in the packet + name: "invalid token amount", + enabled: true, + liquidStakeDenom: Atom, + liquidStakeAmount: "", + expectedError: "not a parsable amount field", }, - { // all okay with no functional part - forwardingActive: true, - packetData: transfertypes.FungibleTokenPacketData{ - Denom: "uatom", - Amount: "1000000", - Sender: "cosmos16plylpsgxechajltx9yeseqexzdzut9g8vla4k", - Receiver: addr1.String(), - Memo: "", - }, - destChannel: "channel-0", - recvDenom: atomIbcDenom, - expSuccess: true, - expLiquidStake: false, + { + // Error caused by the transfer of a non-native token + // (i.e. a token that originated on stride) + name: "unable to liquid stake native token", + enabled: true, + liquidStakeDenom: Strd, + liquidStakeAmount: stakeAmount.String(), + expectedError: "native token is not supported for liquid staking", }, - { // invalid stride address (receiver) - forwardingActive: true, - packetData: transfertypes.FungibleTokenPacketData{ - Denom: "uatom", - Amount: "1000000", - Sender: "cosmos16plylpsgxechajltx9yeseqexzdzut9g8vla4k", - Receiver: getStakeibcPacketMetadata("invalid_address", "LiquidStake"), - Memo: "", - }, - destChannel: "channel-0", - recvDenom: atomIbcDenom, - expSuccess: false, - expLiquidStake: false, + { + // Error caused by the transfer of non-host zone token + name: "unable to liquid stake non-host zone token", + enabled: true, + liquidStakeDenom: Osmo, + liquidStakeAmount: stakeAmount.String(), + expectedError: "No HostZone for uosmo denom found", + }, + { + // Error caused by a mismatched IBC denom + // Invoked by specifiying a different host zone channel ID + name: "ibc denom does not match host zone", + enabled: true, + liquidStakeDenom: Atom, + liquidStakeAmount: stakeAmount.String(), + hostZoneChannelID: "channel-0", + inboundTransferChannnelId: "channel-1", // Different than host zone + expectedError: "is not equal to host zone ibc denom", }, - { // invalid stride address (memo) - forwardingActive: true, - packetData: transfertypes.FungibleTokenPacketData{ - Denom: "uatom", - Amount: "1000000", - Sender: "cosmos16plylpsgxechajltx9yeseqexzdzut9g8vla4k", - Receiver: addr1.String(), - Memo: getStakeibcPacketMetadata("invalid_address", "LiquidStake"), + { + // Error caused by a failed validate basic before the liquid stake + // Invoked by passing a negative amount + name: "failed liquid stake validate basic", + enabled: true, + liquidStakeDenom: Atom, + liquidStakeAmount: "-10000", + expectedError: "amount liquid staked must be positive and nonzero", + }, + { + // Error caused by a failed liquid stake + // Invoked by trying to liquid stake more tokens than the staker has available + name: "failed to liquid stake", + enabled: true, + liquidStakeDenom: Atom, + liquidStakeAmount: stakeAmount.Add(sdkmath.NewInt(100000)).String(), // greater than balance + expectedError: "failed to liquid stake", + }, + { + // Failed to send transfer during forwarding step + // Invoked by specifying a non-existent channel ID + name: "failed to forward transfer", + enabled: true, + liquidStakeDenom: Atom, + liquidStakeAmount: stakeAmount.String(), + autopilotMetadata: types.StakeibcPacketMetadata{ + IbcReceiver: forwardRecipientOnHost, + TransferChannel: "channel-100", // does not exist }, - destChannel: "channel-0", - recvDenom: atomIbcDenom, - expSuccess: false, - expLiquidStake: false, + expectedError: "failed to submit transfer during autopilot liquid stake and forward", + }, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + // Fill in the default channel ID's if they weren't specified + if tc.hostZoneChannelID == "" { + tc.hostZoneChannelID = ibctesting.FirstChannelID + } + if tc.inboundTransferChannnelId == "" { + tc.inboundTransferChannnelId = ibctesting.FirstChannelID + } + + transferMetadata := transfertypes.FungibleTokenPacketData{ + Denom: ReceivePacketDenomTraces[tc.liquidStakeDenom], + Amount: tc.liquidStakeAmount, + Receiver: liquidStakerOnStride.String(), + } + packet := channeltypes.Packet{ + SourcePort: transfertypes.PortID, + SourceChannel: SourceChannelOnHost, + DestinationPort: transfertypes.PortID, + DestinationChannel: tc.inboundTransferChannnelId, + } + + s.SetupTest() + nativeTokenIBCDenom := s.SetupAutopilotLiquidStake(tc.enabled, tc.hostZoneChannelID, depositAddress, liquidStakerOnStride) + + // Since this tested function is normally downstream of the inbound IBC transfer, + // we have to fund the staker with ibc/atom before calling this function so + // they can liquid stake + s.FundAccount(liquidStakerOnStride, sdk.NewCoin(nativeTokenIBCDenom, stakeAmount)) + + err := s.App.AutopilotKeeper.TryLiquidStaking(s.Ctx, packet, transferMetadata, tc.autopilotMetadata) + + if tc.expectedError == "" { + s.Require().NoError(err, "%s - no error expected when attempting liquid stake", tc.name) + s.CheckLiquidStakeSucceeded( + stakeAmount, + liquidStakerOnStride, + depositAddress, + nativeTokenIBCDenom, + tc.expectedForwardChannelId, + ) + } else { + s.Require().ErrorContains(err, tc.expectedError, tc.name) + } + }) + } +} + +// Tests the full OnRecvPacket callback, with liquid staking specific test cases +func (s *KeeperTestSuite) TestOnRecvPacket_LiquidStake() { + liquidStakerOnStride := s.TestAccs[0] + depositAddress := s.TestAccs[1] + forwardRecipientOnHost := HostAddress + + stakeAmount := sdk.NewInt(1000000) + + testCases := []struct { + name string + enabled bool + liquidStakeDenom string + transferReceiver string + transferMemo string + hostZoneChannelID string // defaults to channel-0 if not specified + inboundTransferChannnelId string // defaults to channel-0 if not specified + expectedForwardChannelId string // defaults to empty (no forwarding) + expectedSuccess bool + expectedLiquidStake bool + }{ + { + name: "successful liquid stake with metadata in receiver", + enabled: true, + liquidStakeDenom: Atom, + transferReceiver: getLiquidStakePacketMetadata(liquidStakerOnStride.String(), "", ""), + transferMemo: "", + expectedSuccess: true, + expectedLiquidStake: true, + }, + { + name: "successful liquid stake with metadata in the memo", + enabled: true, + liquidStakeDenom: Atom, + transferReceiver: liquidStakerOnStride.String(), + transferMemo: getLiquidStakePacketMetadata(liquidStakerOnStride.String(), "", ""), + expectedSuccess: true, + expectedLiquidStake: true, + }, + { + name: "successful liquid stake and forward to default host (receiver)", + enabled: true, + liquidStakeDenom: Atom, + transferReceiver: getLiquidStakePacketMetadata(liquidStakerOnStride.String(), forwardRecipientOnHost, ""), + transferMemo: "", + expectedForwardChannelId: ibctesting.FirstChannelID, + expectedSuccess: true, + expectedLiquidStake: true, + }, + { + name: "successful liquid stake and forward to default host (memo)", + enabled: true, + liquidStakeDenom: Atom, + transferReceiver: liquidStakerOnStride.String(), + transferMemo: getLiquidStakePacketMetadata(liquidStakerOnStride.String(), forwardRecipientOnHost, ""), + expectedForwardChannelId: ibctesting.FirstChannelID, + expectedSuccess: true, + expectedLiquidStake: true, + }, + { + name: "successful liquid stake and forward to custom transfer channel (receiver)", + enabled: true, + liquidStakeDenom: Atom, + transferReceiver: getLiquidStakePacketMetadata(liquidStakerOnStride.String(), forwardRecipientOnHost, "channel-0"), + transferMemo: "", + hostZoneChannelID: "channel-1", + inboundTransferChannnelId: "channel-1", + expectedForwardChannelId: "channel-0", // different than host zone, specified in memo + expectedSuccess: true, + expectedLiquidStake: true, + }, + { + name: "successful liquid stake and forward to custom transfer channel (memo)", + enabled: true, + liquidStakeDenom: Atom, + transferReceiver: liquidStakerOnStride.String(), + transferMemo: getLiquidStakePacketMetadata(liquidStakerOnStride.String(), forwardRecipientOnHost, "channel-0"), + hostZoneChannelID: "channel-1", + inboundTransferChannnelId: "channel-1", + expectedForwardChannelId: "channel-0", // different than host zone, specified in memo + expectedSuccess: true, + expectedLiquidStake: true, + }, + { + name: "normal transfer with no liquid stake", + enabled: true, + liquidStakeDenom: Atom, + transferReceiver: liquidStakerOnStride.String(), + transferMemo: "", + expectedSuccess: true, + expectedLiquidStake: false, + }, + { + name: "autopilot disabled", + enabled: false, + liquidStakeDenom: Atom, + transferReceiver: liquidStakerOnStride.String(), + transferMemo: getLiquidStakePacketMetadata(liquidStakerOnStride.String(), "", ""), + expectedSuccess: false, + }, + { + name: "invalid stride address (receiver)", + enabled: true, + liquidStakeDenom: Osmo, + transferReceiver: getLiquidStakePacketMetadata("XXX", "", ""), + transferMemo: "", + expectedSuccess: false, + }, + { + name: "invalid stride address (memo)", + enabled: true, + liquidStakeDenom: Osmo, + transferReceiver: liquidStakerOnStride.String(), + transferMemo: getLiquidStakePacketMetadata("XXX", "", ""), + expectedSuccess: false, + }, + { + name: "not host denom", + enabled: true, + liquidStakeDenom: Osmo, + transferReceiver: liquidStakerOnStride.String(), + transferMemo: getLiquidStakePacketMetadata(liquidStakerOnStride.String(), "", ""), + expectedSuccess: false, + }, + { + name: "failed to outbound transfer", + enabled: true, + liquidStakeDenom: Atom, + transferReceiver: getLiquidStakePacketMetadata(liquidStakerOnStride.String(), forwardRecipientOnHost, "channel-999"), // channel DNE + transferMemo: "", + expectedSuccess: false, + }, + { + name: "valid uatom token from invalid channel", + enabled: true, + liquidStakeDenom: Atom, + transferReceiver: getLiquidStakePacketMetadata(liquidStakerOnStride.String(), "", ""), + transferMemo: "", + hostZoneChannelID: "channel-0", + inboundTransferChannnelId: "channel-999", // channel DNE + expectedSuccess: false, }, } - for i, tc := range testCases { - suite.Run(fmt.Sprintf("Case %d", i), func() { - packet.DestinationChannel = tc.destChannel - packet.Data = transfertypes.ModuleCdc.MustMarshalJSON(&tc.packetData) - - suite.SetupTest() // reset - ctx := suite.Ctx - - suite.App.AutopilotKeeper.SetParams(ctx, types.Params{StakeibcActive: tc.forwardingActive}) - - // set epoch tracker for env - suite.App.StakeibcKeeper.SetEpochTracker(ctx, stakeibctypes.EpochTracker{ - EpochIdentifier: epochtypes.STRIDE_EPOCH, - EpochNumber: 1, - NextEpochStartTime: uint64(now.Unix()), - Duration: 43200, - }) - // set deposit record for env - suite.App.RecordsKeeper.SetDepositRecord(ctx, recordstypes.DepositRecord{ - Id: 1, - Amount: sdk.NewInt(100), - Denom: atomIbcDenom, - HostZoneId: "hub-1", - Status: recordstypes.DepositRecord_TRANSFER_QUEUE, - DepositEpochNumber: 1, - Source: recordstypes.DepositRecord_STRIDE, - }) - // set host zone for env - suite.App.StakeibcKeeper.SetHostZone(ctx, stakeibctypes.HostZone{ - ChainId: "hub-1", - ConnectionId: "connection-0", - Bech32Prefix: "cosmos", - TransferChannelId: "channel-0", - IbcDenom: atomIbcDenom, - HostDenom: atomHostDenom, - RedemptionRate: sdk.NewDec(1), - DepositAddress: addr1.String(), - }) - - // mint coins to be spent on liquid staking - coins := sdk.Coins{sdk.NewInt64Coin(tc.recvDenom, 1000000)} - err := suite.App.BankKeeper.MintCoins(ctx, minttypes.ModuleName, coins) - suite.Require().NoError(err) - err = suite.App.BankKeeper.SendCoinsFromModuleToAccount(ctx, minttypes.ModuleName, addr1, coins) - suite.Require().NoError(err) - - transferIBCModule := transfer.NewIBCModule(suite.App.TransferKeeper) - recordsStack := recordsmodule.NewIBCModule(suite.App.RecordsKeeper, transferIBCModule) - routerIBCModule := autopilot.NewIBCModule(suite.App.AutopilotKeeper, recordsStack) + for _, tc := range testCases { + s.Run(tc.name, func() { + s.SetupTest() // reset + + // Fill in the default channel ID's if they weren't specified + if tc.hostZoneChannelID == "" { + tc.hostZoneChannelID = ibctesting.FirstChannelID + } + if tc.inboundTransferChannnelId == "" { + tc.inboundTransferChannnelId = ibctesting.FirstChannelID + } + + transferMetadata := transfertypes.FungibleTokenPacketData{ + Sender: HostAddress, + Receiver: tc.transferReceiver, + Denom: ReceivePacketDenomTraces[tc.liquidStakeDenom], + Amount: stakeAmount.String(), + Memo: tc.transferMemo, + } + packet := channeltypes.Packet{ + SourcePort: transfertypes.PortID, + SourceChannel: SourceChannelOnHost, + DestinationPort: transfertypes.PortID, + DestinationChannel: tc.inboundTransferChannnelId, + Data: transfertypes.ModuleCdc.MustMarshalJSON(&transferMetadata), + } + + nativeTokenIBCDenom := s.SetupAutopilotLiquidStake(tc.enabled, tc.hostZoneChannelID, depositAddress, liquidStakerOnStride) + + transferIBCModule := transfer.NewIBCModule(s.App.TransferKeeper) + recordsStack := recordsmodule.NewIBCModule(s.App.RecordsKeeper, transferIBCModule) + routerIBCModule := autopilot.NewIBCModule(s.App.AutopilotKeeper, recordsStack) ack := routerIBCModule.OnRecvPacket( - ctx, + s.Ctx, packet, - addr1, + s.TestAccs[2], // arbitrary relayer address - not actually used ) - if tc.expSuccess { - suite.Require().True(ack.Success(), "ack should be successful - ack: %+v", string(ack.Acknowledgement())) - - // Check funds were transferred - coin := suite.App.BankKeeper.GetBalance(suite.Ctx, addr1, tc.recvDenom) - suite.Require().Equal("2000000", coin.Amount.String(), "balance should have updated after successful transfer") - - // check minted balance for liquid staking - allBalance := suite.App.BankKeeper.GetAllBalances(ctx, addr1) - liquidBalance := suite.App.BankKeeper.GetBalance(ctx, addr1, "stuatom") - if tc.expLiquidStake { - suite.Require().True(liquidBalance.Amount.IsPositive(), "liquid balance should be positive but was %s", allBalance.String()) - } else { - suite.Require().True(liquidBalance.Amount.IsZero(), "liquid balance should be zero but was %s", allBalance.String()) + + if tc.expectedSuccess { + s.Require().True(ack.Success(), "ack should be successful - ack: %+v", string(ack.Acknowledgement())) + + if tc.expectedLiquidStake { + s.CheckLiquidStakeSucceeded( + stakeAmount, + liquidStakerOnStride, + depositAddress, + nativeTokenIBCDenom, + tc.expectedForwardChannelId, + ) } } else { - suite.Require().False(ack.Success(), "ack should have failed - ack: %+v", string(ack.Acknowledgement())) + s.Require().False(ack.Success(), "ack should have failed - ack: %+v", string(ack.Acknowledgement())) } }) } diff --git a/x/autopilot/module_ibc.go b/x/autopilot/module_ibc.go index d0fe5c72b..639da53eb 100644 --- a/x/autopilot/module_ibc.go +++ b/x/autopilot/module_ibc.go @@ -262,8 +262,15 @@ func (im IBCModule) OnAcknowledgementPacket( acknowledgement []byte, relayer sdk.AccAddress, ) error { - im.keeper.Logger(ctx).Info(fmt.Sprintf("[IBC-TRANSFER] OnAcknowledgementPacket %v", packet)) - return im.app.OnAcknowledgementPacket(ctx, packet, acknowledgement, relayer) + im.keeper.Logger(ctx).Info(fmt.Sprintf("OnAcknowledgementPacket (Autopilot): Packet %v, Acknowledgement %v", packet, acknowledgement)) + // First pass the packet down the stack so that, in the event of an ack failure, + // the tokens are refunded to the original sender + if err := im.app.OnAcknowledgementPacket(ctx, packet, acknowledgement, relayer); err != nil { + return err + } + // Then process the autopilot-specific callback + // This will handle bank sending to a fallback address if the original transfer failed + return im.keeper.OnAcknowledgementPacket(ctx, packet, acknowledgement) } // OnTimeoutPacket implements the IBCModule interface @@ -272,8 +279,14 @@ func (im IBCModule) OnTimeoutPacket( packet channeltypes.Packet, relayer sdk.AccAddress, ) error { - im.keeper.Logger(ctx).Error(fmt.Sprintf("[IBC-TRANSFER] OnTimeoutPacket %v", packet)) - return im.app.OnTimeoutPacket(ctx, packet, relayer) + im.keeper.Logger(ctx).Error(fmt.Sprintf("OnTimeoutPacket (Autopilot): Packet %v", packet)) + // First pass the packet down the stack so that the tokens are refunded to the original sender + if err := im.app.OnTimeoutPacket(ctx, packet, relayer); err != nil { + return err + } + // Then process the autopilot-specific callback + // This will handle a retry in the event that there was a timeout during an autopilot action + return im.keeper.OnTimeoutPacket(ctx, packet) } // This is implemented by ICS4 and all middleware that are wrapping base application. diff --git a/x/autopilot/types/keys.go b/x/autopilot/types/keys.go index 57b6309e6..2b225e6ad 100644 --- a/x/autopilot/types/keys.go +++ b/x/autopilot/types/keys.go @@ -1,5 +1,7 @@ package types +import "encoding/binary" + const ( // ModuleName defines the module name ModuleName = "autopilot" @@ -13,3 +15,21 @@ const ( // QuerierRoute defines the module's query routing key QuerierRoute = ModuleName ) + +var ( + TransferFallbackAddressPrefix = []byte("fallback") + + FallbackAddressChannelPrefixLength int = 16 +) + +// Builds the store key for a fallback address, key'd by channel ID and sequence number +// The serialized channelId is set to a fixed array size to assist deserialization +func GetTransferFallbackAddressKey(channelId string, sequenceNumber uint64) []byte { + channelIdBz := make([]byte, FallbackAddressChannelPrefixLength) + copy(channelIdBz[:], channelId) + + sequenceNumberBz := make([]byte, 8) + binary.BigEndian.PutUint64(sequenceNumberBz, sequenceNumber) + + return append(channelIdBz, sequenceNumberBz...) +} diff --git a/x/autopilot/types/parser.go b/x/autopilot/types/parser.go index bb540ff8f..c7770c5ef 100644 --- a/x/autopilot/types/parser.go +++ b/x/autopilot/types/parser.go @@ -13,7 +13,7 @@ const RedeemStake = "RedeemStake" // Packet metadata info specific to Stakeibc (e.g. 1-click liquid staking) type StakeibcPacketMetadata struct { Action string `json:"action"` - // TODO: remove StrideAddress + // TODO [cleanup]: Rename to FallbackAddress StrideAddress string IbcReceiver string `json:"ibc_receiver,omitempty"` TransferChannel string `json:"transfer_channel,omitempty"` diff --git a/x/stakeibc/keeper/host_zone.go b/x/stakeibc/keeper/host_zone.go index 9a20cf9ce..827ae9c49 100644 --- a/x/stakeibc/keeper/host_zone.go +++ b/x/stakeibc/keeper/host_zone.go @@ -64,7 +64,7 @@ func (k Keeper) GetHostZoneFromHostDenom(ctx sdk.Context, denom string) (*types. if matchZone.ChainId != "" { return &matchZone, nil } - return nil, errorsmod.Wrapf(sdkerrors.ErrUnknownRequest, "No HostZone for %s found", denom) + return nil, errorsmod.Wrapf(sdkerrors.ErrUnknownRequest, "No HostZone for %s denom found", denom) } // GetHostZoneFromTransferChannelID returns a HostZone from a transfer channel ID