diff --git a/app/upgrades.go b/app/upgrades.go index 66e628b61d..8b35868788 100644 --- a/app/upgrades.go +++ b/app/upgrades.go @@ -22,6 +22,7 @@ import ( v15 "github.com/Stride-Labs/stride/v17/app/upgrades/v15" v16 "github.com/Stride-Labs/stride/v17/app/upgrades/v16" v17 "github.com/Stride-Labs/stride/v17/app/upgrades/v17" + v18 "github.com/Stride-Labs/stride/v17/app/upgrades/v18" v2 "github.com/Stride-Labs/stride/v17/app/upgrades/v2" v3 "github.com/Stride-Labs/stride/v17/app/upgrades/v3" v4 "github.com/Stride-Labs/stride/v17/app/upgrades/v4" @@ -37,6 +38,7 @@ import ( ratelimittypes "github.com/Stride-Labs/stride/v17/x/ratelimit/types" recordtypes "github.com/Stride-Labs/stride/v17/x/records/types" stakeibctypes "github.com/Stride-Labs/stride/v17/x/stakeibc/types" + staketiatypes "github.com/Stride-Labs/stride/v17/x/staketia/types" ) func (app *StrideApp) setupUpgradeHandlers(appOpts servertypes.AppOptions) { @@ -232,6 +234,19 @@ func (app *StrideApp) setupUpgradeHandlers(appOpts servertypes.AppOptions) { ), ) + // v18 upgrade handler + app.UpgradeKeeper.SetUpgradeHandler( + v18.UpgradeName, + v18.CreateUpgradeHandler( + app.mm, + app.configurator, + app.BankKeeper, + app.GovKeeper, + app.RecordsKeeper, + app.StakeibcKeeper, + ), + ) + upgradeInfo, err := app.UpgradeKeeper.ReadUpgradeInfoFromDisk() if err != nil { panic(fmt.Errorf("Failed to read upgrade info from disk: %w", err)) @@ -277,6 +292,10 @@ func (app *StrideApp) setupUpgradeHandlers(appOpts servertypes.AppOptions) { // Add PFM store key Added: []string{packetforwardtypes.ModuleName}, } + case "v18": + storeUpgrades = &storetypes.StoreUpgrades{ + Added: []string{staketiatypes.ModuleName}, + } } if storeUpgrades != nil { diff --git a/app/upgrades/v18/constants.go b/app/upgrades/v18/constants.go new file mode 100644 index 0000000000..c3339b1fba --- /dev/null +++ b/app/upgrades/v18/constants.go @@ -0,0 +1,192 @@ +package v18 + +import ( + sdkmath "cosmossdk.io/math" + sdk "github.com/cosmos/cosmos-sdk/types" +) + +var ( + UpgradeName = "v18" + + // Redemption rate bounds updated to give ~3 months of slack on outer bounds + RedemptionRateOuterMinAdjustment = sdk.MustNewDecFromStr("0.05") + RedemptionRateOuterMaxAdjustment = sdk.MustNewDecFromStr("0.10") + + // Osmosis will have a slighly larger buffer with the redemption rate + // since their yield is less predictable + OsmosisChainId = "osmosis-1" + OsmosisRedemptionRateBuffer = sdk.MustNewDecFromStr("0.02") + + // Terra chain ID for delegation changes in progress + TerraChainId = "phoenix-1" + + // Prop 228 info + Strd = "ustrd" + Prop228ProposalId = uint64(228) + Prop228SendAmount = sdkmath.NewInt(9_000_000_000_000) + IncentiveProgramAddress = "stride1tlxk4as9sgpqkh42cfaxqja0mdj6qculqshy0gg3glazmrnx3y8s8gsvqk" + StrideFoundationAddress_F4 = "stride1yz3mp7c2m739nftfrv5r3h6j64aqp95f3degpf" + + // Get Initial Redemption Rates for Unbonding Records Migration + RedemptionRatesAtTimeOfProp = map[string]sdk.Dec{ + "comdex-1": sdk.MustNewDecFromStr("1.204883527965105396"), + "cosmoshub-4": sdk.MustNewDecFromStr("1.299886984330871277"), + "evmos_9001-2": sdk.MustNewDecFromStr("1.492732862363044751"), + "injective-1": sdk.MustNewDecFromStr("1.216027814303310584"), + "juno-1": sdk.MustNewDecFromStr("1.418690442281976982"), + "osmosis-1": sdk.MustNewDecFromStr("1.201662502920632779"), + "phoenix-1": sdk.MustNewDecFromStr("1.178584742254853106"), + "sommelier-3": sdk.MustNewDecFromStr("1.025900897761638723"), + "stargaze-1": sdk.MustNewDecFromStr("1.430486928659223287"), + "umee-1": sdk.MustNewDecFromStr("1.128892781103330908"), + } + + // Get Amount Unbonded for each HostZone for Unbonding Records Migration + StartingEstimateEpoch = uint64(508) + RedemptionRatesBeforeProp = map[string]map[uint64]sdk.Dec{ + "juno-1": { + 495: sdk.MustNewDecFromStr("1.412164551270598"), + 496: sdk.MustNewDecFromStr("1.412164551270598"), + 497: sdk.MustNewDecFromStr("1.412164551270598"), + 500: sdk.MustNewDecFromStr("1.4161495546072012"), + 501: sdk.MustNewDecFromStr("1.4161495546072012"), + 503: sdk.MustNewDecFromStr("1.4161495546072012"), + 504: sdk.MustNewDecFromStr("1.4161495546072012"), + 505: sdk.MustNewDecFromStr("1.417724248601981"), + 507: sdk.MustNewDecFromStr("1.417724248601981"), + 508: sdk.MustNewDecFromStr("1.417724248601981"), + }, + "phoenix-1": { + 496: sdk.MustNewDecFromStr("1.1740619020285001"), + 498: sdk.MustNewDecFromStr("1.1740619020285001"), + 499: sdk.MustNewDecFromStr("1.1740619020285001"), + 500: sdk.MustNewDecFromStr("1.1757224643748854"), + 503: sdk.MustNewDecFromStr("1.1757224643748854"), + 504: sdk.MustNewDecFromStr("1.176553937681711"), + 505: sdk.MustNewDecFromStr("1.176553937681711"), + 506: sdk.MustNewDecFromStr("1.176553937681711"), + 507: sdk.MustNewDecFromStr("1.176553937681711"), + }, + "sommelier-3": { + 495: sdk.MustNewDecFromStr("1.0241481197817144"), + 496: sdk.MustNewDecFromStr("1.0241481197817144"), + 497: sdk.MustNewDecFromStr("1.0241481197817144"), + 499: sdk.MustNewDecFromStr("1.0241481197817144"), + 501: sdk.MustNewDecFromStr("1.025236900070852"), + 502: sdk.MustNewDecFromStr("1.025236900070852"), + 503: sdk.MustNewDecFromStr("1.025236900070852"), + 504: sdk.MustNewDecFromStr("1.025236900070852"), + 505: sdk.MustNewDecFromStr("1.0259008616651284"), + 507: sdk.MustNewDecFromStr("1.0259008616651284"), + 508: sdk.MustNewDecFromStr("1.0259008616651284"), + 509: sdk.MustNewDecFromStr("1.0259008616651284"), + }, + "cosmoshub-4": { + 496: sdk.MustNewDecFromStr("1.2938404518607025"), + 497: sdk.MustNewDecFromStr("1.2938404518607025"), + 498: sdk.MustNewDecFromStr("1.2938404518607025"), + 499: sdk.MustNewDecFromStr("1.2938404518607025"), + 500: sdk.MustNewDecFromStr("1.2957672912922817"), + 501: sdk.MustNewDecFromStr("1.2957672912922817"), + 502: sdk.MustNewDecFromStr("1.2957672912922817"), + 503: sdk.MustNewDecFromStr("1.2957672912922817"), + 504: sdk.MustNewDecFromStr("1.296926394723948"), + 505: sdk.MustNewDecFromStr("1.296926394723948"), + 506: sdk.MustNewDecFromStr("1.296926394723948"), + 507: sdk.MustNewDecFromStr("1.296926394723948"), + }, + "comdex-1": { + 496: sdk.MustNewDecFromStr("1.1963306878344375"), + 497: sdk.MustNewDecFromStr("1.1963306878344375"), + 498: sdk.MustNewDecFromStr("1.1963306878344375"), + 499: sdk.MustNewDecFromStr("1.1963306878344375"), + 500: sdk.MustNewDecFromStr("1.1994537074221134"), + 501: sdk.MustNewDecFromStr("1.1994537074221134"), + 502: sdk.MustNewDecFromStr("1.1994537074221134"), + 503: sdk.MustNewDecFromStr("1.1994537074221134"), + 504: sdk.MustNewDecFromStr("1.2019746297343605"), + 505: sdk.MustNewDecFromStr("1.2019746297343605"), + 506: sdk.MustNewDecFromStr("1.2019746297343605"), + 507: sdk.MustNewDecFromStr("1.2019746297343605"), + }, + "injective-1": { + 464: sdk.MustNewDecFromStr("1.10904028152176"), + 465: sdk.MustNewDecFromStr("1.1092232046811195"), + 466: sdk.MustNewDecFromStr("1.1094104738505122"), + 467: sdk.MustNewDecFromStr("1.109660102119856"), + 468: sdk.MustNewDecFromStr("1.1099206471560683"), + 469: sdk.MustNewDecFromStr("1.1101781888690843"), + 470: sdk.MustNewDecFromStr("1.1104928343163862"), + 471: sdk.MustNewDecFromStr("1.1106814727683936"), + 472: sdk.MustNewDecFromStr("1.1109147705303473"), + 473: sdk.MustNewDecFromStr("1.1111483631454906"), + 474: sdk.MustNewDecFromStr("1.1113789833325327"), + 475: sdk.MustNewDecFromStr("1.1115865207841595"), + 476: sdk.MustNewDecFromStr("1.1118256565192843"), + 477: sdk.MustNewDecFromStr("1.112062977242558"), + 478: sdk.MustNewDecFromStr("1.112305089405149"), + 479: sdk.MustNewDecFromStr("1.1125496812740654"), + 480: sdk.MustNewDecFromStr("1.112796928321449"), + 481: sdk.MustNewDecFromStr("1.113045979582398"), + 482: sdk.MustNewDecFromStr("1.1133578645679472"), + 483: sdk.MustNewDecFromStr("1.1135463131500978"), + 484: sdk.MustNewDecFromStr("1.113862639530537"), + 485: sdk.MustNewDecFromStr("1.1140510045259582"), + 486: sdk.MustNewDecFromStr("1.114295573398525"), + 487: sdk.MustNewDecFromStr("1.1145990588175787"), + 488: sdk.MustNewDecFromStr("1.114779498371232"), + 489: sdk.MustNewDecFromStr("1.1150839991290917"), + 498: sdk.MustNewDecFromStr("1.1170896901082266"), + 499: sdk.MustNewDecFromStr("1.1498981693771557"), + 500: sdk.MustNewDecFromStr("1.209508137205966"), + 501: sdk.MustNewDecFromStr("1.209985009275008"), + 502: sdk.MustNewDecFromStr("1.210478332327813"), + 503: sdk.MustNewDecFromStr("1.2109676716098068"), + 504: sdk.MustNewDecFromStr("1.2130924701151315"), + 505: sdk.MustNewDecFromStr("1.2136053525521355"), + 507: sdk.MustNewDecFromStr("1.21455566769327"), + }, + "evmos_9001-2": { + 499: sdk.MustNewDecFromStr("1.4895991845634247"), + 500: sdk.MustNewDecFromStr("1.4895991845634247"), + 501: sdk.MustNewDecFromStr("1.490098715761824"), + 502: sdk.MustNewDecFromStr("1.490098715761824"), + 503: sdk.MustNewDecFromStr("1.490098715761824"), + 504: sdk.MustNewDecFromStr("1.4910458236916064"), + 505: sdk.MustNewDecFromStr("1.4910458236916064"), + 507: sdk.MustNewDecFromStr("1.4918520366929944"), + 508: sdk.MustNewDecFromStr("1.4918520366929944"), + }, + "osmosis-1": { + 498: sdk.MustNewDecFromStr("1.1984190041836773"), + 499: sdk.MustNewDecFromStr("1.1984190041836773"), + 500: sdk.MustNewDecFromStr("1.1984190041836773"), + 501: sdk.MustNewDecFromStr("1.1991174772238702"), + 502: sdk.MustNewDecFromStr("1.1991174772238702"), + 503: sdk.MustNewDecFromStr("1.1991174772238702"), + 504: sdk.MustNewDecFromStr("1.2003177583397713"), + 505: sdk.MustNewDecFromStr("1.2003177583397713"), + 506: sdk.MustNewDecFromStr("1.2003177583397713"), + 507: sdk.MustNewDecFromStr("1.2011986371246357"), + 508: sdk.MustNewDecFromStr("1.2011986371246357"), + 509: sdk.MustNewDecFromStr("1.2011986371246357"), + }, + "stargaze-1": { + 498: sdk.MustNewDecFromStr("1.4246347073913794"), + 499: sdk.MustNewDecFromStr("1.4246347073913794"), + 500: sdk.MustNewDecFromStr("1.4246347073913794"), + 501: sdk.MustNewDecFromStr("1.4267297754925006"), + 502: sdk.MustNewDecFromStr("1.4267297754925006"), + 503: sdk.MustNewDecFromStr("1.4267297754925006"), + 504: sdk.MustNewDecFromStr("1.4279528400269015"), + 505: sdk.MustNewDecFromStr("1.4279528400269015"), + 506: sdk.MustNewDecFromStr("1.4279528400269015"), + 507: sdk.MustNewDecFromStr("1.430136789416802"), + 508: sdk.MustNewDecFromStr("1.430136789416802"), + 509: sdk.MustNewDecFromStr("1.430136789416802"), + }, + "umee-1": { + 505: sdk.MustNewDecFromStr("1.1266406527137283"), + }, + } +) diff --git a/app/upgrades/v18/upgrades.go b/app/upgrades/v18/upgrades.go new file mode 100644 index 0000000000..b27d9745a2 --- /dev/null +++ b/app/upgrades/v18/upgrades.go @@ -0,0 +1,224 @@ +package v18 + +import ( + "fmt" + + errorsmod "cosmossdk.io/errors" + sdkmath "cosmossdk.io/math" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/module" + bankkeeper "github.com/cosmos/cosmos-sdk/x/bank/keeper" + govkeeper "github.com/cosmos/cosmos-sdk/x/gov/keeper" + govtypes "github.com/cosmos/cosmos-sdk/x/gov/types/v1" + upgradetypes "github.com/cosmos/cosmos-sdk/x/upgrade/types" + + recordskeeper "github.com/Stride-Labs/stride/v17/x/records/keeper" + recordtypes "github.com/Stride-Labs/stride/v17/x/records/types" + stakeibckeeper "github.com/Stride-Labs/stride/v17/x/stakeibc/keeper" + "github.com/Stride-Labs/stride/v17/x/stakeibc/types" + stakeibctypes "github.com/Stride-Labs/stride/v17/x/stakeibc/types" +) + +// CreateUpgradeHandler creates an SDK upgrade handler for v18 +func CreateUpgradeHandler( + mm *module.Manager, + configurator module.Configurator, + bankKeeper bankkeeper.Keeper, + govKeeper govkeeper.Keeper, + recordsKeeper recordskeeper.Keeper, + stakeibcKeeper stakeibckeeper.Keeper, +) upgradetypes.UpgradeHandler { + return func(ctx sdk.Context, _ upgradetypes.Plan, vm module.VersionMap) (module.VersionMap, error) { + ctx.Logger().Info("Starting upgrade v18...") + + ctx.Logger().Info("Updating redemption rate bounds...") + UpdateRedemptionRateBounds(ctx, stakeibcKeeper) + + ctx.Logger().Info("Resetting delegation changes in progress...") + if err := DecrementTerraDelegationChangesInProgress(ctx, stakeibcKeeper); err != nil { + return vm, errorsmod.Wrapf(err, "unable to reset delegation changes in progress") + } + + ctx.Logger().Info("Updating unbonding records...") + err := UpdateUnbondingRecords( + ctx, + stakeibcKeeper, + recordsKeeper, + StartingEstimateEpoch, + RedemptionRatesBeforeProp, + RedemptionRatesAtTimeOfProp, + ) + if err != nil { + return vm, errorsmod.Wrapf(err, "unable to update unbonding records") + } + + ctx.Logger().Info(fmt.Sprintf("Checking on prop %d status...", Prop228ProposalId)) + if err := ExecuteProp228IfPassed(ctx, bankKeeper, govKeeper); err != nil { + ctx.Logger().Error(fmt.Sprintf("Failed to check on or execute prop %d: %s", + Prop228ProposalId, err.Error())) + } + + return mm.RunMigrations(ctx, configurator, vm) + } +} + +// Updates the outer redemption rate bounds +func UpdateRedemptionRateBounds(ctx sdk.Context, k stakeibckeeper.Keeper) { + for _, hostZone := range k.GetAllHostZone(ctx) { + // Give osmosis a bit more slack since OSMO stakers collect real yield + outerAdjustment := RedemptionRateOuterMaxAdjustment + if hostZone.ChainId == OsmosisChainId { + outerAdjustment = outerAdjustment.Add(OsmosisRedemptionRateBuffer) + } + + outerMinDelta := hostZone.RedemptionRate.Mul(RedemptionRateOuterMinAdjustment) + outerMaxDelta := hostZone.RedemptionRate.Mul(outerAdjustment) + + outerMin := hostZone.RedemptionRate.Sub(outerMinDelta) + outerMax := hostZone.RedemptionRate.Add(outerMaxDelta) + + hostZone.MinRedemptionRate = outerMin + hostZone.MaxRedemptionRate = outerMax + + k.SetHostZone(ctx, hostZone) + } +} + +// Decrement DelegationChangesInProgress on Terra vals by 3 +// - Fetches terra host zone +// - Loops validators +// - Decrements each validator's DelegationChangeInProgress by 3 +func DecrementTerraDelegationChangesInProgress( + ctx sdk.Context, + sk stakeibckeeper.Keeper, +) error { + + // grab the terra host zone + hostZone, found := sk.GetHostZone(ctx, TerraChainId) + if !found { + return types.ErrHostZoneNotFound.Wrapf("failed to fetch %s", TerraChainId) + } + + // iterate the validators + for _, val := range hostZone.Validators { + + // subtract 3, flooring at 0 + if val.DelegationChangesInProgress < 3 { + val.DelegationChangesInProgress = 0 + } else { + val.DelegationChangesInProgress = val.DelegationChangesInProgress - 3 + } + } + + // set the host zone + sk.SetHostZone(ctx, hostZone) + + return nil +} + +// Modify HostZoneUnbonding and UserRedemptionRecords NativeTokenAmount to reflect new data structs +func UpdateUnbondingRecords( + ctx sdk.Context, + sk stakeibckeeper.Keeper, + rk recordskeeper.Keeper, + startingEstimateEpoch uint64, + redemptionRatesBeforeProp map[string]map[uint64]sdk.Dec, + redemptionRatesAtTimeOfProp map[string]sdk.Dec, +) error { + // loop over host zone unbonding records + for _, epochUnbondingRecord := range rk.GetAllEpochUnbondingRecord(ctx) { + for _, hostZoneUnbonding := range epochUnbondingRecord.HostZoneUnbondings { + epochNumber := epochUnbondingRecord.EpochNumber + chainId := hostZoneUnbonding.HostZoneId + + // we can ignore any record that's not currently unbonding + if hostZoneUnbonding.Status != recordtypes.HostZoneUnbonding_EXIT_TRANSFER_QUEUE { + continue + } + + // Grab the redemption rates from before the prop was posted, for a given chain + // across all the epochs that unbonded + hostZoneRRBeforeProp, ok := redemptionRatesBeforeProp[hostZoneUnbonding.HostZoneId] + if !ok { + ctx.Logger().Error(fmt.Sprintf("Host zone %s not included in redemption rate mapping", chainId)) + continue + } + + // Grab the redemption rate for this specific epoch + // If it's not found, that means the unbonding for this epoch occurred after the prop was live + recordRedemptionRate, recordUnbondedBeforeProp := hostZoneRRBeforeProp[epochUnbondingRecord.EpochNumber] + + if !recordUnbondedBeforeProp && (epochNumber < startingEstimateEpoch) { + ctx.Logger().Info(fmt.Sprintf("Skipping unbonding record adjustment for chain %s epoch %d", + chainId, epochNumber)) + continue + } + + // If we don't have the redemption rate, estimate it + if !recordUnbondedBeforeProp { + hostZone, found := sk.GetHostZone(ctx, hostZoneUnbonding.HostZoneId) + if !found { + return errorsmod.Wrapf(stakeibctypes.ErrHostZoneNotFound, + "unable to find host zone with chain-id %s", hostZoneUnbonding.HostZoneId) + } + + redemptionRateAtTimeOfProp := redemptionRatesAtTimeOfProp[hostZoneUnbonding.HostZoneId] + redemptionRateDuringUpgrade := hostZone.RedemptionRate + recordRedemptionRate = redemptionRateAtTimeOfProp.Add(redemptionRateDuringUpgrade).Quo(sdk.NewDec(2)) + } + + // now update all userRedemptionRecords by using the redemption rate to set the native token amount + totalNativeAmount := sdkmath.ZeroInt() + for _, userRedemptionRecordId := range hostZoneUnbonding.UserRedemptionRecords { + userRedemptionRecord, found := rk.GetUserRedemptionRecord(ctx, userRedemptionRecordId) + if !found { + return errorsmod.Wrapf(recordtypes.ErrHostUnbondingRecordNotFound, + "unable to find user redemption record with id %s", userRedemptionRecordId) + } + + userNativeAmount := sdk.NewDecFromInt(userRedemptionRecord.StTokenAmount).Mul(recordRedemptionRate).TruncateInt() + totalNativeAmount = totalNativeAmount.Add(userNativeAmount) + + userRedemptionRecord.NativeTokenAmount = userNativeAmount + rk.SetUserRedemptionRecord(ctx, userRedemptionRecord) + } + + // finally, update the hostZoneUnbonding record + hostZoneUnbonding.NativeTokenAmount = totalNativeAmount + if err := rk.SetHostZoneUnbondingRecord(ctx, epochNumber, chainId, *hostZoneUnbonding); err != nil { + return errorsmod.Wrapf(err, "unable to set host zone unbonding for %s and epoch %d", + hostZoneUnbonding.HostZoneId, epochUnbondingRecord.EpochNumber) + } + } + } + return nil +} + +// Executes the bank send for prop 228 if it passed +func ExecuteProp228IfPassed(ctx sdk.Context, bk bankkeeper.Keeper, gk govkeeper.Keeper) error { + // Grab proposal from gov store + proposal, found := gk.GetProposal(ctx, Prop228ProposalId) + if !found { + return fmt.Errorf("Prop %d not found", Prop228ProposalId) + } + + // Check if it passed - if it didn't do nothing + if proposal.Status != govtypes.ProposalStatus_PROPOSAL_STATUS_PASSED { + ctx.Logger().Info(fmt.Sprintf("Prop %d did not pass", Prop228ProposalId)) + return nil + } + ctx.Logger().Info(fmt.Sprintf("Prop %d passed - executing corresponding bank send", Prop228ProposalId)) + + // Transfer from incentive program address to F4 + fromAddress, err := sdk.AccAddressFromBech32(IncentiveProgramAddress) + if err != nil { + return errorsmod.Wrap(err, "invalid prop sender address") + } + + toAddress, err := sdk.AccAddressFromBech32(StrideFoundationAddress_F4) + if err != nil { + return errorsmod.Wrap(err, "invalid prop recipient address") + } + + return bk.SendCoins(ctx, fromAddress, toAddress, sdk.NewCoins(sdk.NewCoin(Strd, Prop228SendAmount))) +} diff --git a/app/upgrades/v18/upgrades_test.go b/app/upgrades/v18/upgrades_test.go new file mode 100644 index 0000000000..2be28d9bbb --- /dev/null +++ b/app/upgrades/v18/upgrades_test.go @@ -0,0 +1,614 @@ +package v18_test + +import ( + "fmt" + "testing" + + sdkmath "cosmossdk.io/math" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/stretchr/testify/suite" + + govtypes "github.com/cosmos/cosmos-sdk/x/gov/types/v1" + + "github.com/Stride-Labs/stride/v17/app/apptesting" + v18 "github.com/Stride-Labs/stride/v17/app/upgrades/v18" + recordtypes "github.com/Stride-Labs/stride/v17/x/records/types" + "github.com/Stride-Labs/stride/v17/x/stakeibc/types" + stakeibctypes "github.com/Stride-Labs/stride/v17/x/stakeibc/types" +) + +type UpdateRedemptionRateBounds struct { + ChainId string + CurrentRedemptionRate sdk.Dec + ExpectedMinOuterRedemptionRate sdk.Dec + ExpectedMaxOuterRedemptionRate sdk.Dec +} + +type UserRedemptionRecordTestCases struct { + Id string + EpochNumber uint64 + HostZoneId string + StAmount int64 + + // Redemption rate at the time the unbonding was submitted + UnbondedRR string + // Redemption rate used in the records + RecordRR string + + // Native token amount at the time the unbonding was submitted + InitialNativeAmount sdkmath.Int + // Expected native amount after the upgrade + ExpectedNativeAmount sdkmath.Int +} + +type HostZoneUnbondingTestCase struct { + StAmount int64 + URRs []string + + // Redemption rate at the time the unbonding was submitted + UnbondedRR string + // Redemption rate used in the records + RecordRR string + + // Native token amount at the time the unbonding was submitted + InitialNativeAmount sdkmath.Int + // Expected native amount after the upgrade + ExpectedNativeAmount sdkmath.Int +} + +type UpgradeTestSuite struct { + apptesting.AppTestHelper +} + +func TestKeeperTestSuite(t *testing.T) { + suite.Run(t, new(UpgradeTestSuite)) +} + +func (s *UpgradeTestSuite) SetupTest() { + s.Setup() +} + +func (s *UpgradeTestSuite) TestUpgrade() { + dummyUpgradeHeight := int64(5) + + // Setup store before upgrade + checkDelegationsAfterUpgrade := s.SetupTestResetDelegationChanges() + checkUnbondingsAfterUpgrade := s.SetupTestUnbondingRecords() + checkRedemptionRatesAfterUpgrade := s.SetupTestUpdateRedemptionRateBounds() + + // Run through upgrade + s.ConfirmUpgradeSucceededs("v18", dummyUpgradeHeight) + + // Check store after upgrade + checkDelegationsAfterUpgrade() + checkRedemptionRatesAfterUpgrade() + checkUnbondingsAfterUpgrade() +} + +func (s *UpgradeTestSuite) SetupTestResetDelegationChanges() func() { + validators := []*stakeibctypes.Validator{ + {DelegationChangesInProgress: 3}, + {DelegationChangesInProgress: 3}, + {DelegationChangesInProgress: 3}, + } + s.App.StakeibcKeeper.SetHostZone(s.Ctx, stakeibctypes.HostZone{ + ChainId: v18.TerraChainId, + Validators: validators, + }) + s.App.StakeibcKeeper.SetHostZone(s.Ctx, stakeibctypes.HostZone{ + ChainId: "different-host", + Validators: validators, + }) + + // Return callback to check store after upgrade + return func() { + for _, hostZone := range s.App.StakeibcKeeper.GetAllHostZone(s.Ctx) { + expected := int64(3) + if hostZone.ChainId == v18.TerraChainId { + expected = 0 + } + for _, validator := range hostZone.Validators { + s.Require().Equal(expected, validator.DelegationChangesInProgress) + } + } + } +} + +func (s *UpgradeTestSuite) SetupTestUpdateRedemptionRateBounds() func() { + // Define test cases consisting of an initial redemption rate and expected bounds + testCases := []UpdateRedemptionRateBounds{ + { + ChainId: "chain-0", + CurrentRedemptionRate: sdk.MustNewDecFromStr("1.0"), + ExpectedMinOuterRedemptionRate: sdk.MustNewDecFromStr("0.95"), // 1 - 5% = 0.95 + ExpectedMaxOuterRedemptionRate: sdk.MustNewDecFromStr("1.10"), // 1 + 10% = 1.1 + }, + { + ChainId: "chain-1", + CurrentRedemptionRate: sdk.MustNewDecFromStr("1.1"), + ExpectedMinOuterRedemptionRate: sdk.MustNewDecFromStr("1.045"), // 1.1 - 5% = 1.045 + ExpectedMaxOuterRedemptionRate: sdk.MustNewDecFromStr("1.210"), // 1.1 + 10% = 1.21 + }, + { + // Max outer for osmo uses 12% instead of 10% + ChainId: v18.OsmosisChainId, + CurrentRedemptionRate: sdk.MustNewDecFromStr("1.25"), + ExpectedMinOuterRedemptionRate: sdk.MustNewDecFromStr("1.1875"), // 1.25 - 5% = 1.1875 + ExpectedMaxOuterRedemptionRate: sdk.MustNewDecFromStr("1.4000"), // 1.25 + 12% = 1.400 + }, + } + + // Create a host zone for each test case + for _, tc := range testCases { + hostZone := stakeibctypes.HostZone{ + ChainId: tc.ChainId, + RedemptionRate: tc.CurrentRedemptionRate, + } + s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone) + } + + // Return callback to check store after upgrade + return func() { + // Confirm they were all updated + for _, tc := range testCases { + hostZone, found := s.App.StakeibcKeeper.GetHostZone(s.Ctx, tc.ChainId) + s.Require().True(found) + + s.Require().Equal(tc.ExpectedMinOuterRedemptionRate, hostZone.MinRedemptionRate, "%s - min outer", tc.ChainId) + s.Require().Equal(tc.ExpectedMaxOuterRedemptionRate, hostZone.MaxRedemptionRate, "%s - max outer", tc.ChainId) + } + } +} + +func (s *UpgradeTestSuite) SetupTestUnbondingRecords() func() { + chainId := "sommelier-3" + epochNumberBefore := uint64(501) + epochNumberAfter := uint64(510) + stTokenAmount := sdkmath.NewInt(1_000_000) + + // Unbonding #1 (before prop) + redemptionRateUnbonded := sdk.MustNewDecFromStr("1.025236900070852") // Rate used when unbonding occurred + redemptionRateRecords := sdk.MustNewDecFromStr("1.025136900070852") // Rate that was on record at the time (+0.0001) + + s.Require().Equal(redemptionRateUnbonded.String(), v18.RedemptionRatesBeforeProp[chainId][epochNumberBefore].String(), + "example redemption rate from before prop does not match constants - update the test") + + // Unbonding #2 (after prop) + redemptionRateAtPropTime := sdk.MustNewDecFromStr("1.025900897761638723") // Rate at prop time + redemptionRateAtUpgradeTime := sdk.MustNewDecFromStr("1.03") // Rate at upgrade time + estimatedRedemptionRate := sdk.MustNewDecFromStr("1.027950448880819361") // Estimated rate used to update record + unknownRedemptionRate := sdk.MustNewDecFromStr("1.029") // Rate that was used in unbonding + + s.Require().Equal(redemptionRateAtPropTime.String(), v18.RedemptionRatesAtTimeOfProp[chainId].String(), + "example redemption rate from time of prop does not match constants - update the test") + + // Calculate native token in the records before the upgrade + initialNativeAmount1 := sdk.NewDecFromInt(stTokenAmount).Mul(redemptionRateRecords).TruncateInt() + initialNativeAmount2 := sdk.NewDecFromInt(stTokenAmount).Mul(unknownRedemptionRate).TruncateInt() + + // Calculate expected native amounts after upgrade + expectedNativeAmount1 := sdk.NewDecFromInt(stTokenAmount).Mul(redemptionRateUnbonded).TruncateInt() + expectedNativeAmount2 := sdk.NewDecFromInt(stTokenAmount).Mul(estimatedRedemptionRate).TruncateInt() + + // Create the host zone with redemption rate at time of upgrade + s.App.StakeibcKeeper.SetHostZone(s.Ctx, stakeibctypes.HostZone{ + ChainId: chainId, + RedemptionRate: redemptionRateAtUpgradeTime, + }) + + // Create redemption records - one before and one after the prop + s.App.RecordsKeeper.SetUserRedemptionRecord(s.Ctx, recordtypes.UserRedemptionRecord{ + Id: "A", + EpochNumber: epochNumberBefore, + HostZoneId: chainId, + StTokenAmount: stTokenAmount, + NativeTokenAmount: initialNativeAmount1, + }) + s.App.RecordsKeeper.SetUserRedemptionRecord(s.Ctx, recordtypes.UserRedemptionRecord{ + Id: "B", + EpochNumber: epochNumberAfter, + HostZoneId: chainId, + StTokenAmount: stTokenAmount, + NativeTokenAmount: initialNativeAmount2, + }) + + // Create epoch unbonding records + s.App.RecordsKeeper.SetEpochUnbondingRecord(s.Ctx, recordtypes.EpochUnbondingRecord{ + EpochNumber: epochNumberBefore, + HostZoneUnbondings: []*recordtypes.HostZoneUnbonding{ + { + HostZoneId: chainId, + Status: recordtypes.HostZoneUnbonding_EXIT_TRANSFER_QUEUE, + StTokenAmount: stTokenAmount, + NativeTokenAmount: initialNativeAmount1, + UserRedemptionRecords: []string{"A"}, + }, + }, + }) + s.App.RecordsKeeper.SetEpochUnbondingRecord(s.Ctx, recordtypes.EpochUnbondingRecord{ + EpochNumber: epochNumberAfter, + HostZoneUnbondings: []*recordtypes.HostZoneUnbonding{ + { + HostZoneId: chainId, + Status: recordtypes.HostZoneUnbonding_EXIT_TRANSFER_QUEUE, + StTokenAmount: stTokenAmount, + NativeTokenAmount: initialNativeAmount2, + UserRedemptionRecords: []string{"B"}, + }, + }, + }) + + // Add a record that should be ignored because the epoch number is too low + epochNumberIgnore1 := uint64(3) + nativeAmountIgnored := stTokenAmount + s.App.RecordsKeeper.SetUserRedemptionRecord(s.Ctx, recordtypes.UserRedemptionRecord{ + Id: "C", + EpochNumber: epochNumberIgnore1, + HostZoneId: chainId, + StTokenAmount: stTokenAmount, + NativeTokenAmount: nativeAmountIgnored, + }) + s.App.RecordsKeeper.SetEpochUnbondingRecord(s.Ctx, recordtypes.EpochUnbondingRecord{ + EpochNumber: epochNumberIgnore1, + HostZoneUnbondings: []*recordtypes.HostZoneUnbonding{ + { + HostZoneId: chainId, + Status: recordtypes.HostZoneUnbonding_EXIT_TRANSFER_QUEUE, + StTokenAmount: stTokenAmount, + NativeTokenAmount: nativeAmountIgnored, + UserRedemptionRecords: []string{"C"}, + }, + }, + }) + + // Add another record that should be ignored - this one because the status is not EXIT_TRANSFER_IN_QUEUE + epochNumberIgnore2 := uint64(505) // should be in constants + _, ok := v18.RedemptionRatesBeforeProp[chainId][epochNumberIgnore2] + s.Require().True(ok, "example epoch should be in redemption rate map - update the test") + + s.App.RecordsKeeper.SetUserRedemptionRecord(s.Ctx, recordtypes.UserRedemptionRecord{ + Id: "D", + EpochNumber: epochNumberIgnore2, + HostZoneId: chainId, + StTokenAmount: stTokenAmount, + NativeTokenAmount: nativeAmountIgnored, + }) + s.App.RecordsKeeper.SetEpochUnbondingRecord(s.Ctx, recordtypes.EpochUnbondingRecord{ + EpochNumber: epochNumberIgnore2, + HostZoneUnbondings: []*recordtypes.HostZoneUnbonding{ + { + HostZoneId: chainId, + Status: recordtypes.HostZoneUnbonding_UNBONDING_QUEUE, + StTokenAmount: stTokenAmount, + NativeTokenAmount: nativeAmountIgnored, + UserRedemptionRecords: []string{"D"}, + }, + }, + }) + + // Return callback to check store after upgrade + return func() { + // Check the user redemption record amount for unbonding 1 + actualRedemptionRecord1, found := s.App.RecordsKeeper.GetUserRedemptionRecord(s.Ctx, "A") + s.Require().True(found, "record from first unbonding should have been found") + s.Require().Equal(expectedNativeAmount1.Int64(), actualRedemptionRecord1.NativeTokenAmount.Int64(), + "native amount on record from first unbonding") + + // Check the user redemption record amount for unbonding 2 + actualRedemptionRecord2, found := s.App.RecordsKeeper.GetUserRedemptionRecord(s.Ctx, "B") + s.Require().True(found, "record from second unbonding should have been found") + s.Require().Equal(expectedNativeAmount2.Int64(), actualRedemptionRecord2.NativeTokenAmount.Int64(), + "native amount on record from second unbonding") + + // Check the host zone unbonding amount for unbonding 1 + actualHostZoneUnbonding1, found := s.App.RecordsKeeper.GetHostZoneUnbondingByChainId(s.Ctx, epochNumberBefore, chainId) + s.Require().True(found, "host zone unbonding should have been found for second unbonding") + s.Require().Equal(expectedNativeAmount1.Int64(), actualHostZoneUnbonding1.NativeTokenAmount.Int64(), + "host zone native amount from first unbonding") + + // Check the host zone unbonding amount for unbonding 2 + actualHostZoneUnbonding2, found := s.App.RecordsKeeper.GetHostZoneUnbondingByChainId(s.Ctx, epochNumberAfter, chainId) + s.Require().True(found, "host zone unbonding should have been found for second unbonding") + s.Require().Equal(expectedNativeAmount2.Int64(), actualHostZoneUnbonding2.NativeTokenAmount.Int64(), + "host zone native amount from first unbonding") + + // Check that the ignored record 1 did not change + ignoredRecord1, found := s.App.RecordsKeeper.GetUserRedemptionRecord(s.Ctx, "C") + s.Require().True(found, "ignored record should have been found") + s.Require().Equal(nativeAmountIgnored.Int64(), ignoredRecord1.NativeTokenAmount.Int64(), + "native amount on ignored record should not have changed") + + // Check that the ignored record 2 did not change + ignoredRecord2, found := s.App.RecordsKeeper.GetUserRedemptionRecord(s.Ctx, "D") + s.Require().True(found, "ignored record should have been found") + s.Require().Equal(nativeAmountIgnored.Int64(), ignoredRecord2.NativeTokenAmount.Int64(), + "native amount on ignored record should not have changed") + } +} + +func (s *UpgradeTestSuite) TestUpdateUnbondingRecords() { + // We'll create the following scenario across two host zones + // T0 - HostZone1 Unbonds from EpochRecord1 - Record RR is 1.10 - Unbonded RR was 1.05 + // T1 - HostZone2 Unbonds from EpochRecord1 - Record RR is 1.50 - Unbonded RR was 1.40 + // ... + // T2 - HostZone1 Unbonds from EpochRecord2 - Record RR is 1.15 - Unbonded RR was 1.10 + // T3 - HostZone2 Unbonds from EpochRecord2 - Record RR is 1.65 - Unbonded RR was 1.50 + // ... + // upgrade prop submitted + // HostZone1 RR is 1.20 + // HostZone2 RR is 1.80 + // ... + // T4 - HostZone1 Unbonds from EpochRecord3 - Record RR is 1.32 <- Use implied RR of (1.2 + 1.4) / 2 = 1.30 + // T5 - HostZone2 Unbonds from EpochRecord3 - Record RR is 1.88 <- Use implied RR of (1.8 + 1.9) / 2 = 1.85 + // ... + // upgrade goes live + // HostZone1 RR is 1.40 + // HostZone2 RR is 1.90 + + // Create host zones with the RR at the time that the upgrade goes live + chainId1 := "chain-1" + chainId2 := "chain-2" + + hostZone1 := stakeibctypes.HostZone{ + ChainId: chainId1, + RedemptionRate: sdk.MustNewDecFromStr("1.4"), + } + hostZone2 := stakeibctypes.HostZone{ + ChainId: chainId2, + RedemptionRate: sdk.MustNewDecFromStr("1.9"), + } + s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone1) + s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone2) + + // Save down the redemption rates before the prop and at the time of the prop + // These are both hard coded in the upgrade handler + // For the before rates, use the rate that was actually unbonded (not the one in the record) + redemptionRatesAtTimeOfProp := map[string]sdk.Dec{ + chainId1: sdk.MustNewDecFromStr("1.2"), + chainId2: sdk.MustNewDecFromStr("1.8"), + } + redemptionRatesBeforeProp := map[string]map[uint64]sdk.Dec{ + chainId1: { + 1: sdk.MustNewDecFromStr("1.05"), + 2: sdk.MustNewDecFromStr("1.10"), + }, + chainId2: { + 1: sdk.MustNewDecFromStr("1.40"), + 2: sdk.MustNewDecFromStr("1.50"), + }, + } + + // Define all user redemption records across each of the host zone unbondings + // We store just the redemption rate now, but will set native amount when setting them + userRedemptionRecordTestCases := []UserRedemptionRecordTestCases{ + // Epoch 1 - HostZone1 - RR 1.10 + {Id: "A", EpochNumber: 1, HostZoneId: chainId1, StAmount: 1000, RecordRR: "1.1", UnbondedRR: "1.05"}, + {Id: "B", EpochNumber: 1, HostZoneId: chainId1, StAmount: 2000, RecordRR: "1.1", UnbondedRR: "1.05"}, + // Epoch 1 - HostZone2 - RR 1.50 + {Id: "C", EpochNumber: 1, HostZoneId: chainId2, StAmount: 3000, RecordRR: "1.5", UnbondedRR: "1.4"}, + {Id: "D", EpochNumber: 1, HostZoneId: chainId2, StAmount: 4000, RecordRR: "1.5", UnbondedRR: "1.4"}, + + // Epoch 2 - HostZone1 - RR 1.15 + {Id: "E", EpochNumber: 2, HostZoneId: chainId1, StAmount: 5000, RecordRR: "1.15", UnbondedRR: "1.1"}, + {Id: "F", EpochNumber: 2, HostZoneId: chainId1, StAmount: 6000, RecordRR: "1.15", UnbondedRR: "1.1"}, + {Id: "G", EpochNumber: 2, HostZoneId: chainId1, StAmount: 7000, RecordRR: "1.15", UnbondedRR: "1.1"}, + // Epoch 2 - HostZone2 - RR 1.65 + {Id: "H", EpochNumber: 2, HostZoneId: chainId2, StAmount: 8000, RecordRR: "1.65", UnbondedRR: "1.5"}, + {Id: "I", EpochNumber: 2, HostZoneId: chainId2, StAmount: 9000, RecordRR: "1.65", UnbondedRR: "1.5"}, + + // Epoch 2 - HostZone1 - RR 1.40 + {Id: "J", EpochNumber: 3, HostZoneId: chainId1, StAmount: 10000, RecordRR: "1.40", UnbondedRR: "1.30"}, + {Id: "K", EpochNumber: 3, HostZoneId: chainId1, StAmount: 11000, RecordRR: "1.40", UnbondedRR: "1.30"}, + // Epoch 2 - HostZone2 - RR 1.90 + {Id: "L", EpochNumber: 3, HostZoneId: chainId2, StAmount: 12000, RecordRR: "1.90", UnbondedRR: "1.85"}, + {Id: "M", EpochNumber: 3, HostZoneId: chainId2, StAmount: 13000, RecordRR: "1.90", UnbondedRR: "1.85"}, + {Id: "N", EpochNumber: 3, HostZoneId: chainId2, StAmount: 14000, RecordRR: "1.90", UnbondedRR: "1.85"}, + } + + // Consolidate the totals from above into the host zone unbonding level by + // grabbing the unbonded redemption rate, summing up the stToken amounts, + // and aggregating a list of the redemption Ids + // We'll store it all in a mapping of epochNumber -> chainId -> aggregate values + hostZoneUnbondingsInfoMap := map[uint64]map[string]HostZoneUnbondingTestCase{ + 1: { + chainId1: {RecordRR: "1.1", UnbondedRR: "1.05", URRs: []string{"A", "B"}, StAmount: 1000 + 2000}, + chainId2: {RecordRR: "1.5", UnbondedRR: "1.4", URRs: []string{"C", "D"}, StAmount: 3000 + 4000}, + }, + 2: { + chainId1: {RecordRR: "1.15", UnbondedRR: "1.1", URRs: []string{"E", "F", "G"}, StAmount: 5000 + 6000 + 7000}, + chainId2: {RecordRR: "1.65", UnbondedRR: "1.5", URRs: []string{"H", "I"}, StAmount: 8000 + 9000}, + }, + 3: { + chainId1: {RecordRR: "1.40", UnbondedRR: "1.30", URRs: []string{"J", "K"}, StAmount: 10000 + 11000}, + chainId2: {RecordRR: "1.90", UnbondedRR: "1.85", URRs: []string{"L", "M", "N"}, StAmount: 12000 + 13000 + 14000}, + }, + } + + // Write all the redemption records to the store, calculating the native amount from + // the "record" redemption rate + for i, userTestCase := range userRedemptionRecordTestCases { + epochNumber := userTestCase.EpochNumber + hostZoneId := userTestCase.HostZoneId + stTokenAmount := sdkmath.NewInt(userTestCase.StAmount) + + // Calculate the native amount from the "record RR" - this is the value that will be in the + // store before the upgrade + recordRR := sdk.MustNewDecFromStr(userTestCase.RecordRR) + recordNativeAmount := sdk.NewDecFromInt(stTokenAmount).Mul(recordRR).TruncateInt() + + // Calculate the native amount from the "unbond RR" - this is the implied RR from the + // actual unbonding + unbondRR := sdk.MustNewDecFromStr(userTestCase.UnbondedRR) + actualUnbondAmount := sdk.NewDecFromInt(stTokenAmount).Mul(unbondRR).TruncateInt() + + // Create the user redmeption record + s.App.RecordsKeeper.SetUserRedemptionRecord(s.Ctx, recordtypes.UserRedemptionRecord{ + Id: userTestCase.Id, + EpochNumber: epochNumber, + HostZoneId: hostZoneId, + StTokenAmount: stTokenAmount, + NativeTokenAmount: recordNativeAmount, + }) + + // Update the native amounts in the test case + userRedemptionRecordTestCases[i].InitialNativeAmount = recordNativeAmount + userRedemptionRecordTestCases[i].ExpectedNativeAmount = actualUnbondAmount + } + + // Then do a similar loop for the host zone unbonding records + for epochNumber, chainToHZUMap := range hostZoneUnbondingsInfoMap { + for chainId, hostTestCase := range chainToHZUMap { + stTokenAmount := sdkmath.NewInt(hostTestCase.StAmount) + + // Calculate the native amount from the "record RR" - this is the value that will be in the + // store before the upgrade + recordRR := sdk.MustNewDecFromStr(hostTestCase.RecordRR) + recordNativeAmount := sdk.NewDecFromInt(stTokenAmount).Mul(recordRR).TruncateInt() + + // Calculate the native amount from the "unbond RR" - this is the implied RR from the + // actual unbonding + unbondRR := sdk.MustNewDecFromStr(hostTestCase.UnbondedRR) + actualUnbondAmount := sdk.NewDecFromInt(stTokenAmount).Mul(unbondRR).TruncateInt() + + // Initialize the epoch unbonding record if it hasn't happened already + if _, found := s.App.RecordsKeeper.GetEpochUnbondingRecord(s.Ctx, epochNumber); !found { + s.App.RecordsKeeper.SetEpochUnbondingRecord(s.Ctx, recordtypes.EpochUnbondingRecord{ + EpochNumber: epochNumber, + }) + } + + // Set host zone unbonding record + hostZoneUnbondingRecord := recordtypes.HostZoneUnbonding{ + Status: recordtypes.HostZoneUnbonding_EXIT_TRANSFER_QUEUE, + HostZoneId: chainId, + UserRedemptionRecords: hostTestCase.URRs, + StTokenAmount: stTokenAmount, + NativeTokenAmount: recordNativeAmount, + } + err := s.App.RecordsKeeper.SetHostZoneUnbondingRecord(s.Ctx, epochNumber, chainId, hostZoneUnbondingRecord) + s.Require().NoError(err, "no error expected when setting host zone unbonding") + + // Update the test case so we can check against expectations later + updatedTestCase := hostZoneUnbondingsInfoMap[epochNumber][chainId] + updatedTestCase.InitialNativeAmount = recordNativeAmount + updatedTestCase.ExpectedNativeAmount = actualUnbondAmount + hostZoneUnbondingsInfoMap[epochNumber][chainId] = updatedTestCase + } + } + + // Run the migration + startingEpochNumber := uint64(1) + err := v18.UpdateUnbondingRecords( + s.Ctx, + s.App.StakeibcKeeper, + s.App.RecordsKeeper, + startingEpochNumber, + redemptionRatesBeforeProp, + redemptionRatesAtTimeOfProp, + ) + s.Require().NoError(err, "no error expected when updating records") + + // Confirm user redemption records were updated + for _, userTestCase := range userRedemptionRecordTestCases { + actualRedemptionRecord, found := s.App.RecordsKeeper.GetUserRedemptionRecord(s.Ctx, userTestCase.Id) + s.Require().True(found, "user redemption record %s should have been found after upgrade", userTestCase.Id) + s.Require().Equal(userTestCase.ExpectedNativeAmount.Int64(), actualRedemptionRecord.NativeTokenAmount.Int64(), + "user redemption record native amount for %s", userTestCase.Id) + } + + // Confirm host zone unbonding records were updated + for epochNumber, chainToHZUMap := range hostZoneUnbondingsInfoMap { + for chainId, hostTestCase := range chainToHZUMap { + actualHostZoneUnbonding, found := s.App.RecordsKeeper.GetHostZoneUnbondingByChainId(s.Ctx, epochNumber, chainId) + s.Require().True(found, "HZU for epoch %d and host zone %s should have been found", epochNumber, chainId) + s.Require().Equal(hostTestCase.ExpectedNativeAmount.Int64(), actualHostZoneUnbonding.NativeTokenAmount.Int64(), + "host zone unbonding native amount for epoch %d and host zone %s", epochNumber, chainId) + } + } +} + +func (s *UpgradeTestSuite) TestDecrementTerraDelegationChangesInProgress() { + // Create list of validators + validators := []*types.Validator{} + for i := 0; i < 5; i++ { + address := fmt.Sprintf("val-%d", i) + validators = append(validators, &types.Validator{Address: address, DelegationChangesInProgress: int64(i)}) + } + + // set the host zone + hostZone1 := stakeibctypes.HostZone{ + ChainId: v18.TerraChainId, + Validators: validators, + } + s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone1) + + err := v18.DecrementTerraDelegationChangesInProgress(s.Ctx, s.App.StakeibcKeeper) + s.Require().NoError(err, "no error decrementing terra delegation changes in progress") + + hostZoneAfter, err := s.App.StakeibcKeeper.GetActiveHostZone(s.Ctx, v18.TerraChainId) + s.Require().NoError(err, "get host zone") + + // check each val + expectedVals := []int64{0, 0, 0, 0, 1, 2} + for i := 0; i < 5; i++ { + s.Require().Equal(expectedVals[i], hostZoneAfter.Validators[i].DelegationChangesInProgress) + } +} + +func (s *UpgradeTestSuite) TestDecrementTerraDelegationChangesInProgress_ZoneNotFound() { + // test host zone not found + hostZoneWrongChainId := stakeibctypes.HostZone{ + ChainId: "not-terra", + } + s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZoneWrongChainId) + + err := v18.DecrementTerraDelegationChangesInProgress(s.Ctx, s.App.StakeibcKeeper) + s.Require().Error(err, "host zone not found") +} + +func (s *UpgradeTestSuite) TestExecuteProp228IfPassed() { + sender := sdk.MustAccAddressFromBech32(v18.IncentiveProgramAddress) + receiver := sdk.MustAccAddressFromBech32(v18.StrideFoundationAddress_F4) + + // Fund the sender + s.FundAccount(sender, sdk.NewCoin(v18.Strd, v18.Prop228SendAmount)) + + // Attempt to run when the prop has not been created yet - it should error + err := v18.ExecuteProp228IfPassed(s.Ctx, s.App.BankKeeper, s.App.GovKeeper) + s.Require().ErrorContains(err, "Prop 228 not found") + + // Store the prop in status rejected + s.App.GovKeeper.SetProposal(s.Ctx, govtypes.Proposal{ + Id: v18.Prop228ProposalId, + Status: govtypes.ProposalStatus_PROPOSAL_STATUS_REJECTED, + }) + + // Attempt to run when it's been rejected, it should not error but no funds + // should be sent + err = v18.ExecuteProp228IfPassed(s.Ctx, s.App.BankKeeper, s.App.GovKeeper) + s.Require().NoError(err, "no error expected after rejected prop") + + senderBalance := s.App.BankKeeper.GetBalance(s.Ctx, sender, v18.Strd).Amount + receiverBalance := s.App.BankKeeper.GetBalance(s.Ctx, receiver, v18.Strd).Amount + + s.Require().Zero(receiverBalance.Int64(), "receiver balance should not have changed") + s.Require().Equal(v18.Prop228SendAmount.Int64(), senderBalance.Int64(), + "sender balance should not have changed") + + // Update the prop to be successful + s.App.GovKeeper.SetProposal(s.Ctx, govtypes.Proposal{ + Id: v18.Prop228ProposalId, + Status: govtypes.ProposalStatus_PROPOSAL_STATUS_PASSED, + }) + + // Execute the prop again and confirm balances were updated + err = v18.ExecuteProp228IfPassed(s.Ctx, s.App.BankKeeper, s.App.GovKeeper) + s.Require().NoError(err, "no error expected after passed prop") + + senderBalance = s.App.BankKeeper.GetBalance(s.Ctx, sender, v18.Strd).Amount + receiverBalance = s.App.BankKeeper.GetBalance(s.Ctx, receiver, v18.Strd).Amount + + s.Require().Zero(senderBalance.Int64(), "sender balance should be zero") + s.Require().Equal(v18.Prop228SendAmount.Int64(), receiverBalance.Int64(), + "receiver address should have recieved prop funds") +} diff --git a/x/stakeibc/keeper/hooks.go b/x/stakeibc/keeper/hooks.go index 8c3a2d351b..bc983804a8 100644 --- a/x/stakeibc/keeper/hooks.go +++ b/x/stakeibc/keeper/hooks.go @@ -101,11 +101,6 @@ func (k Keeper) BeforeEpochStart(ctx sdk.Context, epochInfo epochstypes.EpochInf k.SwapAllRewardTokens(ctx) } - // TODO [cleanup]: Remove after v17 upgrade - // Submit ICA to disable gaia tokenization (this only needs to be run once) - if epochInfo.Identifier == epochstypes.DAY_EPOCH && epochNumber%10 == 0 { - k.DisableHubTokenization(ctx) - } } func (k Keeper) AfterEpochEnd(ctx sdk.Context, epochInfo epochstypes.EpochInfo) {} diff --git a/x/stakeibc/keeper/unbonding_records.go b/x/stakeibc/keeper/unbonding_records.go index ba85253ea4..2e56470f3f 100644 --- a/x/stakeibc/keeper/unbonding_records.go +++ b/x/stakeibc/keeper/unbonding_records.go @@ -441,14 +441,17 @@ func (k Keeper) UnbondFromHostZone(ctx sdk.Context, hostZone types.HostZone) err } // Get the list of relevant records that should unbond - epochUnbondingRecordIds, epochNumberToHostZoneUnbondingMap := k.GetQueuedHostZoneUnbondingRecords(ctx, hostZone.ChainId) + _, initialEpochNumberToHostZoneUnbondingMap := k.GetQueuedHostZoneUnbondingRecords(ctx, hostZone.ChainId) // Update the native unbond amount on all relevant records // The native amount is calculated from the stTokens - if err := k.RefreshUnbondingNativeTokenAmounts(ctx, epochNumberToHostZoneUnbondingMap); err != nil { + if err := k.RefreshUnbondingNativeTokenAmounts(ctx, initialEpochNumberToHostZoneUnbondingMap); err != nil { return err } + // Fetch the records again with the updated native amounts + epochUnbondingRecordIds, epochNumberToHostZoneUnbondingMap := k.GetQueuedHostZoneUnbondingRecords(ctx, hostZone.ChainId) + // Sum the total number of native tokens that from the records above that are ready to unbond totalUnbondAmount := k.GetTotalUnbondAmount(ctx, epochNumberToHostZoneUnbondingMap) k.Logger(ctx).Info(utils.LogWithHostZone(hostZone.ChainId, diff --git a/x/stakeibc/keeper/unbonding_records_get_host_zone_unbondings_msgs_test.go b/x/stakeibc/keeper/unbonding_records_get_host_zone_unbondings_msgs_test.go index 9478864ac9..acf8c8a5c4 100644 --- a/x/stakeibc/keeper/unbonding_records_get_host_zone_unbondings_msgs_test.go +++ b/x/stakeibc/keeper/unbonding_records_get_host_zone_unbondings_msgs_test.go @@ -1,6 +1,8 @@ package keeper_test import ( + "fmt" + sdkmath "cosmossdk.io/math" sdk "github.com/cosmos/cosmos-sdk/types" "github.com/cosmos/gogoproto/proto" @@ -58,22 +60,34 @@ func (s *KeeperTestSuite) SetupTestUnbondFromHostZone( DelegationIcaAddress: "cosmos_DELEGATION", Validators: validators, TotalDelegations: totalStake, + RedemptionRate: sdk.OneDec(), } s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone) // Store the total unbond amount across two epoch unbonding records + // and create a user redemption record for each halfUnbondAmount := unbondAmount.Quo(sdkmath.NewInt(2)) for i := uint64(1); i <= 2; i++ { + redemptionRecordId := fmt.Sprintf("id-%d", i) + s.App.RecordsKeeper.SetEpochUnbondingRecord(s.Ctx, recordtypes.EpochUnbondingRecord{ EpochNumber: i, HostZoneUnbondings: []*recordtypes.HostZoneUnbonding{ { - HostZoneId: HostChainId, - Status: recordtypes.HostZoneUnbonding_UNBONDING_QUEUE, - NativeTokenAmount: halfUnbondAmount, + HostZoneId: HostChainId, + Status: recordtypes.HostZoneUnbonding_UNBONDING_QUEUE, + StTokenAmount: halfUnbondAmount, + NativeTokenAmount: halfUnbondAmount, + UserRedemptionRecords: []string{redemptionRecordId}, }, }, }) + + s.App.RecordsKeeper.SetUserRedemptionRecord(s.Ctx, recordtypes.UserRedemptionRecord{ + Id: redemptionRecordId, + StTokenAmount: halfUnbondAmount, + NativeTokenAmount: halfUnbondAmount, + }) } // Mock the epoch tracker to timeout 90% through the epoch @@ -351,6 +365,42 @@ func (s *KeeperTestSuite) TestUnbondFromHostZone_Successful_UnbondTotalGreaterTh s.CheckUnbondingMessages(tc, expectedUnbondings) } +func (s *KeeperTestSuite) TestUnbondFromHostZone_Successful_RefreshedNativeAmount() { + // Total Stake: 1000 + // + // Unbond Amount with old redemption rate (RR = 1): 100 + // Unbond Amount with new redemption rate (RR = 1.5): 150 + // + // Stake After Unbond: 850 + updatedRedemptionRate := sdk.MustNewDecFromStr("1.5") + unbondAmountWithOldRate := sdkmath.NewInt(100) + unbondAmountWithNewRate := sdkmath.NewInt(150) + totalStake := sdkmath.NewInt(1000) + totalWeight := int64(100) + + // Since this test is only intended to check the native token refresh, + // we don't need more than 1 validator + // That validator should unbond the full amount with the new redemption rate + validators := []*types.Validator{ + {Address: "valA", Weight: 100, Delegation: totalStake}, + } + expectedUnbondings := []ValidatorUnbonding{ + {Validator: "valA", UnbondAmount: unbondAmountWithNewRate}, + } + + // Setup using default, and then override the redemption rate value to update it from 1.0 to 1.5 + tc := s.SetupTestUnbondFromHostZone(totalWeight, totalStake, unbondAmountWithOldRate, validators) + + hostZone := s.MustGetHostZone(HostChainId) + hostZone.RedemptionRate = updatedRedemptionRate + s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone) + + // Finally check that the unbondings matched - mostly checking that there was a greater amount + // unbonded than was originally in the host zone unbonding record + tc.totalUnbondAmount = unbondAmountWithNewRate + s.CheckUnbondingMessages(tc, expectedUnbondings) +} + func (s *KeeperTestSuite) TestUnbondFromHostZone_NoDelegationAccount() { // Call unbond on a host zone without a delegation account - it should error invalidHostZone := types.HostZone{ diff --git a/x/stakeibc/keeper/unbonding_records_initiate_all_unbondings_test.go b/x/stakeibc/keeper/unbonding_records_initiate_all_unbondings_test.go index b9d9db882d..1953b76b7c 100644 --- a/x/stakeibc/keeper/unbonding_records_initiate_all_unbondings_test.go +++ b/x/stakeibc/keeper/unbonding_records_initiate_all_unbondings_test.go @@ -2,6 +2,7 @@ package keeper_test import ( sdkmath "cosmossdk.io/math" + sdk "github.com/cosmos/cosmos-sdk/types" ibctesting "github.com/cosmos/ibc-go/v7/testing" _ "github.com/stretchr/testify/suite" @@ -9,18 +10,14 @@ import ( "github.com/Stride-Labs/stride/v17/x/stakeibc/types" ) -type InitiateAllHostZoneUnbondingsTestCase struct { - epochUnbondingRecords []recordtypes.EpochUnbondingRecord - hostZones []types.HostZone -} - -func (s *KeeperTestSuite) SetupInitiateAllHostZoneUnbondings() InitiateAllHostZoneUnbondingsTestCase { +func (s *KeeperTestSuite) SetupInitiateAllHostZoneUnbondings() { s.CreateICAChannel("GAIA.DELEGATION") gaiaValAddr := "cosmos_VALIDATOR" osmoValAddr := "osmo_VALIDATOR" gaiaDelegationAddr := "cosmos_DELEGATION" osmoDelegationAddr := "osmo_DELEGATION" + // define the host zone with total delegation and validators with staked amounts gaiaValidators := []*types.Validator{ { @@ -51,6 +48,7 @@ func (s *KeeperTestSuite) SetupInitiateAllHostZoneUnbondings() InitiateAllHostZo DelegationIcaAddress: gaiaDelegationAddr, TotalDelegations: sdkmath.NewInt(5_000_000), ConnectionId: ibctesting.FirstConnectionID, + RedemptionRate: sdk.OneDec(), }, { ChainId: OsmoChainId, @@ -61,38 +59,55 @@ func (s *KeeperTestSuite) SetupInitiateAllHostZoneUnbondings() InitiateAllHostZo DelegationIcaAddress: osmoDelegationAddr, TotalDelegations: sdkmath.NewInt(5_000_000), ConnectionId: ibctesting.FirstConnectionID, + RedemptionRate: sdk.OneDec(), }, } + for _, hostZone := range hostZones { + s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone) + } + // list of epoch unbonding records - default_unbonding := []*recordtypes.HostZoneUnbonding{ - { - HostZoneId: HostChainId, - StTokenAmount: sdkmath.NewInt(1_900_000), - NativeTokenAmount: sdkmath.NewInt(2_000_000), - Denom: Atom, - Status: recordtypes.HostZoneUnbonding_UNBONDING_QUEUE, - }, - { - HostZoneId: OsmoChainId, - StTokenAmount: sdkmath.NewInt(2_800_000), - NativeTokenAmount: sdkmath.NewInt(3), - Denom: Osmo, - Status: recordtypes.HostZoneUnbonding_UNBONDING_QUEUE, + epochNumber := uint64(5) + + redemptionRecordId1 := recordtypes.UserRedemptionRecordKeyFormatter(HostChainId, epochNumber, "receiver") + redemptionRecordId2 := recordtypes.UserRedemptionRecordKeyFormatter(OsmoChainId, epochNumber, "receiver") + + epochUnbondingRecord := recordtypes.EpochUnbondingRecord{ + EpochNumber: epochNumber, + HostZoneUnbondings: []*recordtypes.HostZoneUnbonding{ + { + HostZoneId: HostChainId, + StTokenAmount: sdkmath.NewInt(1_900_000), + NativeTokenAmount: sdkmath.NewInt(2_000_000), + Denom: Atom, + Status: recordtypes.HostZoneUnbonding_UNBONDING_QUEUE, + UserRedemptionRecords: []string{redemptionRecordId1}, + }, + { + HostZoneId: OsmoChainId, + StTokenAmount: sdkmath.NewInt(2_800_000), + NativeTokenAmount: sdkmath.NewInt(3), + Denom: Osmo, + Status: recordtypes.HostZoneUnbonding_UNBONDING_QUEUE, + UserRedemptionRecords: []string{redemptionRecordId2}, + }, }, } - epochUnbondingRecords := []recordtypes.EpochUnbondingRecord{} - for _, epochNumber := range []uint64{5} { - epochUnbondingRecord := recordtypes.EpochUnbondingRecord{ - EpochNumber: epochNumber, - HostZoneUnbondings: default_unbonding, - } - epochUnbondingRecords = append(epochUnbondingRecords, epochUnbondingRecord) - s.App.RecordsKeeper.SetEpochUnbondingRecord(s.Ctx, epochUnbondingRecord) - } + s.App.RecordsKeeper.SetEpochUnbondingRecord(s.Ctx, epochUnbondingRecord) - for _, hostZone := range hostZones { - s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone) - } + s.App.RecordsKeeper.SetUserRedemptionRecord(s.Ctx, recordtypes.UserRedemptionRecord{ + Id: redemptionRecordId1, + HostZoneId: HostChainId, + EpochNumber: epochNumber, + StTokenAmount: epochUnbondingRecord.HostZoneUnbondings[0].StTokenAmount, + }) + + s.App.RecordsKeeper.SetUserRedemptionRecord(s.Ctx, recordtypes.UserRedemptionRecord{ + Id: redemptionRecordId2, + HostZoneId: OsmoChainId, + EpochNumber: epochNumber, + StTokenAmount: epochUnbondingRecord.HostZoneUnbondings[1].StTokenAmount, + }) s.App.StakeibcKeeper.SetEpochTracker(s.Ctx, types.EpochTracker{ EpochIdentifier: "day", @@ -100,11 +115,6 @@ func (s *KeeperTestSuite) SetupInitiateAllHostZoneUnbondings() InitiateAllHostZo NextEpochStartTime: uint64(2661750006000000000), // arbitrary time in the future, year 2056 I believe Duration: uint64(1000000000000), // 16 min 40 sec }) - - return InitiateAllHostZoneUnbondingsTestCase{ - epochUnbondingRecords: epochUnbondingRecords, - hostZones: hostZones, - } } func (s *KeeperTestSuite) TestInitiateAllHostZoneUnbondings_Successful() {