Skip to content

Commit

Permalink
dynamically update user redemption records during unbonding (#1053)
Browse files Browse the repository at this point in the history
Co-authored-by: riley-stride <104941670+riley-stride@users.noreply.github.com>
Co-authored-by: vish-stride <vishal@stridelabs.co>
Co-authored-by: shellvish <104537253+shellvish@users.noreply.github.com>
Co-authored-by: ethan-stride <126913021+ethan-stride@users.noreply.github.com>
Co-authored-by: Aidan Salzmann <aidan@stridelabs.co>
  • Loading branch information
6 people authored Jan 11, 2024
1 parent e96f3f8 commit 7f88955
Show file tree
Hide file tree
Showing 15 changed files with 709 additions and 101 deletions.
54 changes: 54 additions & 0 deletions app/upgrades/v17/upgrades.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
icqkeeper "github.com/Stride-Labs/stride/v16/x/interchainquery/keeper"
ratelimitkeeper "github.com/Stride-Labs/stride/v16/x/ratelimit/keeper"
ratelimittypes "github.com/Stride-Labs/stride/v16/x/ratelimit/types"
recordtypes "github.com/Stride-Labs/stride/v16/x/records/types"
stakeibckeeper "github.com/Stride-Labs/stride/v16/x/stakeibc/keeper"
stakeibctypes "github.com/Stride-Labs/stride/v16/x/stakeibc/types"
)
Expand Down Expand Up @@ -89,6 +90,11 @@ func CreateUpgradeHandler(
ctx.Logger().Info("Migrating stakeibc params...")
MigrateStakeibcParams(ctx, stakeibcKeeper)

ctx.Logger().Info("Migrating Unbonding Records...")
if err := MigrateUnbondingRecords(ctx, stakeibcKeeper); err != nil {
return vm, errorsmod.Wrapf(err, "unable to migrate unbonding records")
}

ctx.Logger().Info("Migrating host zones...")
if err := RegisterCommunityPoolAddresses(ctx, stakeibcKeeper); err != nil {
return vm, errorsmod.Wrapf(err, "unable to register community pool addresses on host zones")
Expand Down Expand Up @@ -140,6 +146,54 @@ func MigrateStakeibcParams(ctx sdk.Context, k stakeibckeeper.Keeper) {
k.SetParams(ctx, params)
}

// Migrate the user redemption records to add the stToken amount, calculated by estimating
// the redemption rate from the corresponding host zone unbonding records
// UserUnbondingRecords previously only used Native Token Amounts, we now want to use StTokenAmounts
// We only really need to migrate records in status UNBONDING_QUEUE or UNBONDING_IN_PROGRESS
// because the stToken amount is never used after unbonding is initiated
func MigrateUnbondingRecords(ctx sdk.Context, k stakeibckeeper.Keeper) error {
for _, epochUnbondingRecord := range k.RecordsKeeper.GetAllEpochUnbondingRecord(ctx) {
for _, hostZoneUnbonding := range epochUnbondingRecord.HostZoneUnbondings {
// If a record is in state claimable, the native token amount can't be trusted
// since it gets decremented with each claim
// As a result, we can't accurately estimate the redemption rate for these
// user redemption records (but it also doesn't matter since the stToken
// amount on the records is not used)
if hostZoneUnbonding.Status == recordtypes.HostZoneUnbonding_CLAIMABLE {
continue
}
// similarly, if there aren't any tokens to unbond, we don't want to modify the record
// as we won't be able to estimate a redemption rate
if hostZoneUnbonding.NativeTokenAmount.IsZero() {
continue
}

// Calculate the estimated redemption rate
nativeTokenAmountDec := sdk.NewDecFromInt(hostZoneUnbonding.NativeTokenAmount)
stTokenAmountDec := sdk.NewDecFromInt(hostZoneUnbonding.StTokenAmount)
// this estimated rate is the amount of stTokens that would be received for 1 native token
// e.g. if the rate is 0.5, then 1 native token would be worth 0.5 stTokens
// estimatedStTokenConversionRate is 1 / redemption rate
estimatedStTokenConversionRate := stTokenAmountDec.Quo(nativeTokenAmountDec)

// Loop through User Redemption Records and insert an estimated stTokenAmount
for _, userRedemptionRecordId := range hostZoneUnbonding.UserRedemptionRecords {
userRedemptionRecord, found := k.RecordsKeeper.GetUserRedemptionRecord(ctx, userRedemptionRecordId)
if !found {
// this would happen if the user has already claimed the unbonding, but given the status check above, this should never happen
k.Logger(ctx).Error(fmt.Sprintf("user redemption record %s not found", userRedemptionRecordId))
continue
}

userRedemptionRecord.StTokenAmount = estimatedStTokenConversionRate.Mul(sdk.NewDecFromInt(userRedemptionRecord.Amount)).RoundInt()
k.RecordsKeeper.SetUserRedemptionRecord(ctx, userRedemptionRecord)
}
}
}

return nil
}

// Migrates the host zones to the new structure which supports community pool liquid staking
// We don't have to perform a true migration here since only new fields were added
// (in other words, we can deserialize the old host zone structs into the new types)
Expand Down
126 changes: 126 additions & 0 deletions app/upgrades/v17/upgrades_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package v17_test

import (
"fmt"
"strconv"
"testing"

sdkmath "cosmossdk.io/math"
Expand All @@ -12,6 +13,7 @@ import (

icqtypes "github.com/Stride-Labs/stride/v16/x/interchainquery/types"
ratelimittypes "github.com/Stride-Labs/stride/v16/x/ratelimit/types"
recordtypes "github.com/Stride-Labs/stride/v16/x/records/types"

"github.com/Stride-Labs/stride/v16/app/apptesting"
v17 "github.com/Stride-Labs/stride/v16/app/upgrades/v17"
Expand Down Expand Up @@ -75,6 +77,7 @@ func (s *UpgradeTestSuite) TestUpgrade() {

// Setup store before upgrade
checkHostZonesAfterUpgrade := s.SetupHostZonesBeforeUpgrade()
checkMigrateUnbondingRecords := s.SetupMigrateUnbondingRecords()
checkRateLimitsAfterUpgrade := s.SetupRateLimitsBeforeUpgrade()
checkCommunityPoolTaxAfterUpgrade := s.SetupCommunityPoolTaxBeforeUpgrade()
checkQueriesAfterUpgrade := s.SetupQueriesBeforeUpgrade()
Expand All @@ -86,6 +89,7 @@ func (s *UpgradeTestSuite) TestUpgrade() {

// Check state after upgrade
checkHostZonesAfterUpgrade()
checkMigrateUnbondingRecords()
checkRateLimitsAfterUpgrade()
checkCommunityPoolTaxAfterUpgrade()
checkQueriesAfterUpgrade()
Expand Down Expand Up @@ -203,6 +207,128 @@ func (s *UpgradeTestSuite) SetupHostZonesBeforeUpgrade() func() {
}
}

func (s *UpgradeTestSuite) SetupMigrateUnbondingRecords() func() {
// Create EURs for two host zones
// 2 HZU on each host zone will trigger URR updates
// - UNBONDING_QUEUE
// - UNBONDING_IN_PROGRESS
// - EXIT_TRANSFER_QUEUE
// - EXIT_TRANSFER_IN_PROGRESS
// 2 HZU on each host zone will not trigger URR updates
// - 1 HZU has 0 NativeTokenAmount
// - 1 HZU has status CLAIMABLE

nativeTokenAmount := int64(2000000)
stTokenAmount := int64(1000000)
URRAmount := int64(500)

// create mockURRIds 1 through 6 and store the URRs
for i := 1; i <= 6; i++ {
mockURRId := strconv.Itoa(i)
mockURR := recordtypes.UserRedemptionRecord{
Id: mockURRId,
Amount: sdkmath.NewInt(URRAmount),
}
s.App.RecordsKeeper.SetUserRedemptionRecord(s.Ctx, mockURR)
}

epochUnbondingRecord := recordtypes.EpochUnbondingRecord{
EpochNumber: 1,
HostZoneUnbondings: []*recordtypes.HostZoneUnbonding{
// HZUs that will trigger URR updates
{
HostZoneId: v17.GaiaChainId,
NativeTokenAmount: sdkmath.NewInt(nativeTokenAmount),
StTokenAmount: sdkmath.NewInt(stTokenAmount),
Status: recordtypes.HostZoneUnbonding_UNBONDING_QUEUE,
UserRedemptionRecords: []string{"1"},
},
{
HostZoneId: SommelierChainId,
NativeTokenAmount: sdkmath.NewInt(nativeTokenAmount),
StTokenAmount: sdkmath.NewInt(stTokenAmount),
Status: recordtypes.HostZoneUnbonding_EXIT_TRANSFER_QUEUE,
UserRedemptionRecords: []string{"2"},
},
},
}
epochUnbondingRecord2 := recordtypes.EpochUnbondingRecord{
EpochNumber: 2,
HostZoneUnbondings: []*recordtypes.HostZoneUnbonding{
// HZUs that will trigger URR updates
{
HostZoneId: v17.GaiaChainId,
NativeTokenAmount: sdkmath.NewInt(nativeTokenAmount),
StTokenAmount: sdkmath.NewInt(stTokenAmount),
Status: recordtypes.HostZoneUnbonding_UNBONDING_IN_PROGRESS,
UserRedemptionRecords: []string{"3"},
},
{
HostZoneId: SommelierChainId,
NativeTokenAmount: sdkmath.NewInt(nativeTokenAmount),
StTokenAmount: sdkmath.NewInt(stTokenAmount),
Status: recordtypes.HostZoneUnbonding_EXIT_TRANSFER_IN_PROGRESS,
UserRedemptionRecords: []string{"4"},
},
},
}
epochUnbondingRecord3 := recordtypes.EpochUnbondingRecord{
EpochNumber: 3,
HostZoneUnbondings: []*recordtypes.HostZoneUnbonding{
// HZUs that will not trigger URR updates
{
HostZoneId: v17.GaiaChainId,
// Will not trigger update because NativeTokenAmount is 0
NativeTokenAmount: sdkmath.NewInt(0),
StTokenAmount: sdkmath.NewInt(stTokenAmount),
Status: recordtypes.HostZoneUnbonding_UNBONDING_QUEUE,
UserRedemptionRecords: []string{"5"},
},
{
HostZoneId: v17.GaiaChainId,
NativeTokenAmount: sdkmath.NewInt(nativeTokenAmount),
StTokenAmount: sdkmath.NewInt(stTokenAmount),
// Will not trigger update because status is CLAIMABLE
Status: recordtypes.HostZoneUnbonding_CLAIMABLE,
UserRedemptionRecords: []string{"6"},
},
},
}
s.App.RecordsKeeper.SetEpochUnbondingRecord(s.Ctx, epochUnbondingRecord)
s.App.RecordsKeeper.SetEpochUnbondingRecord(s.Ctx, epochUnbondingRecord2)
s.App.RecordsKeeper.SetEpochUnbondingRecord(s.Ctx, epochUnbondingRecord3)

return func() {
// conversionRate is stTokenAmount / nativeTokenAmount
conversionRate := sdk.NewDec(stTokenAmount).Quo(sdk.NewDec(nativeTokenAmount))
expectedConversionRate := sdk.MustNewDecFromStr("0.5")
s.Require().Equal(expectedConversionRate, conversionRate, "expected conversion rate (1/redemption rate)")

// stTokenAmount is conversionRate * URRAmount
stTokenAmount := conversionRate.Mul(sdk.NewDec(URRAmount)).RoundInt()
expectedStTokenAmount := sdkmath.NewInt(250)
s.Require().Equal(stTokenAmount, expectedStTokenAmount, "expected st token amount")

// Verify URR stToken amounts are set correctly for records 1 through 4
for i := 1; i <= 4; i++ {
mockURRId := strconv.Itoa(i)
mockURR, found := s.App.RecordsKeeper.GetUserRedemptionRecord(s.Ctx, mockURRId)
s.Require().True(found)
s.Require().Equal(expectedStTokenAmount, mockURR.StTokenAmount, "URR %s - st token amount", mockURRId)
}

// Verify URRs with status CLAIMABLE are skipped (record 5)
// Verify HZUs with 0 NativeTokenAmount are skipped (record 6)
for i := 5; i <= 6; i++ {
mockURRId := strconv.Itoa(i)
mockURR, found := s.App.RecordsKeeper.GetUserRedemptionRecord(s.Ctx, mockURRId)
s.Require().True(found)
// verify the amount was not updated
s.Require().Equal(sdk.NewInt(0), mockURR.StTokenAmount, "URR %s - st token amount", mockURRId)
}
}
}

func (s *UpgradeTestSuite) SetupRateLimitsBeforeUpgrade() func() {
gaiaChannelId := "channel-0"

Expand Down
4 changes: 4 additions & 0 deletions proto/stride/records/records.proto
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ message UserRedemptionRecord {
uint64 epoch_number = 7;
bool claim_is_pending = 8;
reserved 2;
string st_token_amount = 9 [
(gogoproto.customtype) = "github.com/cosmos/cosmos-sdk/types.Int",
(gogoproto.nullable) = false
];
}

message DepositRecord {
Expand Down
6 changes: 4 additions & 2 deletions x/records/client/cli/query_user_redemption_record_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/Stride-Labs/stride/v16/x/records/types"
)

// TODO [cleanup] - Migrate to new CLI testing framework
func networkWithUserRedemptionRecordObjects(t *testing.T, n int) (*network.Network, []types.UserRedemptionRecord) {
t.Helper()
cfg := network.DefaultConfig()
Expand All @@ -28,8 +29,9 @@ func networkWithUserRedemptionRecordObjects(t *testing.T, n int) (*network.Netwo

for i := 0; i < n; i++ {
userRedemptionRecord := types.UserRedemptionRecord{
Id: strconv.Itoa(i),
Amount: sdkmath.NewInt(int64(i)),
Id: strconv.Itoa(i),
Amount: sdkmath.NewInt(int64(i)),
StTokenAmount: sdkmath.NewInt(int64(i)),
}
nullify.Fill(&userRedemptionRecord)
state.UserRedemptionRecordList = append(state.UserRedemptionRecordList, userRedemptionRecord)
Expand Down
12 changes: 12 additions & 0 deletions x/records/keeper/epoch_unbonding_record.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ func (k Keeper) GetHostZoneUnbondingByChainId(ctx sdk.Context, epochNumber uint6
}

// Adds a HostZoneUnbonding to an EpochUnbondingRecord
// TODO [cleanup]: Return error instead of success
func (k Keeper) AddHostZoneToEpochUnbondingRecord(ctx sdk.Context, epochNumber uint64, chainId string, hzu *types.HostZoneUnbonding) (val *types.EpochUnbondingRecord, success bool) {
epochUnbondingRecord, found := k.GetEpochUnbondingRecord(ctx, epochNumber)
if !found {
Expand All @@ -121,7 +122,18 @@ func (k Keeper) AddHostZoneToEpochUnbondingRecord(ctx sdk.Context, epochNumber u
return &epochUnbondingRecord, true
}

// Stores a host zone unbonding record - set via an epoch unbonding record
func (k Keeper) SetHostZoneUnbondingRecord(ctx sdk.Context, epochNumber uint64, chainId string, hostZoneUnbonding types.HostZoneUnbonding) error {
epochUnbondingRecord, success := k.AddHostZoneToEpochUnbondingRecord(ctx, epochNumber, chainId, &hostZoneUnbonding)
if !success {
return errorsmod.Wrapf(types.ErrEpochUnbondingRecordNotFound, "epoch unbonding record not found for epoch %d", epochNumber)
}
k.SetEpochUnbondingRecord(ctx, *epochUnbondingRecord)
return nil
}

// Updates the status for a given host zone across relevant epoch unbonding record IDs
// TODO [cleanup]: Rename to SetHostZoneUnbondingStatus
func (k Keeper) SetHostZoneUnbondings(ctx sdk.Context, chainId string, epochUnbondingRecordIds []uint64, status types.HostZoneUnbonding_Status) error {
for _, epochUnbondingRecordId := range epochUnbondingRecordIds {
k.Logger(ctx).Info(fmt.Sprintf("Updating host zone unbondings on EpochUnbondingRecord %d to status %s", epochUnbondingRecordId, status.String()))
Expand Down
58 changes: 58 additions & 0 deletions x/records/keeper/epoch_unbonding_record_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,3 +170,61 @@ func TestSetHostZoneUnbondings(t *testing.T) {
actualEpochUnbondingRecord,
)
}

func (s *KeeperTestSuite) TestSetHostZoneUnbonding() {
initialAmount := sdkmath.NewInt(10)
updatedAmount := sdkmath.NewInt(99)

// Create two epoch unbonding records, each with two host zone unbondings
epochUnbondingRecords := []types.EpochUnbondingRecord{
{
EpochNumber: 1,
HostZoneUnbondings: []*types.HostZoneUnbonding{
{HostZoneId: "chain-0", NativeTokenAmount: initialAmount},
{HostZoneId: "chain-1", NativeTokenAmount: initialAmount},
},
},
{
EpochNumber: 2,
HostZoneUnbondings: []*types.HostZoneUnbonding{
{HostZoneId: "chain-0", NativeTokenAmount: initialAmount},
{HostZoneId: "chain-1", NativeTokenAmount: initialAmount},
},
},
}
for _, epochUnbondingRecord := range epochUnbondingRecords {
s.App.RecordsKeeper.SetEpochUnbondingRecord(s.Ctx, epochUnbondingRecord)
}

// Update the amount for (epoch-1, chain-0) and (epoch-2, chain-1)
updatedHostZoneUnbonding1 := types.HostZoneUnbonding{HostZoneId: "chain-0", NativeTokenAmount: updatedAmount}
err := s.App.RecordsKeeper.SetHostZoneUnbondingRecord(s.Ctx, 1, "chain-0", updatedHostZoneUnbonding1)
s.Require().NoError(err, "no error expected when setting amount for (epoch-1, chain-0)")

updatedHostZoneUnbonding2 := types.HostZoneUnbonding{HostZoneId: "chain-1", NativeTokenAmount: updatedAmount}
err = s.App.RecordsKeeper.SetHostZoneUnbondingRecord(s.Ctx, 2, "chain-1", updatedHostZoneUnbonding2)
s.Require().NoError(err, "no error expected when setting amount for (epoch-2, chain-1)")

// Create the mapping of expected native amounts
expectedAmountMapping := map[uint64]map[string]sdkmath.Int{
1: {
"chain-0": updatedAmount,
"chain-1": initialAmount,
},
2: {
"chain-0": initialAmount,
"chain-1": updatedAmount,
},
}

// Loop the records and check that the amounts match the updates
for _, epochUnbondingRecord := range s.App.RecordsKeeper.GetAllEpochUnbondingRecord(s.Ctx) {
s.Require().Len(epochUnbondingRecord.HostZoneUnbondings, 2, "there should be two host records per epoch record")

for _, hostZoneUnbondingRecord := range epochUnbondingRecord.HostZoneUnbondings {
expectedAmount := expectedAmountMapping[epochUnbondingRecord.EpochNumber][hostZoneUnbondingRecord.HostZoneId]
s.Require().Equal(expectedAmount.Int64(), hostZoneUnbondingRecord.NativeTokenAmount.Int64(), "updated record amount")
}
}

}
2 changes: 2 additions & 0 deletions x/records/keeper/user_redemption_record_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ import (
"github.com/Stride-Labs/stride/v16/x/records/types"
)

// TODO [cleanup]: Migrate to new KeeperTestSuite framework and remove use of nullify
func createNUserRedemptionRecord(keeper *keeper.Keeper, ctx sdk.Context, n int) []types.UserRedemptionRecord {
items := make([]types.UserRedemptionRecord, n)
for i := range items {
items[i].Id = strconv.Itoa(i)
items[i].Amount = sdkmath.NewInt(int64(i))
items[i].StTokenAmount = sdkmath.NewInt(int64(i))
keeper.SetUserRedemptionRecord(ctx, items[i])
}
return items
Expand Down
1 change: 1 addition & 0 deletions x/records/types/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ var (
ErrUnknownDepositRecord = errorsmod.Register(ModuleName, 1504, "unknown deposit record")
ErrUnmarshalFailure = errorsmod.Register(ModuleName, 1505, "cannot unmarshal")
ErrAddingHostZone = errorsmod.Register(ModuleName, 1506, "could not add hzu to epoch unbonding record")
ErrHostUnbondingRecordNotFound = errorsmod.Register(ModuleName, 1507, "host zone unbonding record not found on epoch unbonding record")
)
10 changes: 10 additions & 0 deletions x/records/types/records.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package types

import sdkmath "cosmossdk.io/math"

// Helper function to evaluate if a host zone unbonding record still needs to be initiated
func (r HostZoneUnbonding) ShouldInitiateUnbonding() bool {
notYetUnbonding := r.Status == HostZoneUnbonding_UNBONDING_QUEUE
hasAtLeastOneRecord := r.NativeTokenAmount.GT(sdkmath.ZeroInt())
return notYetUnbonding && hasAtLeastOneRecord
}
Loading

0 comments on commit 7f88955

Please sign in to comment.