diff --git a/app/app.go b/app/app.go index 769bec3d0e..d9975233a7 100644 --- a/app/app.go +++ b/app/app.go @@ -544,7 +544,8 @@ func NewStrideApp( appCodec, keys[autopilottypes.StoreKey], app.GetSubspace(autopilottypes.ModuleName), - app.StakeibcKeeper) + app.StakeibcKeeper, + app.ClaimKeeper) autopilotModule := autopilot.NewAppModule(appCodec, app.AutopilotKeeper) // Register Gov (must be registerd after stakeibc) diff --git a/app/upgrades/v8/upgrades_test.go b/app/upgrades/v8/upgrades_test.go index 588fcfa136..26e87be2da 100644 --- a/app/upgrades/v8/upgrades_test.go +++ b/app/upgrades/v8/upgrades_test.go @@ -133,7 +133,8 @@ func (s *UpgradeTestSuite) CheckStoreAfterUpgrade() { // Check autopilot params expectedAutoPilotParams := autopilottypes.Params{ - Active: false, + StakeibcActive: false, + ClaimActive: true, } actualAutopilotParams := s.App.AutopilotKeeper.GetParams(s.Ctx) s.Require().Equal(expectedAutoPilotParams, actualAutopilotParams, "autopilot params") diff --git a/dockernet/tests/integration_tests.bats b/dockernet/tests/integration_tests.bats index b171c5e3c4..354d0c0a5c 100644 --- a/dockernet/tests/integration_tests.bats +++ b/dockernet/tests/integration_tests.bats @@ -100,8 +100,8 @@ setup_file() { hval_token_balance_start=$($HOST_MAIN_CMD q bank balances $HOST_VAL_ADDRESS --denom $HOST_DENOM | GETBAL) # do IBC transfer - $STRIDE_MAIN_CMD tx ibc-transfer transfer transfer $STRIDE_TRANFER_CHANNEL $HOST_VAL_ADDRESS ${TRANSFER_AMOUNT}${STRIDE_DENOM} --from $STRIDE_VAL -y & - $HOST_MAIN_CMD tx ibc-transfer transfer transfer $HOST_TRANSFER_CHANNEL $(STRIDE_ADDRESS) ${TRANSFER_AMOUNT}${HOST_DENOM} --from $HOST_VAL -y & + $STRIDE_MAIN_CMD tx ibc-transfer transfer transfer $STRIDE_TRANFER_CHANNEL $HOST_VAL_ADDRESS ${TRANSFER_AMOUNT}${STRIDE_DENOM} --from $STRIDE_VAL -y + $HOST_MAIN_CMD tx ibc-transfer transfer transfer $HOST_TRANSFER_CHANNEL $(STRIDE_ADDRESS) ${TRANSFER_AMOUNT}${HOST_DENOM} --from $HOST_VAL -y WAIT_FOR_BLOCK $STRIDE_LOGS 8 @@ -160,11 +160,23 @@ setup_file() { @test "[INTEGRATION-BASIC-$CHAIN_NAME] packet forwarding automatically liquid stakes" { skip "DefaultActive set to false, skip test" + memo='{ "autopilot": { "receiver": "'"$(STRIDE_ADDRESS)"'", "stakeibc": { "stride_address": "'"$(STRIDE_ADDRESS)"'", "action": "LiquidStake" } } }' + # get initial balances sttoken_balance_start=$($STRIDE_MAIN_CMD q bank balances $(STRIDE_ADDRESS) --denom st$HOST_DENOM | GETBAL) - # do IBC transfer - $HOST_MAIN_CMD tx ibc-transfer transfer transfer $HOST_TRANSFER_CHANNEL $(STRIDE_ADDRESS)'|stakeibc/LiquidStake' ${PACKET_FORWARD_STAKE_AMOUNT}${HOST_DENOM} --from $HOST_VAL -y & + # Send the IBC transfer with the JSON memo + transfer_msg_prefix="$HOST_MAIN_CMD tx ibc-transfer transfer transfer $HOST_TRANSFER_CHANNEL" + if [[ "$CHAIN_NAME" == "GAIA" ]]; then + # For GAIA (ibc-v3), pass the memo into the receiver field + $transfer_msg_prefix "$memo" ${PACKET_FORWARD_STAKE_AMOUNT}${HOST_DENOM} --from $HOST_VAL -y + elif [[ "$CHAIN_NAME" == "HOST" ]]; then + # For HOST (ibc-v5), pass an address for a receiver and the memo in the --memo field + $transfer_msg_prefix $(STRIDE_ADDRESS) ${PACKET_FORWARD_STAKE_AMOUNT}${HOST_DENOM} --memo "$memo" --from $HOST_VAL -y + else + # For all other hosts, skip this test + skip "Packet forward liquid stake test is only run on GAIA and HOST" + fi # Wait for the transfer to complete WAIT_FOR_BALANCE_CHANGE STRIDE $(STRIDE_ADDRESS) st$HOST_DENOM diff --git a/proto/stride/autopilot/params.proto b/proto/stride/autopilot/params.proto index 7bea98d08f..b670e9d8a3 100644 --- a/proto/stride/autopilot/params.proto +++ b/proto/stride/autopilot/params.proto @@ -9,6 +9,7 @@ option go_package = "github.com/Stride-Labs/stride/v8/x/autopilot/types"; // next id: 1 message Params { option (gogoproto.goproto_stringer) = false; - // optionally, turn off this module - bool active = 1; + // optionally, turn off each module + bool stakeibc_active = 1; + bool claim_active = 2; } \ No newline at end of file diff --git a/x/autopilot/README.md b/x/autopilot/README.md index 414ff83043..3b622cf3ee 100644 --- a/x/autopilot/README.md +++ b/x/autopilot/README.md @@ -14,10 +14,50 @@ With current implementation of Autopilot module, it supports: Note: This will support more functions that can reduce number of users' operations. +## Memo +### Format +```json +{ + "autopilot": { + "receiver": "strideXXX", + "{module_name}": { "{additiional_field}": "{value}" } + } +} +``` + +### Example (1-Click Liquid Stake) +```json +{ + "autopilot": { + "receiver": "strideXXX", + "stakeibc": { + "stride_address": "strideXXX", + "action": "LiquidStake", + } + } +} +``` +### Example (Update Airdrop Address) +```json +{ + "autopilot": { + "receiver": "strideXXX", + "claim": { + "stride_address": "strideXXX", + "airdrop_id": "evmos", + } + } +} +``` + +### A Note on Parsing +Since older versions of IBC do not have a `Memo` field, they must pass the routing information in the `Receiver` attribute of the IBC packet. To make autopilot backwards compatible with all older IBC versions, the receiver address must be specified in the JSON string. Before passing the packet down the stack to the transfer module, the address in the JSON string will replace the `Receiver` field in the packet data, regardless of the IBC version. + ## Params ``` -Active (default bool = true) +StakeibcActive (default bool = false) +ClaimActive (default bool = false) ``` ## Keeper functions diff --git a/x/autopilot/genesis_test.go b/x/autopilot/genesis_test.go index 76ac858b42..bca98cd696 100644 --- a/x/autopilot/genesis_test.go +++ b/x/autopilot/genesis_test.go @@ -12,7 +12,10 @@ import ( func TestGenesis(t *testing.T) { expectedGenesisState := types.GenesisState{ - Params: types.Params{Active: true}, + Params: types.Params{ + StakeibcActive: true, + ClaimActive: true, + }, } s := apptesting.SetupSuitelessTestHelper() diff --git a/x/autopilot/keeper/airdrop.go b/x/autopilot/keeper/airdrop.go new file mode 100644 index 0000000000..4f6b8fae96 --- /dev/null +++ b/x/autopilot/keeper/airdrop.go @@ -0,0 +1,40 @@ +package keeper + +import ( + "errors" + "fmt" + + errorsmod "cosmossdk.io/errors" + + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" + transfertypes "github.com/cosmos/ibc-go/v5/modules/apps/transfer/types" + + "github.com/Stride-Labs/stride/v8/utils" + "github.com/Stride-Labs/stride/v8/x/autopilot/types" +) + +func (k Keeper) TryUpdateAirdropClaim( + ctx sdk.Context, + data transfertypes.FungibleTokenPacketData, + packetMetadata types.ClaimPacketMetadata, +) error { + params := k.GetParams(ctx) + if !params.ClaimActive { + return errors.New("packet forwarding param is not active") + } + + // grab relevant addresses + senderStrideAddress := utils.ConvertAddressToStrideAddress(data.Sender) + if senderStrideAddress == "" { + return errorsmod.Wrapf(sdkerrors.ErrInvalidAddress, fmt.Sprintf("invalid sender address (%s)", data.Sender)) + } + newStrideAddress := packetMetadata.StrideAddress + + // update the airdrop + airdropId := packetMetadata.AirdropId + k.Logger(ctx).Info(fmt.Sprintf("updating airdrop address %s (orig %s) to %s for airdrop %s", + senderStrideAddress, data.Sender, newStrideAddress, airdropId)) + + return k.claimKeeper.UpdateAirdropAddress(ctx, senderStrideAddress, newStrideAddress, airdropId) +} diff --git a/x/autopilot/keeper/airdrop_test.go b/x/autopilot/keeper/airdrop_test.go new file mode 100644 index 0000000000..e496cf7992 --- /dev/null +++ b/x/autopilot/keeper/airdrop_test.go @@ -0,0 +1,294 @@ +package keeper_test + +import ( + "fmt" + "strings" + + "github.com/cosmos/ibc-go/v5/modules/apps/transfer" + transfertypes "github.com/cosmos/ibc-go/v5/modules/apps/transfer/types" + clienttypes "github.com/cosmos/ibc-go/v5/modules/core/02-client/types" + channeltypes "github.com/cosmos/ibc-go/v5/modules/core/04-channel/types" + + sdk "github.com/cosmos/cosmos-sdk/types" + + "github.com/Stride-Labs/stride/v8/utils" + "github.com/Stride-Labs/stride/v8/x/autopilot" + "github.com/Stride-Labs/stride/v8/x/autopilot/types" + claimtypes "github.com/Stride-Labs/stride/v8/x/claim/types" +) + +// TODO: Separate out tests cases that are not necessarily Claim or Stakeibc related, +// but more just test the parsing that occurs in OnRecvPacket +// Move them to a different test file + +func getClaimPacketMetadata(address, airdropId string) string { + return fmt.Sprintf(` + { + "autopilot": { + "receiver": "%[1]s", + "claim": { "stride_address": "%[1]s", "airdrop_id": "%[2]s" } + } + }`, address, airdropId) +} + +func (s *KeeperTestSuite) TestAirdropOnRecvPacket() { + evmosAirdropId := "evmos" + evmosDenom := "aevmos" + + // The evmos addresses represent the airdrop recipient + evmosAddress := "evmos1wg6vh689gw93umxqquhe3yaqf0h9wt9d4q7550" + + // Each evmos address has a serialized mapping that was used to store the claim record + // This is in the form of an "incorrect" stride address and was stored during the upgrade + evmosAddressKeyString := utils.ConvertAddressToStrideAddress(evmosAddress) + evmosAddressKey := sdk.MustAccAddressFromBech32(evmosAddressKeyString) + + // For each evmos address, there is a corresponding stride address that will specified + // in the transfer packet - so for the sake of this test, we'll use arbitrary stride addresses + strideAccAddress := s.TestAccs[0] + strideAddress := strideAccAddress.String() + + // Build the template for the transfer packet (the data and channel fields will get updated from each unit test) + packetTemplate := channeltypes.Packet{ + Sequence: 1, + SourcePort: "transfer", + SourceChannel: "channel-0", + DestinationPort: "transfer", + DestinationChannel: "channel-0", + Data: []byte{}, + TimeoutHeight: clienttypes.Height{}, + TimeoutTimestamp: 0, + } + packetDataTemplate := transfertypes.FungibleTokenPacketData{ + Denom: evmosDenom, + Amount: "1000000", + Sender: evmosAddress, + } + + prefixedDenom := transfertypes.GetPrefixedDenom(packetTemplate.GetSourcePort(), packetTemplate.GetSourceChannel(), evmosDenom) + evmosIbcDenom := transfertypes.ParseDenomTrace(prefixedDenom).IBCDenom() + + testCases := []struct { + name string + forwardingActive bool + packetData transfertypes.FungibleTokenPacketData + transferShouldSucceed bool + airdropShouldUpdate bool + }{ + { + name: "successful airdrop update from receiver", + forwardingActive: true, + packetData: transfertypes.FungibleTokenPacketData{ + Receiver: getClaimPacketMetadata(strideAddress, evmosAirdropId), + Memo: "", + }, + transferShouldSucceed: true, + airdropShouldUpdate: true, + }, + { + name: "successful airdrop update from memo", + forwardingActive: true, + packetData: transfertypes.FungibleTokenPacketData{ + Receiver: strideAddress, + Memo: getClaimPacketMetadata(strideAddress, evmosAirdropId), + }, + transferShouldSucceed: true, + airdropShouldUpdate: true, + }, + { + name: "memo receiver overrides original receiver field", + forwardingActive: true, + packetData: transfertypes.FungibleTokenPacketData{ + Receiver: "address-will-get-overriden", + Memo: getClaimPacketMetadata(strideAddress, evmosAirdropId), + }, + transferShouldSucceed: true, + airdropShouldUpdate: true, + }, + { + name: "valid receiver routing schema, but routing inactive", + forwardingActive: false, + packetData: transfertypes.FungibleTokenPacketData{ + Receiver: getClaimPacketMetadata(strideAddress, evmosAirdropId), + Memo: "", + }, + transferShouldSucceed: false, + airdropShouldUpdate: false, + }, + { + name: "valid memo routing schema, but routing inactive", + forwardingActive: false, + packetData: transfertypes.FungibleTokenPacketData{ + Receiver: getClaimPacketMetadata(strideAddress, evmosAirdropId), + Memo: "", + }, + transferShouldSucceed: false, + airdropShouldUpdate: false, + }, + { + name: "airdrop does not exist", + forwardingActive: true, + packetData: transfertypes.FungibleTokenPacketData{ + Receiver: strideAddress, + Memo: getClaimPacketMetadata(strideAddress, "fake_airdrop"), + }, + transferShouldSucceed: false, + airdropShouldUpdate: false, + }, + { + name: "invalid stride address", + forwardingActive: true, + packetData: transfertypes.FungibleTokenPacketData{ + Receiver: strideAddress, + Memo: getClaimPacketMetadata("invalid_address", evmosAirdropId), + }, + transferShouldSucceed: false, + airdropShouldUpdate: false, + }, + { + name: "normal transfer packet - no memo", + forwardingActive: true, + packetData: transfertypes.FungibleTokenPacketData{ + Receiver: strideAddress, + Memo: "", + }, + transferShouldSucceed: true, + airdropShouldUpdate: false, + }, + { + name: "normal transfer packet - empty JSON memo", + forwardingActive: true, + packetData: transfertypes.FungibleTokenPacketData{ + Receiver: strideAddress, + Memo: "{}", + }, + transferShouldSucceed: true, + airdropShouldUpdate: false, + }, + { + name: "normal transfer packet - different middleware", + forwardingActive: true, + packetData: transfertypes.FungibleTokenPacketData{ + Receiver: strideAddress, + Memo: `{ "other_module": { } }`, + }, + transferShouldSucceed: true, + airdropShouldUpdate: false, + }, + { + name: "invalid autopilot JSON - no receiver", + forwardingActive: true, + packetData: transfertypes.FungibleTokenPacketData{ + Receiver: strideAddress, + Memo: `{ "autopilot": {} }`, + }, + transferShouldSucceed: false, + airdropShouldUpdate: false, + }, + { + name: "invalid autopilot JSON - no routing module", + forwardingActive: true, + packetData: transfertypes.FungibleTokenPacketData{ + Receiver: strideAddress, + Memo: fmt.Sprintf(`{ "autopilot": { "receiver": "%s" } }`, strideAddress), + }, + transferShouldSucceed: false, + airdropShouldUpdate: false, + }, + { + name: "memo too long", + forwardingActive: true, + packetData: transfertypes.FungibleTokenPacketData{ + Receiver: strideAddress, + Memo: strings.Repeat("X", 300), + }, + transferShouldSucceed: false, + airdropShouldUpdate: false, + }, + { + name: "receiver too long", + forwardingActive: true, + packetData: transfertypes.FungibleTokenPacketData{ + Receiver: strings.Repeat("X", 300), + Memo: "", + }, + transferShouldSucceed: false, + airdropShouldUpdate: false, + }, + } + + for i, tc := range testCases { + s.Run(fmt.Sprintf("Case %d", i), func() { + s.SetupTest() + + // Update the autopilot active flag + s.App.AutopilotKeeper.SetParams(s.Ctx, types.Params{ClaimActive: tc.forwardingActive}) + + // Set evmos airdrop + airdrops := claimtypes.Params{ + Airdrops: []*claimtypes.Airdrop{{AirdropIdentifier: evmosAirdropId}}, + } + err := s.App.ClaimKeeper.SetParams(s.Ctx, airdrops) + s.Require().NoError(err, "no error expected when setting airdrop params") + + // Set claim records using key'd address + oldClaimRecord := claimtypes.ClaimRecord{ + AirdropIdentifier: evmosAirdropId, + Address: evmosAddressKeyString, + Weight: sdk.NewDec(10), + ActionCompleted: []bool{false, false, false}, + } + err = s.App.ClaimKeeper.SetClaimRecord(s.Ctx, oldClaimRecord) + s.Require().NoError(err, "no error expected when setting claim record") + + // Store the expected new claim record which should have the address changed + expectedNewClaimRecord := oldClaimRecord + expectedNewClaimRecord.Address = strideAddress + + // Replicate middleware stack + transferIBCModule := transfer.NewIBCModule(s.App.TransferKeeper) + autopilotStack := autopilot.NewIBCModule(s.App.AutopilotKeeper, transferIBCModule) + + // Update packet and packet data + packetData := packetDataTemplate + packetData.Memo = tc.packetData.Memo + packetData.Receiver = tc.packetData.Receiver + + packet := packetTemplate + packet.Data = transfertypes.ModuleCdc.MustMarshalJSON(&packetData) + + // Call OnRecvPacket for autopilot + ack := autopilotStack.OnRecvPacket( + s.Ctx, + packet, + sdk.AccAddress{}, + ) + + if tc.transferShouldSucceed { + s.Require().True(ack.Success(), "ack should be successful - ack: %+v", string(ack.Acknowledgement())) + + // Check funds were transferred + coin := s.App.BankKeeper.GetBalance(s.Ctx, sdk.MustAccAddressFromBech32(strideAddress), evmosIbcDenom) + s.Require().Equal(packetDataTemplate.Amount, coin.Amount.String(), "balance should have updated after successful transfer") + + if tc.airdropShouldUpdate { + // Check that we have a new record for the user + actualNewClaimRecord, err := s.App.ClaimKeeper.GetClaimRecord(s.Ctx, strideAccAddress, evmosAirdropId) + s.Require().NoError(err, "no error expected when getting new claim record") + s.Require().Equal(expectedNewClaimRecord, actualNewClaimRecord) + + // Check that the old record was removed (GetClaimRecord returns a zero-struct if not found) + oldClaimRecord, _ := s.App.ClaimKeeper.GetClaimRecord(s.Ctx, evmosAddressKey, evmosAirdropId) + s.Require().Equal("", oldClaimRecord.Address) + } else { + // If the airdrop code was never called, check that the old record claim record is still there + oldClaimRecordAfterTransfer, err := s.App.ClaimKeeper.GetClaimRecord(s.Ctx, evmosAddressKey, evmosAirdropId) + s.Require().NoError(err, "no error expected when getting old claim record") + s.Require().Equal(oldClaimRecord, oldClaimRecordAfterTransfer) + } + } else { + s.Require().False(ack.Success(), "ack should have failed - ack: %+v", string(ack.Acknowledgement())) + } + }) + } +} diff --git a/x/autopilot/keeper/grpc_query_params_test.go b/x/autopilot/keeper/grpc_query_params_test.go index a0c0a45b9c..9c55d7deea 100644 --- a/x/autopilot/keeper/grpc_query_params_test.go +++ b/x/autopilot/keeper/grpc_query_params_test.go @@ -7,15 +7,23 @@ import ( ) func (s *KeeperTestSuite) TestParamsQuery() { - // Test with app-route param active - s.App.AutopilotKeeper.SetParams(s.Ctx, types.Params{Active: true}) + // Test with stakeibc enabled and claim disabled + s.App.AutopilotKeeper.SetParams(s.Ctx, types.Params{ + StakeibcActive: true, + ClaimActive: false, + }) queryResponse, err := s.QueryClient.Params(context.Background(), &types.QueryParamsRequest{}) s.Require().NoError(err) - s.Require().True(queryResponse.Params.Active) + s.Require().True(queryResponse.Params.StakeibcActive) + s.Require().False(queryResponse.Params.ClaimActive) - // Test with app-route param in-active - s.App.AutopilotKeeper.SetParams(s.Ctx, types.Params{Active: false}) + // Test with claim enabled and stakeibc disabled + s.App.AutopilotKeeper.SetParams(s.Ctx, types.Params{ + StakeibcActive: false, + ClaimActive: true, + }) queryResponse, err = s.QueryClient.Params(context.Background(), &types.QueryParamsRequest{}) s.Require().NoError(err) - s.Require().False(queryResponse.Params.Active) + s.Require().False(queryResponse.Params.StakeibcActive) + s.Require().True(queryResponse.Params.ClaimActive) } diff --git a/x/autopilot/keeper/keeper.go b/x/autopilot/keeper/keeper.go index 1ea27f3b64..43684161b7 100644 --- a/x/autopilot/keeper/keeper.go +++ b/x/autopilot/keeper/keeper.go @@ -11,6 +11,7 @@ import ( paramtypes "github.com/cosmos/cosmos-sdk/x/params/types" "github.com/Stride-Labs/stride/v8/x/autopilot/types" + claimkeeper "github.com/Stride-Labs/stride/v8/x/claim/keeper" stakeibckeeper "github.com/Stride-Labs/stride/v8/x/stakeibc/keeper" ) @@ -20,6 +21,7 @@ type ( storeKey storetypes.StoreKey paramstore paramtypes.Subspace stakeibcKeeper stakeibckeeper.Keeper + claimKeeper claimkeeper.Keeper } ) @@ -28,6 +30,7 @@ func NewKeeper( storeKey storetypes.StoreKey, ps paramtypes.Subspace, stakeibcKeeper stakeibckeeper.Keeper, + claimKeeper claimkeeper.Keeper, ) *Keeper { // set KeyTable if it has not already been set if !ps.HasKeyTable() { @@ -39,6 +42,7 @@ func NewKeeper( storeKey: storeKey, paramstore: ps, stakeibcKeeper: stakeibcKeeper, + claimKeeper: claimKeeper, } } diff --git a/x/autopilot/keeper/liquidstake.go b/x/autopilot/keeper/liquidstake.go index f5348c967c..2d2ef4a56e 100644 --- a/x/autopilot/keeper/liquidstake.go +++ b/x/autopilot/keeper/liquidstake.go @@ -2,6 +2,7 @@ package keeper import ( "errors" + "fmt" errorsmod "cosmossdk.io/errors" @@ -9,7 +10,6 @@ import ( sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" transfertypes "github.com/cosmos/ibc-go/v5/modules/apps/transfer/types" channeltypes "github.com/cosmos/ibc-go/v5/modules/core/04-channel/types" - ibcexported "github.com/cosmos/ibc-go/v5/modules/core/exported" "github.com/Stride-Labs/stride/v8/x/autopilot/types" stakeibckeeper "github.com/Stride-Labs/stride/v8/x/stakeibc/keeper" @@ -20,22 +20,21 @@ func (k Keeper) TryLiquidStaking( ctx sdk.Context, packet channeltypes.Packet, newData transfertypes.FungibleTokenPacketData, - parsedReceiver *types.ParsedReceiver, - ack ibcexported.Acknowledgement, -) ibcexported.Acknowledgement { + packetMetadata types.StakeibcPacketMetadata, +) error { params := k.GetParams(ctx) - if !params.Active { - return channeltypes.NewErrorAcknowledgement(errors.New("packet forwarding param is not active")) + if !params.StakeibcActive { + return errorsmod.Wrapf(types.ErrPacketForwardingInactive, "autopilot stakeibc routing is inactive") } // In this case, we can't process a liquid staking transaction, because we're dealing with STRD tokens if transfertypes.ReceiverChainIsSource(packet.GetSourcePort(), packet.GetSourceChannel(), newData.Denom) { - return channeltypes.NewErrorAcknowledgement(errors.New("the native token is not supported for liquid staking")) + return errors.New("the native token is not supported for liquid staking") } amount, ok := sdk.NewIntFromString(newData.Amount) if !ok { - return channeltypes.NewErrorAcknowledgement(errors.New("not a parsable amount field")) + return errors.New("not a parsable amount field") } // Note: newData.denom is base denom e.g. uatom - not ibc/xxx @@ -46,18 +45,19 @@ func (k Keeper) TryLiquidStaking( hostZone, err := k.stakeibcKeeper.GetHostZoneFromHostDenom(ctx, token.Denom) if err != nil { - return channeltypes.NewErrorAcknowledgement(err) + return fmt.Errorf("host zone not found for denom (%s)", token.Denom) } if hostZone.IbcDenom != ibcDenom { - return channeltypes.NewErrorAcknowledgement(errors.New("ibc denom is not equal to host zone ibc denom")) + return fmt.Errorf("ibc denom %s is not equal to host zone ibc denom %s", ibcDenom, hostZone.IbcDenom) } - err = k.RunLiquidStake(ctx, parsedReceiver.StrideAccAddress, token) + strideAddress, err := sdk.AccAddressFromBech32(packetMetadata.StrideAddress) if err != nil { - ack = channeltypes.NewErrorAcknowledgement(err) + return errorsmod.Wrapf(sdkerrors.ErrInvalidAddress, "invalid stride_address (%s) in autopilot memo", strideAddress) } - return ack + + return k.RunLiquidStake(ctx, strideAddress, token) } func (k Keeper) RunLiquidStake(ctx sdk.Context, addr sdk.AccAddress, token sdk.Coin) error { diff --git a/x/autopilot/keeper/liquidstake_test.go b/x/autopilot/keeper/liquidstake_test.go index 4a8f0483bf..0e5c200163 100644 --- a/x/autopilot/keeper/liquidstake_test.go +++ b/x/autopilot/keeper/liquidstake_test.go @@ -22,7 +22,17 @@ import ( stakeibctypes "github.com/Stride-Labs/stride/v8/x/stakeibc/types" ) -func (suite *KeeperTestSuite) TestOnRecvPacket() { +func getStakeibcPacketMetadata(address, action string) string { + return fmt.Sprintf(` + { + "autopilot": { + "receiver": "%[1]s", + "stakeibc": { "stride_address": "%[1]s", "action": "%[2]s" } + } + }`, address, action) +} + +func (suite *KeeperTestSuite) TestLiquidStakeOnRecvPacket() { now := time.Now() packet := channeltypes.Packet{ @@ -61,7 +71,7 @@ func (suite *KeeperTestSuite) TestOnRecvPacket() { Denom: "uatom", Amount: "1000000", Sender: "cosmos16plylpsgxechajltx9yeseqexzdzut9g8vla4k", - Receiver: fmt.Sprintf("%s|stakeibc/LiquidStake", addr1.String()), + Receiver: getStakeibcPacketMetadata(addr1.String(), "LiquidStake"), Memo: "", }, destChannel: "channel-0", @@ -75,7 +85,7 @@ func (suite *KeeperTestSuite) TestOnRecvPacket() { Denom: strdIbcDenom, Amount: "1000000", Sender: "cosmos16plylpsgxechajltx9yeseqexzdzut9g8vla4k", - Receiver: fmt.Sprintf("%s|stakeibc/LiquidStake", addr1.String()), + Receiver: getStakeibcPacketMetadata(addr1.String(), "LiquidStake"), Memo: "", }, destChannel: "channel-0", @@ -89,7 +99,7 @@ func (suite *KeeperTestSuite) TestOnRecvPacket() { Denom: "uatom", Amount: "1000000", Sender: "cosmos16plylpsgxechajltx9yeseqexzdzut9g8vla4k", - Receiver: fmt.Sprintf("%s|stakeibc/LiquidStake", addr1.String()), + Receiver: getStakeibcPacketMetadata(addr1.String(), "LiquidStake"), Memo: "", }, destChannel: "channel-0", @@ -103,7 +113,7 @@ func (suite *KeeperTestSuite) TestOnRecvPacket() { Denom: "uatom", Amount: "1000000", Sender: "cosmos16plylpsgxechajltx9yeseqexzdzut9g8vla4k", - Receiver: fmt.Sprintf("%s|stakeibc/LiquidStake", addr1.String()), + Receiver: getStakeibcPacketMetadata(addr1.String(), "LiquidStake"), Memo: "", }, destChannel: "channel-1000", @@ -118,7 +128,7 @@ func (suite *KeeperTestSuite) TestOnRecvPacket() { Amount: "1000000", Sender: "cosmos16plylpsgxechajltx9yeseqexzdzut9g8vla4k", Receiver: addr1.String(), - Memo: "stakeibc/LiquidStake", + Memo: getStakeibcPacketMetadata(addr1.String(), "LiquidStake"), }, destChannel: "channel-0", recvDenom: atomIbcDenom, @@ -139,13 +149,13 @@ func (suite *KeeperTestSuite) TestOnRecvPacket() { expSuccess: true, expLiquidStake: false, }, - { // invalid receiver + { // invalid stride address (receiver) forwardingActive: true, packetData: transfertypes.FungibleTokenPacketData{ Denom: "uatom", Amount: "1000000", Sender: "cosmos16plylpsgxechajltx9yeseqexzdzut9g8vla4k", - Receiver: "xxx|stakeibc/LiquidStake", + Receiver: getStakeibcPacketMetadata("invalid_address", "LiquidStake"), Memo: "", }, destChannel: "channel-0", @@ -153,14 +163,14 @@ func (suite *KeeperTestSuite) TestOnRecvPacket() { expSuccess: false, expLiquidStake: false, }, - { // invalid receiver liquid staking + { // invalid stride address (memo) forwardingActive: true, packetData: transfertypes.FungibleTokenPacketData{ Denom: "uatom", Amount: "1000000", Sender: "cosmos16plylpsgxechajltx9yeseqexzdzut9g8vla4k", - Receiver: "xxx|stakeibc/LiquidStake", - Memo: "", + Receiver: addr1.String(), + Memo: getStakeibcPacketMetadata("invalid_address", "LiquidStake"), }, destChannel: "channel-0", recvDenom: atomIbcDenom, @@ -177,7 +187,7 @@ func (suite *KeeperTestSuite) TestOnRecvPacket() { suite.SetupTest() // reset ctx := suite.Ctx - suite.App.AutopilotKeeper.SetParams(ctx, types.Params{Active: tc.forwardingActive}) + suite.App.AutopilotKeeper.SetParams(ctx, types.Params{StakeibcActive: tc.forwardingActive}) // set epoch tracker for env suite.App.StakeibcKeeper.SetEpochTracker(ctx, stakeibctypes.EpochTracker{ @@ -230,18 +240,22 @@ func (suite *KeeperTestSuite) TestOnRecvPacket() { addr1, ) if tc.expSuccess { - suite.Require().True(ack.Success(), string(ack.Acknowledgement())) + 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(), allBalance.String()) + suite.Require().True(liquidBalance.Amount.IsPositive(), "liquid balance should be positive but was %s", allBalance.String()) } else { - suite.Require().True(liquidBalance.Amount.IsZero(), allBalance.String()) + suite.Require().True(liquidBalance.Amount.IsZero(), "liquid balance should be zero but was %s", allBalance.String()) } } else { - suite.Require().False(ack.Success(), string(ack.Acknowledgement())) + suite.Require().False(ack.Success(), "ack should have failed - ack: %+v", string(ack.Acknowledgement())) } }) } diff --git a/x/autopilot/keeper/params_test.go b/x/autopilot/keeper/params_test.go index 7aad1a5cb6..ba2c7ed27f 100644 --- a/x/autopilot/keeper/params_test.go +++ b/x/autopilot/keeper/params_test.go @@ -6,7 +6,8 @@ import ( func (s *KeeperTestSuite) TestGetParams() { params := types.DefaultParams() - params.Active = true + params.StakeibcActive = false + params.ClaimActive = true s.App.AutopilotKeeper.SetParams(s.Ctx, params) diff --git a/x/autopilot/module_ibc.go b/x/autopilot/module_ibc.go index 90acb5035e..11adcb70ed 100644 --- a/x/autopilot/module_ibc.go +++ b/x/autopilot/module_ibc.go @@ -3,6 +3,8 @@ package autopilot import ( "fmt" + errorsmod "cosmossdk.io/errors" + sdk "github.com/cosmos/cosmos-sdk/types" capabilitytypes "github.com/cosmos/cosmos-sdk/x/capability/types" ibctransfertypes "github.com/cosmos/ibc-go/v5/modules/apps/transfer/types" @@ -16,6 +18,8 @@ import ( ibcexported "github.com/cosmos/ibc-go/v5/modules/core/exported" ) +const MaxMemoCharLength = 256 + // IBC MODULE IMPLEMENTATION // IBCModule implements the ICS26 interface for transfer given the transfer keeper. // TODO: Use IBCMiddleware struct @@ -117,43 +121,56 @@ func (im IBCModule) OnRecvPacket( packet channeltypes.Packet, relayer sdk.AccAddress, ) ibcexported.Acknowledgement { + im.keeper.Logger(ctx).Info(fmt.Sprintf("OnRecvPacket (autopilot): Sequence: %d, Source: %s, %s; Destination: %s, %s", + packet.Sequence, packet.SourcePort, packet.SourceChannel, packet.DestinationPort, packet.DestinationChannel)) + // NOTE: acknowledgement will be written synchronously during IBC handler execution. var data transfertypes.FungibleTokenPacketData if err := transfertypes.ModuleCdc.UnmarshalJSON(packet.GetData(), &data); err != nil { return channeltypes.NewErrorAcknowledgement(err) } - // to be utilized from ibc-go v5.1.0 - if data.Memo == "stakeibc/LiquidStake" { - strideAccAddress, err := sdk.AccAddressFromBech32(data.Receiver) - if err != nil { - return channeltypes.NewErrorAcknowledgement(err) - } + // Error any transactions with a Memo or Receiver field are greater than the max characters + if len(data.Memo) > MaxMemoCharLength { + return channeltypes.NewErrorAcknowledgement(errorsmod.Wrapf(types.ErrInvalidMemoSize, "memo length: %d", len(data.Memo))) + } + if len(data.Receiver) > MaxMemoCharLength { + return channeltypes.NewErrorAcknowledgement(errorsmod.Wrapf(types.ErrInvalidMemoSize, "receiver length: %d", len(data.Receiver))) + } - ack := im.app.OnRecvPacket(ctx, packet, relayer) - if ack.Success() { - return im.keeper.TryLiquidStaking(ctx, packet, data, &types.ParsedReceiver{ - ShouldLiquidStake: true, - StrideAccAddress: strideAccAddress, - }, ack) - } - return ack + // ibc-go v5 has a Memo field that can store forwarding info + // For older version of ibc-go, the data must be stored in the receiver field + var metadata string + if data.Memo != "" { // ibc-go v5+ + metadata = data.Memo + } else { // before ibc-go v5 + metadata = data.Receiver + } + + // If a valid receiver address has been provided and no memo, + // this is clearly just an normal IBC transfer + // Pass down the stack immediately instead of parsing + _, err := sdk.AccAddressFromBech32(data.Receiver) + if err == nil && data.Memo == "" { + return im.app.OnRecvPacket(ctx, packet, relayer) } // parse out any forwarding info - parsedReceiver, err := types.ParseReceiverData(data.Receiver) + packetForwardMetadata, err := types.ParsePacketMetadata(metadata) if err != nil { return channeltypes.NewErrorAcknowledgement(err) } - // move on to the next middleware - if !parsedReceiver.ShouldLiquidStake { + // If the parsed metadata is nil, that means there is no forwarding logic + // Pass the packet down to the next middleware + if packetForwardMetadata == nil { return im.app.OnRecvPacket(ctx, packet, relayer) } - // Modify packet data to process packet transfer for this chain, omitting liquid staking info + // Modify the packet data by replacing the JSON metadata field with a receiver address + // to allow the packet to continue down the stack newData := data - newData.Receiver = parsedReceiver.StrideAccAddress.String() + newData.Receiver = packetForwardMetadata.Receiver bz, err := transfertypes.ModuleCdc.MarshalJSON(&newData) if err != nil { return channeltypes.NewErrorAcknowledgement(err) @@ -161,13 +178,50 @@ func (im IBCModule) OnRecvPacket( newPacket := packet newPacket.Data = bz - // process the transfer receipt - // NOTE: this code is pulled from packet-forwarding-middleware + // Pass the new packet down the middleware stack first ack := im.app.OnRecvPacket(ctx, newPacket, relayer) - if ack.Success() { - return im.keeper.TryLiquidStaking(ctx, packet, newData, parsedReceiver, ack) + if !ack.Success() { + return ack + } + + autopilotParams := im.keeper.GetParams(ctx) + + // If the transfer was successful, then route to the corresponding module, if applicable + switch routingInfo := packetForwardMetadata.RoutingInfo.(type) { + case types.StakeibcPacketMetadata: + // If stakeibc routing is inactive (but the packet had routing info in the memo) return an ack error + if !autopilotParams.StakeibcActive { + im.keeper.Logger(ctx).Error(fmt.Sprintf("Packet from %s had stakeibc routing info but autopilot stakeibc routing is disabled", newData.Sender)) + return channeltypes.NewErrorAcknowledgement(types.ErrPacketForwardingInactive) + } + im.keeper.Logger(ctx).Info(fmt.Sprintf("Forwaring packet from %s to stakeibc", newData.Sender)) + + // Try to liquid stake - return an ack error if it fails, otherwise return the ack generated from the earlier packet propogation + if err := im.keeper.TryLiquidStaking(ctx, packet, newData, routingInfo); err != nil { + im.keeper.Logger(ctx).Error(fmt.Sprintf("Error liquid staking packet from autopilot for %s: %s", newData.Sender, err.Error())) + return channeltypes.NewErrorAcknowledgement(err) + } + + return ack + + case types.ClaimPacketMetadata: + // If claim routing is inactive (but the packet had routing info in the memo) return an ack error + if !autopilotParams.ClaimActive { + im.keeper.Logger(ctx).Error(fmt.Sprintf("Packet from %s had claim routing info but autopilot claim routing is disabled", newData.Sender)) + return channeltypes.NewErrorAcknowledgement(types.ErrPacketForwardingInactive) + } + im.keeper.Logger(ctx).Info(fmt.Sprintf("Forwaring packet from %s to claim", newData.Sender)) + + if err := im.keeper.TryUpdateAirdropClaim(ctx, newData, routingInfo); err != nil { + im.keeper.Logger(ctx).Error(fmt.Sprintf("Error updating airdrop claim from autopilot for %s: %s", newData.Sender, err.Error())) + return channeltypes.NewErrorAcknowledgement(err) + } + + return ack + + default: + return channeltypes.NewErrorAcknowledgement(errorsmod.Wrapf(types.ErrUnsupportedAutopilotRoute, "%T", routingInfo)) } - return ack } // OnAcknowledgementPacket implements the IBCModule interface diff --git a/x/autopilot/types/errors.go b/x/autopilot/types/errors.go index 76ce793318..ac6b59a3cc 100644 --- a/x/autopilot/types/errors.go +++ b/x/autopilot/types/errors.go @@ -6,5 +6,12 @@ import ( // x/autopilot module sentinel errors var ( - ErrInvalidReceiverData = errorsmod.Register(ModuleName, 1501, "invalid receiver data") + ErrInvalidPacketMetadata = errorsmod.Register(ModuleName, 1501, "invalid packet metadata") + ErrUnsupportedStakeibcAction = errorsmod.Register(ModuleName, 1502, "unsupported stakeibc action") + ErrInvalidClaimAirdropId = errorsmod.Register(ModuleName, 1503, "invalid claim airdrop ID (cannot be empty)") + ErrInvalidModuleRoutes = errorsmod.Register(ModuleName, 1504, "invalid number of module routes, only 1 module is allowed at a time") + ErrUnsupportedAutopilotRoute = errorsmod.Register(ModuleName, 1505, "unsupported autpilot route") + ErrInvalidReceiverAddress = errorsmod.Register(ModuleName, 1506, "receiver address must be specified when using autopilot") + ErrPacketForwardingInactive = errorsmod.Register(ModuleName, 1507, "autopilot packet forwarding is disabled") + ErrInvalidMemoSize = errorsmod.Register(ModuleName, 1508, "the memo or receiver field exceeded the max allowable size") ) diff --git a/x/autopilot/types/params.go b/x/autopilot/types/params.go index 0204f52458..cab76fba31 100644 --- a/x/autopilot/types/params.go +++ b/x/autopilot/types/params.go @@ -8,12 +8,14 @@ import ( ) const ( - // DefaultActive is the default value for the active param (set to true) - DefaultActive = false + // Default active value for each autopilot supported module + DefaultStakeibcActive = false + DefaultClaimActive = true ) // KeyActive is the store key for Params -var KeyActive = []byte("Active") +var KeyStakeibcActive = []byte("StakeibcActive") +var KeyClaimActive = []byte("ClaimActive") var _ paramtypes.ParamSet = (*Params)(nil) @@ -23,27 +25,32 @@ func ParamKeyTable() paramtypes.KeyTable { } // NewParams creates a new Params instance -func NewParams(active bool) Params { +func NewParams(stakeibcActive, claimActive bool) Params { return Params{ - Active: active, + StakeibcActive: stakeibcActive, + ClaimActive: claimActive, } } // DefaultParams returns a default set of parameters func DefaultParams() Params { - return NewParams(DefaultActive) + return NewParams(DefaultStakeibcActive, DefaultClaimActive) } // ParamSetPairs get the params.ParamSet func (p *Params) ParamSetPairs() paramtypes.ParamSetPairs { return paramtypes.ParamSetPairs{ - paramtypes.NewParamSetPair(KeyActive, &p.Active, validateActive), + paramtypes.NewParamSetPair(KeyStakeibcActive, &p.StakeibcActive, validateBool), + paramtypes.NewParamSetPair(KeyClaimActive, &p.ClaimActive, validateBool), } } // Validate validates the set of params func (p Params) Validate() error { - if err := validateActive(p.Active); err != nil { + if err := validateBool(p.StakeibcActive); err != nil { + return err + } + if err := validateBool(p.ClaimActive); err != nil { return err } @@ -56,7 +63,7 @@ func (p Params) String() string { return string(out) } -func validateActive(i interface{}) error { +func validateBool(i interface{}) error { _, ok := i.(bool) if !ok { return fmt.Errorf("invalid parameter type: %T", i) diff --git a/x/autopilot/types/params.pb.go b/x/autopilot/types/params.pb.go index 24b13212c9..b8cb8aa404 100644 --- a/x/autopilot/types/params.pb.go +++ b/x/autopilot/types/params.pb.go @@ -26,8 +26,9 @@ const _ = proto.GoGoProtoPackageIsVersion3 // please upgrade the proto package // Params defines the parameters for the module. // next id: 1 type Params struct { - // optionally, turn off this module - Active bool `protobuf:"varint,1,opt,name=active,proto3" json:"active,omitempty"` + // optionally, turn off each module + StakeibcActive bool `protobuf:"varint,1,opt,name=stakeibc_active,json=stakeibcActive,proto3" json:"stakeibc_active,omitempty"` + ClaimActive bool `protobuf:"varint,2,opt,name=claim_active,json=claimActive,proto3" json:"claim_active,omitempty"` } func (m *Params) Reset() { *m = Params{} } @@ -62,9 +63,16 @@ func (m *Params) XXX_DiscardUnknown() { var xxx_messageInfo_Params proto.InternalMessageInfo -func (m *Params) GetActive() bool { +func (m *Params) GetStakeibcActive() bool { if m != nil { - return m.Active + return m.StakeibcActive + } + return false +} + +func (m *Params) GetClaimActive() bool { + if m != nil { + return m.ClaimActive } return false } @@ -76,18 +84,20 @@ func init() { func init() { proto.RegisterFile("stride/autopilot/params.proto", fileDescriptor_b0b993e9f5195319) } var fileDescriptor_b0b993e9f5195319 = []byte{ - // 175 bytes of a gzipped FileDescriptorProto + // 207 bytes of a gzipped FileDescriptorProto 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xe2, 0x92, 0x2d, 0x2e, 0x29, 0xca, 0x4c, 0x49, 0xd5, 0x4f, 0x2c, 0x2d, 0xc9, 0x2f, 0xc8, 0xcc, 0xc9, 0x2f, 0xd1, 0x2f, 0x48, 0x2c, 0x4a, 0xcc, 0x2d, 0xd6, 0x2b, 0x28, 0xca, 0x2f, 0xc9, 0x17, 0x12, 0x80, 0x48, 0xeb, 0xc1, 0xa5, - 0xa5, 0x44, 0xd2, 0xf3, 0xd3, 0xf3, 0xc1, 0x92, 0xfa, 0x20, 0x16, 0x44, 0x9d, 0x92, 0x1a, 0x17, - 0x5b, 0x00, 0x58, 0x9f, 0x90, 0x18, 0x17, 0x5b, 0x62, 0x72, 0x49, 0x66, 0x59, 0xaa, 0x04, 0xa3, - 0x02, 0xa3, 0x06, 0x47, 0x10, 0x94, 0x67, 0xc5, 0x32, 0x63, 0x81, 0x3c, 0x83, 0x93, 0xcf, 0x89, - 0x47, 0x72, 0x8c, 0x17, 0x1e, 0xc9, 0x31, 0x3e, 0x78, 0x24, 0xc7, 0x38, 0xe1, 0xb1, 0x1c, 0xc3, - 0x85, 0xc7, 0x72, 0x0c, 0x37, 0x1e, 0xcb, 0x31, 0x44, 0x19, 0xa5, 0x67, 0x96, 0x64, 0x94, 0x26, - 0xe9, 0x25, 0xe7, 0xe7, 0xea, 0x07, 0x83, 0x2d, 0xd5, 0xf5, 0x49, 0x4c, 0x2a, 0xd6, 0x87, 0xba, - 0xaf, 0xcc, 0x42, 0xbf, 0x02, 0xc9, 0x91, 0x25, 0x95, 0x05, 0xa9, 0xc5, 0x49, 0x6c, 0x60, 0xcb, - 0x8d, 0x01, 0x01, 0x00, 0x00, 0xff, 0xff, 0xed, 0xc2, 0x8e, 0xaf, 0xc5, 0x00, 0x00, 0x00, + 0xa5, 0x44, 0xd2, 0xf3, 0xd3, 0xf3, 0xc1, 0x92, 0xfa, 0x20, 0x16, 0x44, 0x9d, 0x52, 0x14, 0x17, + 0x5b, 0x00, 0x58, 0x9f, 0x90, 0x3a, 0x17, 0x7f, 0x71, 0x49, 0x62, 0x76, 0x6a, 0x66, 0x52, 0x72, + 0x7c, 0x62, 0x72, 0x49, 0x66, 0x59, 0xaa, 0x04, 0xa3, 0x02, 0xa3, 0x06, 0x47, 0x10, 0x1f, 0x4c, + 0xd8, 0x11, 0x2c, 0x2a, 0xa4, 0xc8, 0xc5, 0x93, 0x9c, 0x93, 0x98, 0x99, 0x0b, 0x53, 0xc5, 0x04, + 0x56, 0xc5, 0x0d, 0x16, 0x83, 0x28, 0xb1, 0x62, 0x99, 0xb1, 0x40, 0x9e, 0xc1, 0xc9, 0xe7, 0xc4, + 0x23, 0x39, 0xc6, 0x0b, 0x8f, 0xe4, 0x18, 0x1f, 0x3c, 0x92, 0x63, 0x9c, 0xf0, 0x58, 0x8e, 0xe1, + 0xc2, 0x63, 0x39, 0x86, 0x1b, 0x8f, 0xe5, 0x18, 0xa2, 0x8c, 0xd2, 0x33, 0x4b, 0x32, 0x4a, 0x93, + 0xf4, 0x92, 0xf3, 0x73, 0xf5, 0x83, 0xc1, 0x0e, 0xd5, 0xf5, 0x49, 0x4c, 0x2a, 0xd6, 0x87, 0xfa, + 0xa9, 0xcc, 0x42, 0xbf, 0x02, 0xc9, 0x63, 0x25, 0x95, 0x05, 0xa9, 0xc5, 0x49, 0x6c, 0x60, 0x07, + 0x1b, 0x03, 0x02, 0x00, 0x00, 0xff, 0xff, 0x03, 0xcc, 0x29, 0x23, 0xf9, 0x00, 0x00, 0x00, } func (m *Params) Marshal() (dAtA []byte, err error) { @@ -110,9 +120,19 @@ func (m *Params) MarshalToSizedBuffer(dAtA []byte) (int, error) { _ = i var l int _ = l - if m.Active { + if m.ClaimActive { + i-- + if m.ClaimActive { + dAtA[i] = 1 + } else { + dAtA[i] = 0 + } + i-- + dAtA[i] = 0x10 + } + if m.StakeibcActive { i-- - if m.Active { + if m.StakeibcActive { dAtA[i] = 1 } else { dAtA[i] = 0 @@ -140,7 +160,10 @@ func (m *Params) Size() (n int) { } var l int _ = l - if m.Active { + if m.StakeibcActive { + n += 2 + } + if m.ClaimActive { n += 2 } return n @@ -183,7 +206,27 @@ func (m *Params) Unmarshal(dAtA []byte) error { switch fieldNum { case 1: if wireType != 0 { - return fmt.Errorf("proto: wrong wireType = %d for field Active", wireType) + return fmt.Errorf("proto: wrong wireType = %d for field StakeibcActive", wireType) + } + var v int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowParams + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + v |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + m.StakeibcActive = bool(v != 0) + case 2: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field ClaimActive", wireType) } var v int for shift := uint(0); ; shift += 7 { @@ -200,7 +243,7 @@ func (m *Params) Unmarshal(dAtA []byte) error { break } } - m.Active = bool(v != 0) + m.ClaimActive = bool(v != 0) default: iNdEx = preIndex skippy, err := skipParams(dAtA[iNdEx:]) diff --git a/x/autopilot/types/parser.go b/x/autopilot/types/parser.go index 4043363cdf..06386968ab 100644 --- a/x/autopilot/types/parser.go +++ b/x/autopilot/types/parser.go @@ -1,51 +1,117 @@ package types import ( + "encoding/json" "strings" + errorsmod "cosmossdk.io/errors" + sdk "github.com/cosmos/cosmos-sdk/types" ) -type ParsedReceiver struct { - ShouldLiquidStake bool - StrideAccAddress sdk.AccAddress -} - -// {stride_address}|{module_id}/{function_id} -// for now, only allow liquid staking transactions -// Note: some addresses that previously *could not* easily liquid stake, now *can* (e.g. module accounts) -// stride10d07y265gmmuvt4z0w9aw880jnsr700jefnezl|stakeibc/LiquidStake -// need args: Creator, HostDenom, Amount -func ParseReceiverData(receiverData string) (*ParsedReceiver, error) { - // TODO: Use JSON parsing instead of string parsing - parts := strings.Split(receiverData, "|") - - switch len(parts) { - case 1: - return &ParsedReceiver{ - ShouldLiquidStake: false, - }, nil - case 2: - addressPart := parts[0] - functionPart := parts[1] - - // verify the rightmost field is stakeibc/LiquidStake - if functionPart != "stakeibc/LiquidStake" { - return &ParsedReceiver{ - ShouldLiquidStake: false, - }, nil - } - - strideAccAddress, err := sdk.AccAddressFromBech32(addressPart) - if err != nil { - return nil, err - } - - return &ParsedReceiver{ - ShouldLiquidStake: true, - StrideAccAddress: strideAccAddress, - }, nil - default: - return nil, ErrInvalidReceiverData +type RawPacketMetadata struct { + Autopilot *struct { + Receiver string `json:"receiver"` + Stakeibc *StakeibcPacketMetadata `json:"stakeibc,omitempty"` + Claim *ClaimPacketMetadata `json:"claim,omitempty"` + } `json:"autopilot"` +} + +type PacketForwardMetadata struct { + Receiver string + RoutingInfo ModuleRoutingInfo +} + +type ModuleRoutingInfo interface { + Validate() error +} + +// Packet metadata info specific to Stakeibc (e.g. 1-click liquid staking) +type StakeibcPacketMetadata struct { + Action string `json:"action"` + StrideAddress string `json:"stride_address"` +} + +// Packet metadata info specific to Claim (e.g. airdrops for non-118 coins) +type ClaimPacketMetadata struct { + AirdropId string `json:"airdrop_id"` + StrideAddress string `json:"stride_address"` +} + +// Validate stakeibc packet metadata fields +// including the stride address and action type +func (m StakeibcPacketMetadata) Validate() error { + _, err := sdk.AccAddressFromBech32(m.StrideAddress) + if err != nil { + return err + } + if m.Action != "LiquidStake" { + return errorsmod.Wrapf(ErrUnsupportedStakeibcAction, "action %s is not supported", m.Action) + } + + return nil +} + +// Validate claim packet metadata fields including the +// stride address and Airdrop type +func (m ClaimPacketMetadata) Validate() error { + _, err := sdk.AccAddressFromBech32(m.StrideAddress) + if err != nil { + return err } + if len(strings.TrimSpace(m.AirdropId)) == 0 { + return ErrInvalidClaimAirdropId + } + + return nil +} + +// Parse packet metadata intended for autopilot +// In the ICS-20 packet, the metadata can optionally indicate a module to route to (e.g. stakeibc) +// The PacketForwardMetadata returned from this function contains attributes for each autopilot supported module +// It can only be forward to one module per packet +// Returns nil if there was no metadata found +func ParsePacketMetadata(metadata string) (*PacketForwardMetadata, error) { + // If we can't unmarshal the metadata into a PacketMetadata struct, + // assume packet forwarding was no used and pass back nil so that autopilot is ignored + var raw RawPacketMetadata + if err := json.Unmarshal([]byte(metadata), &raw); err != nil { + return nil, nil + } + + // If no forwarding logic was used for autopilot, return the metadata with each disabled + if raw.Autopilot == nil { + return nil, nil + } + + // Confirm a receiver address was supplied + if _, err := sdk.AccAddressFromBech32(raw.Autopilot.Receiver); err != nil { + return nil, errorsmod.Wrapf(ErrInvalidPacketMetadata, ErrInvalidReceiverAddress.Error()) + } + + // Parse the packet info into the specific module type + // We increment the module count to ensure only one module type was provided + moduleCount := 0 + var routingInfo ModuleRoutingInfo + if raw.Autopilot.Stakeibc != nil { + moduleCount++ + routingInfo = *raw.Autopilot.Stakeibc + } + if raw.Autopilot.Claim != nil { + moduleCount++ + routingInfo = *raw.Autopilot.Claim + } + if moduleCount != 1 { + return nil, errorsmod.Wrapf(ErrInvalidPacketMetadata, ErrInvalidModuleRoutes.Error()) + } + + // Validate the packet info according to the specific module type + if err := routingInfo.Validate(); err != nil { + return nil, errorsmod.Wrapf(err, ErrInvalidPacketMetadata.Error()) + } + + return &PacketForwardMetadata{ + Receiver: raw.Autopilot.Receiver, + RoutingInfo: routingInfo, + }, nil } diff --git a/x/autopilot/types/parser_test.go b/x/autopilot/types/parser_test.go index f5a0811fc3..a8c640f656 100644 --- a/x/autopilot/types/parser_test.go +++ b/x/autopilot/types/parser_test.go @@ -1,57 +1,271 @@ -package types +package types_test import ( + fmt "fmt" "testing" - sdk "github.com/cosmos/cosmos-sdk/types" "github.com/stretchr/testify/require" -) -const Bech32Prefix = "stride" + "github.com/Stride-Labs/stride/v8/app/apptesting" + "github.com/Stride-Labs/stride/v8/x/autopilot/types" +) func init() { - config := sdk.GetConfig() - valoper := sdk.PrefixValidator + sdk.PrefixOperator - valoperpub := sdk.PrefixValidator + sdk.PrefixOperator + sdk.PrefixPublic - config.SetBech32PrefixForAccount(Bech32Prefix, Bech32Prefix+sdk.PrefixPublic) - config.SetBech32PrefixForValidator(Bech32Prefix+valoper, Bech32Prefix+valoperpub) + apptesting.SetupConfig() +} + +func getStakeibcMemo(address, action string) string { + return fmt.Sprintf(` + { + "autopilot": { + "receiver": "%[1]s", + "stakeibc": { "stride_address": "%[1]s", "action": "%[2]s" } + } + }`, address, action) } -func TestParseReceiverDataTransfer(t *testing.T) { - data := "stride10d07y265gmmuvt4z0w9aw880jnsr700jefnezl|stakeibc/LiquidStake" - pt, err := ParseReceiverData(data) +func getClaimMemo(address, airdropId string) string { + return fmt.Sprintf(` + { + "autopilot": { + "receiver": "%[1]s", + "claim": { "stride_address": "%[1]s", "airdrop_id": "%[2]s" } + } + }`, address, airdropId) +} + +func getClaimAndStakeibcMemo(address, action, airdropId string) string { + return fmt.Sprintf(` + { + "autopilot": { + "receiver": "%[1]s", + "stakeibc": { "stride_address": "%[1]s", "action": "%[2]s" }, + "claim": { "stride_address": "%[1]s", "airdrop_id": "%[3]s" } + } + }`, address, action, airdropId) +} - require.NoError(t, err) - require.True(t, pt.ShouldLiquidStake) - require.Equal(t, pt.StrideAccAddress.String(), "stride10d07y265gmmuvt4z0w9aw880jnsr700jefnezl") +// Helper function to check the routingInfo with a switch statement +// This isn't the most efficient way to check the type (require.TypeOf could be used instead) +// but it better aligns with how the routing info is checked in module_ibc +func checkModuleRoutingInfoType(routingInfo types.ModuleRoutingInfo, expectedType string) bool { + switch routingInfo.(type) { + case types.StakeibcPacketMetadata: + return expectedType == "stakeibc" + case types.ClaimPacketMetadata: + return expectedType == "claim" + default: + return false + } } -func TestParseReceiverDataNoTransfer(t *testing.T) { - data := "cosmos16plylpsgxechajltx9yeseqexzdzut9g8vla4k" - pt, err := ParseReceiverData(data) +func TestParsePacketMetadata(t *testing.T) { + validAddress, invalidAddress := apptesting.GenerateTestAddrs() + validStakeibcAction := "LiquidStake" + validAirdropId := "gaia" - require.NoError(t, err) - require.False(t, pt.ShouldLiquidStake) + validParsedStakeibcPacketMetadata := types.StakeibcPacketMetadata{ + StrideAddress: validAddress, + Action: validStakeibcAction, + } + + validParsedClaimPacketMetadata := types.ClaimPacketMetadata{ + StrideAddress: validAddress, + AirdropId: validAirdropId, + } + + testCases := []struct { + name string + metadata string + parsedStakeibc *types.StakeibcPacketMetadata + parsedClaim *types.ClaimPacketMetadata + expectedNilMetadata bool + expectedErr string + }{ + { + name: "valid stakeibc memo", + metadata: getStakeibcMemo(validAddress, validStakeibcAction), + parsedStakeibc: &validParsedStakeibcPacketMetadata, + }, + { + name: "valid claim memo", + metadata: getClaimMemo(validAddress, validAirdropId), + parsedClaim: &validParsedClaimPacketMetadata, + }, + { + name: "normal IBC transfer", + metadata: validAddress, // normal address - not autopilot JSON + expectedNilMetadata: true, + }, + { + name: "empty memo", + metadata: "", + expectedNilMetadata: true, + }, + { + name: "empty JSON memo", + metadata: "{}", + expectedNilMetadata: true, + }, + { + name: "different module specified", + metadata: `{ "other_module": { } }`, + expectedNilMetadata: true, + }, + { + name: "empty receiver address", + metadata: `{ "autopilot": { } }`, + expectedErr: "receiver address must be specified when using autopilot", + }, + { + name: "invalid receiver address", + metadata: `{ "autopilot": { "receiver": "invalid_address" } }`, + expectedErr: "receiver address must be specified when using autopilot", + }, + { + name: "invalid stakeibc address", + metadata: getStakeibcMemo(invalidAddress, validStakeibcAction), + expectedErr: "receiver address must be specified when using autopilot", + }, + { + name: "invalid stakeibc action", + metadata: getStakeibcMemo(validAddress, "bad_action"), + expectedErr: "unsupported stakeibc action", + }, + { + name: "invalid claim address", + metadata: getClaimMemo(invalidAddress, validAirdropId), + expectedErr: "receiver address must be specified when using autopilot", + }, + { + name: "invalid claim airdrop", + metadata: getClaimMemo(validAddress, ""), + expectedErr: "invalid claim airdrop ID", + }, + { + name: "both claim and stakeibc memo set", + metadata: getClaimAndStakeibcMemo(validAddress, validStakeibcAction, validAirdropId), + expectedErr: "invalid number of module routes", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + parsedData, actualErr := types.ParsePacketMetadata(tc.metadata) + + if tc.expectedErr == "" { + require.NoError(t, actualErr) + if tc.expectedNilMetadata { + require.Nil(t, parsedData, "parsed data response should be nil") + } else { + if tc.parsedStakeibc != nil { + checkModuleRoutingInfoType(parsedData.RoutingInfo, "stakeibc") + routingInfo, ok := parsedData.RoutingInfo.(types.StakeibcPacketMetadata) + require.True(t, ok, "routing info should be stakeibc") + require.Equal(t, *tc.parsedStakeibc, routingInfo, "parsed stakeibc value") + } else if tc.parsedClaim != nil { + checkModuleRoutingInfoType(parsedData.RoutingInfo, "claim") + routingInfo, ok := parsedData.RoutingInfo.(types.ClaimPacketMetadata) + require.True(t, ok, "routing info should be claim") + require.Equal(t, *tc.parsedClaim, routingInfo, "parsed claim value") + } + } + } else { + require.ErrorContains(t, actualErr, types.ErrInvalidPacketMetadata.Error(), "expected error type for %s", tc.name) + require.ErrorContains(t, actualErr, tc.expectedErr, "expected error for %s", tc.name) + } + }) + } } -func TestParseReceiverDataErrors(t *testing.T) { - // empty transfer field - pt, err := ParseReceiverData("") - require.NoError(t, err) - require.False(t, pt.ShouldLiquidStake) +func TestValidateStakeibcPacketMetadata(t *testing.T) { + validAddress, _ := apptesting.GenerateTestAddrs() + validAction := "LiquidStake" + + testCases := []struct { + name string + metadata *types.StakeibcPacketMetadata + expectedErr string + }{ + { + name: "valid Metadata data", + metadata: &types.StakeibcPacketMetadata{ + StrideAddress: validAddress, + Action: validAction, + }, + }, + { + name: "invalid address", + metadata: &types.StakeibcPacketMetadata{ + StrideAddress: "bad_address", + Action: validAction, + }, + expectedErr: "decoding bech32 failed", + }, + { + name: "invalid action", + metadata: &types.StakeibcPacketMetadata{ + StrideAddress: validAddress, + Action: "bad_action", + }, + expectedErr: "unsupported stakeibc action", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + actualErr := tc.metadata.Validate() + if tc.expectedErr == "" { + require.NoError(t, actualErr, "no error expected for %s", tc.name) + } else { + require.ErrorContains(t, actualErr, tc.expectedErr, "error expected for %s", tc.name) + } + }) + } +} - // invalid string - pt, err = ParseReceiverData("abc:def:") - require.NoError(t, err) - require.False(t, pt.ShouldLiquidStake) +func TestValidateClaimPacketMetadata(t *testing.T) { + validAddress, _ := apptesting.GenerateTestAddrs() + validAirdropId := "gaia" - // invalid function - pt, err = ParseReceiverData("stride10d07y265gmmuvt4z0w9aw880jnsr700jefnezl|stakeibc/xxx") - require.NoError(t, err) - require.False(t, pt.ShouldLiquidStake) + testCases := []struct { + name string + metadata *types.ClaimPacketMetadata + expectedErr string + }{ + { + name: "valid metadata", + metadata: &types.ClaimPacketMetadata{ + StrideAddress: validAddress, + AirdropId: validAirdropId, + }, + }, + { + name: "invalid address", + metadata: &types.ClaimPacketMetadata{ + StrideAddress: "bad_address", + AirdropId: validAirdropId, + }, + expectedErr: "decoding bech32 failed", + }, + { + name: "invalid airdrop-id", + metadata: &types.ClaimPacketMetadata{ + StrideAddress: validAddress, + AirdropId: "", + }, + expectedErr: "invalid claim airdrop ID", + }, + } - // invalid address - pt, err = ParseReceiverData("xxx|stakeibc/LiquidStake") - require.EqualError(t, err, "decoding bech32 failed: invalid bech32 string length 3") - require.Nil(t, pt) + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + actualErr := tc.metadata.Validate() + if tc.expectedErr == "" { + require.NoError(t, actualErr, "no error expected for %s", tc.name) + } else { + require.ErrorContains(t, actualErr, tc.expectedErr, "error expected for %s", tc.name) + } + }) + } } diff --git a/x/claim/README.md b/x/claim/README.md index 549a770f93..0a2f360919 100644 --- a/x/claim/README.md +++ b/x/claim/README.md @@ -54,6 +54,21 @@ type ClaimRecord struct { ``` +## A Note on Address Mappings + +When an airdrop is created, we call `LoadAllocationData` to load the airdrop data from the allocations file. +This will apply `utils.ConvertAddressToStrideAddress` on each of those addresses, and then store those with the `ClaimRecords`. +For an airdrop to, say, the Cosmos Hub, this will be the proper Stride address associated with that account. +`claim` state will only ever store this Stride address. + +However, for zones with a different coin type, _this will be an incorrect Stride address_. This should not cause any issues though, +as this Stride address will be unusable. + +In order to claim that airdrop, the user will have to verify that they own the corresponding Evmos address. When the user tries to verify, +we call `utils.ConvertAddressToStrideAddress` on that address, and verify it gives the same "incorrect" Stride address from earlier. +Through this, we can confirm that the user owns the Evmos address. +We then replace the Stride address with a "correct" one that the user verifies they own. + ## Params The airdrop logic has 4 parameters: diff --git a/x/claim/keeper/claim.go b/x/claim/keeper/claim.go index 1208e9eb6a..168a4a8d83 100644 --- a/x/claim/keeper/claim.go +++ b/x/claim/keeper/claim.go @@ -356,6 +356,15 @@ func (k Keeper) SetClaimRecord(ctx sdk.Context, claimRecord types.ClaimRecord) e return nil } +func (k Keeper) DeleteClaimRecord(ctx sdk.Context, addr sdk.AccAddress, airdropId string) error { + store := ctx.KVStore(k.storeKey) + prefixStore := prefix.NewStore(store, append([]byte(types.ClaimRecordsStorePrefix), []byte(airdropId)...)) + + prefixStore.Delete(addr) + + return nil +} + // Get airdrop distributor address func (k Keeper) GetAirdropDistributor(ctx sdk.Context, airdropIdentifier string) (sdk.AccAddress, error) { airdrop := k.GetAirdropByIdentifier(ctx, airdropIdentifier) @@ -806,3 +815,47 @@ func (k Keeper) DeleteAirdropAndEpoch(ctx sdk.Context, identifier string) error k.epochsKeeper.DeleteEpochInfo(ctx, fmt.Sprintf("airdrop-%s", identifier)) return k.SetParams(ctx, params) } + +func (k Keeper) UpdateAirdropAddress(ctx sdk.Context, existingStrideAddress string, newStrideAddress string, airdropId string) error { + airdrop := k.GetAirdropByIdentifier(ctx, airdropId) + if airdrop == nil { + return errorsmod.Wrapf(types.ErrAirdropNotFound, fmt.Sprintf("airdrop not found for identifier %s", airdropId)) + } + + // verify that the strideAddress is valid + _, err := sdk.AccAddressFromBech32(newStrideAddress) + if err != nil { + return errorsmod.Wrapf(sdkerrors.ErrInvalidAddress, fmt.Sprintf("invalid stride address %s", newStrideAddress)) + } + + // note: existingAccAddress will be a STRIDE address with the same coin type as existingAddress + // when new airdrops are ingested, we call utils.ConvertAddressToStrideAddress to convert + // the host zone (e.g. Evmos) address to a Stride address. The same conversion must be done + // if you're attempting to access a claim record for a non-Stride-address. + existingAccAddress, err := sdk.AccAddressFromBech32(existingStrideAddress) + if err != nil { + return errorsmod.Wrapf(types.ErrClaimNotFound, + fmt.Sprintf("error getting claim record for address %s on airdrop %s", existingStrideAddress, airdropId)) + } + claimRecord, err := k.GetClaimRecord(ctx, existingAccAddress, airdrop.AirdropIdentifier) + if (err != nil) || (claimRecord.Address == "") { + return errorsmod.Wrapf(types.ErrClaimNotFound, + fmt.Sprintf("error getting claim record for address %s on airdrop %s", existingStrideAddress, airdropId)) + } + + claimRecord.Address = newStrideAddress + err = k.SetClaimRecord(ctx, claimRecord) // this does NOT delete the old record, because claims are indexed by address + if err != nil { + return errorsmod.Wrapf(types.ErrModifyingClaimRecord, + fmt.Sprintf("error setting claim record from address %s to address %s on airdrop %s", existingStrideAddress, newStrideAddress, airdropId)) + } + + // this deletes the old record + err = k.DeleteClaimRecord(ctx, existingAccAddress, airdrop.AirdropIdentifier) + if err != nil { + return errorsmod.Wrapf(types.ErrModifyingClaimRecord, + fmt.Sprintf("error deleting claim record for address %s on airdrop %s", existingStrideAddress, airdropId)) + } + + return nil +} diff --git a/x/claim/keeper/claim_test.go b/x/claim/keeper/claim_test.go index 97081223fb..d6a8c28019 100644 --- a/x/claim/keeper/claim_test.go +++ b/x/claim/keeper/claim_test.go @@ -1,6 +1,7 @@ package keeper_test import ( + "strings" "time" "github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1" @@ -10,6 +11,7 @@ import ( vestingtypes "github.com/cosmos/cosmos-sdk/x/auth/vesting/types" "github.com/Stride-Labs/stride/v8/app/apptesting" + "github.com/Stride-Labs/stride/v8/utils" claimkeeper "github.com/Stride-Labs/stride/v8/x/claim/keeper" "github.com/Stride-Labs/stride/v8/x/claim/types" @@ -70,7 +72,7 @@ func (suite *KeeperTestSuite) TestHookOfUnclaimableAccount() { suite.Equal(sdk.Coins{}, balances) } -//Check balances before and after airdrop starts +// Check balances before and after airdrop starts func (suite *KeeperTestSuite) TestHookBeforeAirdropStart() { suite.SetupTest() @@ -667,3 +669,155 @@ func (suite *KeeperTestSuite) TestGetClaimMetadata() { } } } + +type UpdateAirdropTestCase struct { + airdropId string + evmosAddress string + strideAddress string + recordKey sdk.AccAddress +} + +func (suite *KeeperTestSuite) SetupUpdateAirdropAddressChangeTests() UpdateAirdropTestCase { + suite.SetupTest() + + airdropId := "osmosis" + + evmosAddress := "evmos1wg6vh689gw93umxqquhe3yaqf0h9wt9d4q7550" + strideAddress := "stride1svy5pga6g2er2wjrcujcrg0efce4pref8dksr9" + + recordKeyString := utils.ConvertAddressToStrideAddress(evmosAddress) + recordKey := sdk.MustAccAddressFromBech32(recordKeyString) + + claimRecord := types.ClaimRecord{ + Address: recordKeyString, + Weight: sdk.NewDecWithPrec(50, 2), // 50% + ActionCompleted: []bool{false, false, false}, + AirdropIdentifier: airdropId, + } + + err := suite.app.ClaimKeeper.SetClaimRecordsWithWeights(suite.ctx, []types.ClaimRecord{claimRecord}) + suite.Require().NoError(err) + + // Create stride account so that it can claim + suite.app.AccountKeeper.SetAccount(suite.ctx, &authtypes.BaseAccount{Address: strideAddress}) + + return UpdateAirdropTestCase{ + airdropId: airdropId, + evmosAddress: evmosAddress, + recordKey: recordKey, + strideAddress: strideAddress, + } +} + +func (suite *KeeperTestSuite) TestUpdateAirdropAddress() { + tc := suite.SetupUpdateAirdropAddressChangeTests() + + strideAccAddress := sdk.MustAccAddressFromBech32(tc.strideAddress) + airdropClaimCoins := sdk.NewCoins(sdk.NewInt64Coin(sdk.DefaultBondDenom, 100_000_000)) + + // verify that the Evmos address is different from the address in the key used to store the claim + suite.Require().NotEqual(tc.evmosAddress, tc.recordKey.String(), "evmos address should not equal the address key") + // verify new Evmos address starts with "stride" + suite.Require().True(strings.HasPrefix(tc.recordKey.String(), "stride"), "evmos address should start with stride") + + // Confirm that the user (using the old key'd address) has claimable tokens + coins, err := suite.app.ClaimKeeper.GetUserTotalClaimable(suite.ctx, tc.recordKey, tc.airdropId, false) + suite.Require().NoError(err) + suite.Require().Equal(coins.String(), airdropClaimCoins.String()) + + // verify that we can't yet claim with the stride address (because it hasn't been remapped yet) + coins, err = suite.app.ClaimKeeper.GetUserTotalClaimable(suite.ctx, strideAccAddress, tc.airdropId, false) + suite.Require().NoError(err) + suite.Require().Equal(coins, sdk.NewCoins(), "stride address should claim 0 coins before update", strideAccAddress) + + claims, err := suite.app.ClaimKeeper.GetClaimStatus(suite.ctx, strideAccAddress) + suite.Require().NoError(err) + suite.Require().Empty(claims, "stride address should have 0 claim records before update") + + // verify that we can claim the airdrop with the current airdrop key (which represents the incorrect stride address) + coins, err = suite.app.ClaimKeeper.GetUserTotalClaimable(suite.ctx, tc.recordKey, tc.airdropId, false) + suite.Require().NoError(err) + suite.Require().Equal(coins, sdk.NewCoins(sdk.NewCoin("stake", sdk.NewIntFromUint64(100_000_000))), "parsed evmos address should be allowed to claim") + + claims, err = suite.app.ClaimKeeper.GetClaimStatus(suite.ctx, tc.recordKey) + suite.Require().NoError(err) + + properClaims := []types.ClaimStatus{{AirdropIdentifier: tc.airdropId, Claimed: false}} + suite.Require().Equal(claims, properClaims, "evmos address should have 1 claim record before update") + + // update the stride address so that there's now a correct mapping from evmos -> stride address + err = suite.app.ClaimKeeper.UpdateAirdropAddress(suite.ctx, tc.recordKey.String(), tc.strideAddress, tc.airdropId) + suite.Require().NoError(err, "airdrop update address should succeed") + + // verify that the old key CAN NOT claim after the update + coins, err = suite.app.ClaimKeeper.GetUserTotalClaimable(suite.ctx, tc.recordKey, tc.airdropId, false) + suite.Require().NoError(err) + suite.Require().Equal(coins, sdk.NewCoins(), "evmos address should claim 0 coins after update", tc.recordKey) + + claims, err = suite.app.ClaimKeeper.GetClaimStatus(suite.ctx, tc.recordKey) + suite.Require().NoError(err) + suite.Require().Empty(claims, "evmos address should have 0 claim records after update") + + // verify that the stride address CAN claim after the update + coins, err = suite.app.ClaimKeeper.GetUserTotalClaimable(suite.ctx, strideAccAddress, tc.airdropId, false) + suite.Require().NoError(err) + suite.Require().Equal(coins, sdk.NewCoins(sdk.NewCoin("stake", sdk.NewIntFromUint64(100_000_000))), "stride address should be allowed to claim after update") + + claims, err = suite.app.ClaimKeeper.GetClaimStatus(suite.ctx, strideAccAddress) + suite.Require().NoError(err) + suite.Require().Equal(claims, properClaims, "stride address should have 1 claim record after update") + + // claim with the Stride address + coins, err = suite.app.ClaimKeeper.ClaimCoinsForAction(suite.ctx, strideAccAddress, types.ACTION_FREE, tc.airdropId) + suite.Require().NoError(err) + suite.Require().Equal(coins, sdk.NewCoins(sdk.NewCoin("stake", sdk.NewIntFromUint64(20_000_000))), "stride address should be allowed to claim after update") + + // verify Stride address can't claim again + coins, err = suite.app.ClaimKeeper.ClaimCoinsForAction(suite.ctx, strideAccAddress, types.ACTION_FREE, tc.airdropId) + suite.Require().NoError(err) + suite.Require().Equal(coins, sdk.NewCoins(sdk.NewCoin("stake", sdk.NewIntFromUint64(0))), "can't claim twice after update") + + // verify Stride address balance went up + strideBalance := suite.app.BankKeeper.GetBalance(suite.ctx, strideAccAddress, "stake") + suite.Require().Equal(strideBalance.Amount, sdk.NewIntFromUint64(20_000_000), "stride address balance should have increased after claiming") +} + +func (suite *KeeperTestSuite) TestUpdateAirdropAddress_AirdropNotFound() { + tc := suite.SetupUpdateAirdropAddressChangeTests() + + // update the address + err := suite.app.ClaimKeeper.UpdateAirdropAddress(suite.ctx, tc.evmosAddress, tc.strideAddress, "stride") + suite.Require().Error(err, "airdrop address update should fail with incorrect airdrop id") +} + +func (suite *KeeperTestSuite) TestUpdateAirdropAddress_StrideAddressIncorrect() { + tc := suite.SetupUpdateAirdropAddressChangeTests() + + // update the address + incorrectStrideAddress := tc.strideAddress + "a" + err := suite.app.ClaimKeeper.UpdateAirdropAddress(suite.ctx, tc.evmosAddress, incorrectStrideAddress, tc.airdropId) + suite.Require().Error(err, "airdrop address update should fail with incorrect stride address") +} + +func (suite *KeeperTestSuite) TestUpdateAirdropAddress_HostAddressIncorrect() { + tc := suite.SetupUpdateAirdropAddressChangeTests() + + // should fail with a clearly wrong host address + err := suite.app.ClaimKeeper.UpdateAirdropAddress(suite.ctx, "evmostest", tc.strideAddress, tc.airdropId) + suite.Require().Error(err, "airdrop address update should fail with clearly incorrect host address") + + // should fail if host address is not a stride address + err = suite.app.ClaimKeeper.UpdateAirdropAddress(suite.ctx, tc.evmosAddress, tc.strideAddress, tc.airdropId) + suite.Require().Error(err, "airdrop address update should fail with host address in wrong zone") + + // should fail is host address (record key) is slightly incorrect + recordKeyString := tc.recordKey.String() + modifiedAddress := recordKeyString[:len(recordKeyString)-1] + "a" + err = suite.app.ClaimKeeper.UpdateAirdropAddress(suite.ctx, modifiedAddress, tc.strideAddress, tc.airdropId) + suite.Require().Error(err, "airdrop address update should fail with incorrect host address") + + // should fail is host address is correct but doesn't have a claimrecord + randomStrideAddress := "stride16qv5wnkwwvd2qj5ttwznmngc09cet8l9zhm2ru" + err = suite.app.ClaimKeeper.UpdateAirdropAddress(suite.ctx, randomStrideAddress, tc.strideAddress, tc.airdropId) + suite.Require().Error(err, "airdrop address update should fail with not present host address") +} diff --git a/x/claim/keeper/hooks.go b/x/claim/keeper/hooks.go index 4cd094721c..da1c837fb2 100644 --- a/x/claim/keeper/hooks.go +++ b/x/claim/keeper/hooks.go @@ -48,15 +48,23 @@ func (k Keeper) AfterEpochEnd(ctx sdk.Context, epochInfo epochstypes.EpochInfo) // check if epochInfo.Identifier is an airdrop epoch // if yes, reset claim status for all users // check if epochInfo.Identifier starts with "airdrop" + k.Logger(ctx).Info(fmt.Sprintf("[CLAIM] checking if epoch %s is an airdrop epoch", epochInfo.Identifier)) if strings.HasPrefix(epochInfo.Identifier, "airdrop-") { + airdropIdentifier := strings.TrimPrefix(epochInfo.Identifier, "airdrop-") + k.Logger(ctx).Info(fmt.Sprintf("[CLAIM] trimmed airdrop identifier: %s", airdropIdentifier)) + airdrop := k.GetAirdropByIdentifier(ctx, airdropIdentifier) + k.Logger(ctx).Info(fmt.Sprintf("[CLAIM] airdrop found: %v", airdrop)) + if airdrop != nil { - k.Logger(ctx).Info(fmt.Sprintf("resetting claims for airdrop %s", epochInfo.Identifier)) + k.Logger(ctx).Info(fmt.Sprintf("[CLAIM] resetting claims for airdrop %s", epochInfo.Identifier)) err := k.ResetClaimStatus(ctx, airdropIdentifier) if err != nil { - k.Logger(ctx).Error(fmt.Sprintf("failed to reset claim status for epoch %s: %s", epochInfo.Identifier, err.Error())) + k.Logger(ctx).Error(fmt.Sprintf("[CLAIM] failed to reset claim status for epoch %s: %s", epochInfo.Identifier, err.Error())) } + } else { + k.Logger(ctx).Info(fmt.Sprintf("[CLAIM] airdrop %s not found, skipping reset", airdropIdentifier)) } } } diff --git a/x/claim/types/errors.go b/x/claim/types/errors.go index 425166f940..a095a88880 100644 --- a/x/claim/types/errors.go +++ b/x/claim/types/errors.go @@ -22,4 +22,10 @@ var ( "cannot claim negative tokens") ErrInvalidAccount = errorsmod.Register(ModuleName, 1109, "only BaseAccount and StridePeriodicVestingAccount can claim") + ErrAirdropNotFound = errorsmod.Register(ModuleName, 1110, + "the airdrop was not found") + ErrClaimNotFound = errorsmod.Register(ModuleName, 1111, + "the claim record was not found") + ErrModifyingClaimRecord = errorsmod.Register(ModuleName, 1112, + "failed to modify claim record") )