From 1fd5a8055efd6168fa209ea569f427c1ce4a9b9a Mon Sep 17 00:00:00 2001 From: Joe Bowman Date: Mon, 10 Jul 2023 21:37:31 +0100 Subject: [PATCH 1/8] fix: refactor redelegations distribution logic --- x/interchainstaking/keeper/delegation.go | 18 +- x/interchainstaking/keeper/keeper.go | 4 +- x/interchainstaking/keeper/redemptions.go | 2 +- x/interchainstaking/types/rebalance.go | 259 +++++++------ x/interchainstaking/types/rebalance_test.go | 396 ++++++++++++++++++++ 5 files changed, 560 insertions(+), 119 deletions(-) create mode 100644 x/interchainstaking/types/rebalance_test.go diff --git a/x/interchainstaking/keeper/delegation.go b/x/interchainstaking/keeper/delegation.go index 7a5d6c4f9..30c94a8c4 100644 --- a/x/interchainstaking/keeper/delegation.go +++ b/x/interchainstaking/keeper/delegation.go @@ -202,7 +202,7 @@ func (k *Keeper) PrepareDelegationMessagesForShares(zone *types.Zone, coins sdk. } func (k *Keeper) DeterminePlanForDelegation(ctx sdk.Context, zone *types.Zone, amount sdk.Coins) (map[string]sdkmath.Int, error) { - currentAllocations, currentSum, _ := k.GetDelegationMap(ctx, zone) + currentAllocations, currentSum, _, _ := k.GetDelegationMap(ctx, zone) targetAllocations, err := k.GetAggregateIntentOrDefault(ctx, zone) if err != nil { return nil, err @@ -248,25 +248,23 @@ func (k *Keeper) WithdrawDelegationRewardsForResponse(ctx sdk.Context, zone *typ return k.SubmitTx(ctx, msgs, zone.DelegationAddress, "", zone.MessagesPerTx) } -func (k *Keeper) GetDelegationMap(ctx sdk.Context, zone *types.Zone) (out map[string]sdkmath.Int, sum sdkmath.Int, locked map[string]bool) { +func (k *Keeper) GetDelegationMap(ctx sdk.Context, zone *types.Zone) (out map[string]sdkmath.Int, sum sdkmath.Int, locked map[string]bool, lockedSum sdkmath.Int) { out = make(map[string]sdkmath.Int) locked = make(map[string]bool) sum = sdk.ZeroInt() + lockedSum = sdk.ZeroInt() k.IterateAllDelegations(ctx, zone, func(delegation types.Delegation) bool { - existing, found := out[delegation.ValidatorAddress] - if !found { - out[delegation.ValidatorAddress] = delegation.Amount.Amount - locked[delegation.ValidatorAddress] = delegation.RedelegationEnd != 0 && delegation.RedelegationEnd >= ctx.BlockTime().Unix() - } else { - out[delegation.ValidatorAddress] = existing.Add(delegation.Amount.Amount) - locked[delegation.ValidatorAddress] = locked[delegation.ValidatorAddress] || (delegation.RedelegationEnd != 0 && delegation.RedelegationEnd >= ctx.BlockTime().Unix()) + out[delegation.ValidatorAddress] = delegation.Amount.Amount + if delegation.RedelegationEnd >= ctx.BlockTime().Unix() { + locked[delegation.ValidatorAddress] = true + lockedSum = lockedSum.Add(delegation.Amount.Amount) } sum = sum.Add(delegation.Amount.Amount) return false }) - return out, sum, locked + return } func (k *Keeper) MakePerformanceDelegation(ctx sdk.Context, zone *types.Zone, validator string) error { diff --git a/x/interchainstaking/keeper/keeper.go b/x/interchainstaking/keeper/keeper.go index c1f6bb5d3..45bec826e 100644 --- a/x/interchainstaking/keeper/keeper.go +++ b/x/interchainstaking/keeper/keeper.go @@ -630,12 +630,12 @@ func (k *Keeper) GetAggregateIntentOrDefault(ctx sdk.Context, z *types.Zone) (ty } func (k *Keeper) Rebalance(ctx sdk.Context, zone *types.Zone, epochNumber int64) error { - currentAllocations, currentSum, currentLocked := k.GetDelegationMap(ctx, zone) + currentAllocations, currentSum, currentLocked, lockedSum := k.GetDelegationMap(ctx, zone) targetAllocations, err := k.GetAggregateIntentOrDefault(ctx, zone) if err != nil { return err } - rebalances := types.DetermineAllocationsForRebalancing(currentAllocations, currentLocked, currentSum, targetAllocations, k.ZoneRedelegationRecords(ctx, zone.ChainId), k.Logger(ctx)) + rebalances := types.DetermineAllocationsForRebalancing(currentAllocations, currentLocked, currentSum, lockedSum, targetAllocations, k.Logger(ctx)) msgs := make([]sdk.Msg, 0) for _, rebalance := range rebalances { msgs = append(msgs, &stakingtypes.MsgBeginRedelegate{DelegatorAddress: zone.DelegationAddress.Address, ValidatorSrcAddress: rebalance.Source, ValidatorDstAddress: rebalance.Target, Amount: sdk.NewCoin(zone.BaseDenom, rebalance.Amount)}) diff --git a/x/interchainstaking/keeper/redemptions.go b/x/interchainstaking/keeper/redemptions.go index 6654c1441..2ad35bb29 100644 --- a/x/interchainstaking/keeper/redemptions.go +++ b/x/interchainstaking/keeper/redemptions.go @@ -272,7 +272,7 @@ func (k *Keeper) GCCompletedUnbondings(ctx sdk.Context, zone *types.Zone) error } func (k *Keeper) DeterminePlanForUndelegation(ctx sdk.Context, zone *types.Zone, amount sdk.Coins) (map[string]math.Int, error) { - currentAllocations, currentSum, _ := k.GetDelegationMap(ctx, zone) + currentAllocations, currentSum, _, _ := k.GetDelegationMap(ctx, zone) availablePerValidator, _, err := k.GetUnlockedTokensForZone(ctx, zone) if err != nil { return nil, err diff --git a/x/interchainstaking/types/rebalance.go b/x/interchainstaking/types/rebalance.go index 7369b5cc5..28eab36a6 100644 --- a/x/interchainstaking/types/rebalance.go +++ b/x/interchainstaking/types/rebalance.go @@ -57,140 +57,187 @@ func CalculateDeltas(currentAllocations map[string]sdkmath.Int, currentSum sdkma return deltas } -type RebalanceTarget struct { - Amount sdkmath.Int - Source string - Target string -} +// CalculateDeltas determines, for the current delegations, in delta between actual allocations and the target intent. +// Positive delta represents current allocation is below target, and vice versa. +func CalculateDeltasNew(currentAllocations map[string]sdkmath.Int, locked map[string]bool, currentSum sdkmath.Int, targetAllocations ValidatorIntents) (targets, sources Deltas) { + targets = make(Deltas, 0) + sources = make(Deltas, 0) -func DetermineAllocationsForRebalancing( - currentAllocations map[string]sdkmath.Int, - currentLocked map[string]bool, - currentSum sdkmath.Int, - targetAllocations ValidatorIntents, - existingRedelegations []RedelegationRecord, - logger log.Logger, -) []RebalanceTarget { - out := make([]RebalanceTarget, 0) - deltas := CalculateDeltas(currentAllocations, currentSum, targetAllocations) - - wantToRebalance := sdk.ZeroInt() - canRebalanceFrom := sdk.ZeroInt() - - totalLocked := int64(0) - lockedPerValidator := map[string]int64{} - for _, redelegation := range existingRedelegations { - totalLocked += redelegation.Amount - thisLocked, found := lockedPerValidator[redelegation.Destination] - if !found { - thisLocked = 0 + targetValopers := func(in ValidatorIntents) []string { + out := make([]string, 0, len(in)) + for _, i := range in { + out = append(out, i.ValoperAddress) } - lockedPerValidator[redelegation.Destination] = thisLocked + redelegation.Amount - } - for _, valoper := range utils.Keys(currentAllocations) { - // if validator already has a redelegation _to_ it, we can no longer redelegate _from_ it (transitive redelegations) - // remove _locked_ amount from lpv and total locked for purposes of rebalancing. - if currentLocked[valoper] { - thisLocked, found := lockedPerValidator[valoper] - if !found { - thisLocked = 0 - } - totalLocked = totalLocked - thisLocked + currentAllocations[valoper].Int64() - lockedPerValidator[valoper] = currentAllocations[valoper].Int64() + return out + }(targetAllocations) + + keySet := utils.Unique(append(targetValopers, utils.Keys(currentAllocations)...)) + sort.Strings(keySet) + // for target allocations, raise the intent weight by the total delegated value to get target amount + for _, valoper := range keySet { + current, ok := currentAllocations[valoper] + if !ok { + current = sdk.ZeroInt() } - } - // TODO: make these params - maxCanRebalanceTotal := currentSum.Sub(sdkmath.NewInt(totalLocked)).Quo(sdk.NewInt(2)) - maxCanRebalance := sdkmath.MinInt(maxCanRebalanceTotal, currentSum.Quo(sdk.NewInt(7))) - if logger != nil { - logger.Debug("Rebalancing", "totalLocked", totalLocked, "lockedPerValidator", lockedPerValidator, "canRebalanceTotal", maxCanRebalanceTotal, "canRebalanceEpoch", maxCanRebalance) - } + target, ok := targetAllocations.GetForValoper(valoper) + if !ok { + target = &ValidatorIntent{ValoperAddress: valoper, Weight: sdk.ZeroDec()} + } + targetAmount := target.Weight.MulInt(currentSum).TruncateInt() + // diff between target and current allocations + // positive == below target, negative == above target + delta := targetAmount.Sub(current) - // deltas are sorted in CalculateDeltas; don't re-sort. - for _, delta := range deltas { - switch { - case delta.Weight.IsZero(): - // do nothing - case delta.Weight.IsPositive(): - // if delta > current value - locked value, truncate, as we cannot rebalance locked tokens. - wantToRebalance = wantToRebalance.Add(delta.Weight.TruncateInt()) - case delta.Weight.IsNegative(): - if delta.Weight.Abs().GT(sdk.NewDecFromInt(currentAllocations[delta.ValoperAddress].Sub(sdkmath.NewInt(lockedPerValidator[delta.ValoperAddress])))) { - delta.Weight = sdk.NewDecFromInt(currentAllocations[delta.ValoperAddress].Sub(sdkmath.NewInt(lockedPerValidator[delta.ValoperAddress]))).Neg() - if logger != nil { - logger.Debug("Truncated delta due to locked tokens", "valoper", delta.ValoperAddress, "delta", delta.Weight.Abs()) - } + if delta.IsPositive() { + targets = append(targets, &Delta{Amount: delta, ValoperAddress: valoper}) + } else { + if _, found := locked[valoper]; !found { + // only append to sources if the delegation is not locked - i.e. it doesn't have an incoming redelegation. + sources = append(sources, &Delta{Amount: delta.Abs(), ValoperAddress: valoper}) } - canRebalanceFrom = canRebalanceFrom.Add(delta.Weight.Abs().TruncateInt()) } } - toRebalance := sdk.MinInt(sdk.MinInt(wantToRebalance, canRebalanceFrom), maxCanRebalance) + targets.Sort() + sources.Sort() - if toRebalance.Equal(sdkmath.ZeroInt()) { - if logger != nil { - logger.Debug("No rebalancing this epoch") + return +} + +type Delta struct { + ValoperAddress string + Amount sdkmath.Int +} + +type Deltas []*Delta + +func (d Deltas) Sort() { + + // filter zeros + new := make(Deltas, 0) + for _, delta := range d { + if !delta.Amount.IsZero() { + new = append(new, delta) } - return []RebalanceTarget{} - } - if logger != nil { - logger.Debug("Will rebalance this epoch", "amount", toRebalance) } + d = new - tgtIdx := 0 - srcIdx := len(deltas) - 1 - for i := 0; toRebalance.GT(sdk.ZeroInt()); { - i++ - if i > 20 { - break - } - src := deltas[srcIdx] - tgt := deltas[tgtIdx] - if src.ValoperAddress == tgt.ValoperAddress { - break - } - var amount sdkmath.Int - if src.Weight.Abs().TruncateInt().IsZero() { //nolint:gocritic - srcIdx-- - continue - } else if src.Weight.Abs().TruncateInt().GT(toRebalance) { // amount == rebalance - amount = toRebalance - } else { - amount = src.Weight.Abs().TruncateInt() - } + // sort keys by relative value of delta + sort.SliceStable(d, func(i, j int) bool { + // < sorts alphabetically. + return d[i].ValoperAddress < d[j].ValoperAddress + }) - if tgt.Weight.Abs().TruncateInt().IsZero() { - tgtIdx++ - continue - } else if tgt.Weight.Abs().TruncateInt().LTE(toRebalance) { - amount = sdk.MinInt(amount, tgt.Weight.Abs().TruncateInt()) - } + // sort keys by relative value of delta + sort.SliceStable(d, func(i, j int) bool { + return d[i].Amount.GT(d[j].Amount) + }) +} - out = append(out, RebalanceTarget{Amount: amount, Target: tgt.ValoperAddress, Source: src.ValoperAddress}) - deltas[srcIdx].Weight = src.Weight.Add(sdk.NewDecFromInt(amount)) - deltas[tgtIdx].Weight = tgt.Weight.Sub(sdk.NewDecFromInt(amount)) - toRebalance = toRebalance.Sub(amount) +type RebalanceTarget struct { + Amount sdkmath.Int + Source string + Target string +} - } +type RebalanceTargets []*RebalanceTarget +func (t RebalanceTargets) Sort() { // sort keys by relative value of delta - sort.SliceStable(out, func(i, j int) bool { - return out[i].Source < out[j].Source + sort.SliceStable(t, func(i, j int) bool { + // < sorts alphabetically. + return t[i].Source < t[j].Source }) - sort.SliceStable(out, func(i, j int) bool { - return out[i].Target < out[j].Target + // sort keys by relative value of delta + sort.SliceStable(t, func(i, j int) bool { + // < sorts alphabetically. + return t[i].Target < t[j].Target }) // sort keys by relative value of delta - sort.SliceStable(out, func(i, j int) bool { - return out[i].Amount.GT(out[j].Amount) + sort.SliceStable(t, func(i, j int) bool { + return t[i].Amount.LT(t[j].Amount) }) +} + +// DetermineAllocationsForRebalancing takes +func DetermineAllocationsForRebalancing( + currentAllocations map[string]sdkmath.Int, + currentLocked map[string]bool, + currentSum sdkmath.Int, + lockedSum sdkmath.Int, + targetAllocations ValidatorIntents, + logger log.Logger, +) RebalanceTargets { + out := make(RebalanceTargets, 0) + targets, sources := CalculateDeltasNew(currentAllocations, currentLocked, currentSum, targetAllocations) + + // rebalanceBudget = (total_delegations - locked)/2 == 50% of (total_delegations - locked) + // TODO: make this 2 (max_redelegation_factor) a param. + rebalanceBudget := currentSum.Sub(lockedSum).Quo(sdk.NewInt(2)) + + if logger != nil { + logger.Debug("Rebalancing", "total", currentSum, "totalLocked", lockedSum, "rebalanceBudget", rebalanceBudget) + } + +TARGET: + // targets are validators with a delegation deficit, sorted in descending order. + // that is, those at the top should be satisfied first to maximise progress toward goal. + for _, target := range targets { + // amount is amount we should try to redelegate toward target. This may be constrained by the remaining redelegateBudget. + // if it is zero (i.e. we hit the redelegation budget) break out of the loop. + amount := sdkmath.MinInt(target.Amount, rebalanceBudget) + if amount.IsZero() { + break + } + sources.Sort() + // sources are validators with available balance to redelegate, sorted in desc order. + for _, source := range sources { + switch { + case source.Amount.IsZero(): + // if source is zero, skip. + continue + case source.Amount.GTE(amount): + // if source >= amount, fully satisfy target. + out = append(out, &RebalanceTarget{Amount: amount, Target: target.ValoperAddress, Source: source.ValoperAddress}) + source.Amount = source.Amount.Sub(amount) + target.Amount = target.Amount.Sub(amount) + rebalanceBudget = rebalanceBudget.Sub(amount) + continue TARGET + case source.Amount.LT(amount): + // if source < amount, partially satisfy amount. + out = append(out, &RebalanceTarget{Amount: source.Amount, Target: target.ValoperAddress, Source: source.ValoperAddress}) + amount = amount.Sub(source.Amount) + target.Amount = target.Amount.Sub(source.Amount) + rebalanceBudget = rebalanceBudget.Sub(source.Amount) + source.Amount = source.Amount.Sub(source.Amount) + if amount.IsZero() || rebalanceBudget.IsZero() { + // if the amount is fully satisfied or the rebalanceBudget is zero, skip to next target. + continue TARGET + } + // otherwise, try next source. + } + } + // we only get here if we are unable to satisfy targets due to rebalanceBudget depletion. + if logger != nil { + logger.Info("unable to satisfy targets with available sources.") + } + } + + out.Sort() return out } +// func (d Deltas) Render() (out string) { +// for _, delta := range d { +// out = out + fmt.Sprintf("%s:\t%d\n", delta.ValoperAddress, delta.Amount.Int64()) +// } +// return +// } + // MinDeltas returns the lowest value in a slice of Deltas. func MinDeltas(deltas ValidatorIntents) sdkmath.Int { minValue := sdk.NewInt(math.MaxInt64) diff --git a/x/interchainstaking/types/rebalance_test.go b/x/interchainstaking/types/rebalance_test.go new file mode 100644 index 000000000..36c644641 --- /dev/null +++ b/x/interchainstaking/types/rebalance_test.go @@ -0,0 +1,396 @@ +package types_test + +import ( + "sort" + "testing" + + "cosmossdk.io/math" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/ingenuity-build/quicksilver/utils/addressutils" + "github.com/ingenuity-build/quicksilver/x/interchainstaking/types" + "github.com/stretchr/testify/require" +) + +func GenerateDeterministicValidators(n int) (out []string) { + out = make([]string, 0, n) + for i := 0; i < n; i++ { + out = append(out, addressutils.GenerateAddressForTestWithPrefix("cosmosvaloper")) + } + sort.Strings(out) + return out +} + +func TestDetermineAllocationsForRebalancing(t *testing.T) { + vals := GenerateDeterministicValidators(5) + + type testcase struct { + name string + allocations map[string]math.Int + target types.ValidatorIntents + locked map[string]bool + expected types.RebalanceTargets + } + + tcs := []testcase{ + { + name: "100% No Existing Redelegations", + allocations: map[string]math.Int{ + vals[0]: math.NewInt(10), + vals[1]: math.NewInt(10), + vals[2]: math.NewInt(10), + vals[3]: math.NewInt(10), + vals[4]: math.NewInt(10), + }, + target: types.ValidatorIntents{ + &types.ValidatorIntent{ + ValoperAddress: vals[0], + Weight: sdk.NewDec(1), + }, + }, + locked: map[string]bool{}, + expected: types.RebalanceTargets{ + { + Source: vals[1], + Target: vals[0], + Amount: math.NewInt(10), + }, + { + Source: vals[2], + Target: vals[0], + Amount: math.NewInt(10), + }, + { + Source: vals[3], + Target: vals[0], + Amount: math.NewInt(5), + }, + }, + }, + { + name: "50/50 No Existing Redelegations, Constrained by total", + allocations: map[string]math.Int{ + vals[0]: math.NewInt(10), + vals[1]: math.NewInt(10), + vals[2]: math.NewInt(10), + vals[3]: math.NewInt(10), + vals[4]: math.NewInt(10), + }, + target: types.ValidatorIntents{ + &types.ValidatorIntent{ + ValoperAddress: vals[0], + Weight: sdk.NewDecWithPrec(5, 1), + }, + &types.ValidatorIntent{ + ValoperAddress: vals[1], + Weight: sdk.NewDecWithPrec(5, 1), + }, + }, + locked: map[string]bool{}, + expected: types.RebalanceTargets{ + { + Source: vals[2], + Target: vals[0], + Amount: math.NewInt(10), + }, + { + Source: vals[3], + Target: vals[0], + Amount: math.NewInt(5), + }, + { + Source: vals[3], + Target: vals[1], + Amount: math.NewInt(5), + }, + { + Source: vals[4], + Target: vals[1], + Amount: math.NewInt(5), + }, + }, + }, + { + name: "50/50 No Existing Redelegations, Unconstrained", + allocations: map[string]math.Int{ + vals[0]: math.NewInt(10), + vals[1]: math.NewInt(10), + vals[2]: math.NewInt(10), + vals[3]: math.NewInt(10), + }, + target: types.ValidatorIntents{ + &types.ValidatorIntent{ + ValoperAddress: vals[0], + Weight: sdk.NewDecWithPrec(5, 1), + }, + &types.ValidatorIntent{ + ValoperAddress: vals[1], + Weight: sdk.NewDecWithPrec(5, 1), + }, + }, + locked: map[string]bool{}, + expected: []*types.RebalanceTarget{ + { + Source: vals[2], + Target: vals[0], + Amount: math.NewInt(10), + }, + { + Source: vals[3], + Target: vals[1], + Amount: math.NewInt(10), + }, + }, + }, + { + name: "Drop one validator, No Existing Redelegations", + allocations: map[string]math.Int{ + vals[0]: math.NewInt(8), + vals[1]: math.NewInt(8), + vals[2]: math.NewInt(8), + vals[3]: math.NewInt(8), + vals[4]: math.NewInt(8), + }, + target: types.ValidatorIntents{ + &types.ValidatorIntent{ + ValoperAddress: vals[0], + Weight: sdk.NewDecWithPrec(25, 2), + }, + &types.ValidatorIntent{ + ValoperAddress: vals[1], + Weight: sdk.NewDecWithPrec(25, 2), + }, + &types.ValidatorIntent{ + ValoperAddress: vals[2], + Weight: sdk.NewDecWithPrec(25, 2), + }, + &types.ValidatorIntent{ + ValoperAddress: vals[3], + Weight: sdk.NewDecWithPrec(25, 2), + }, + }, + locked: map[string]bool{}, + expected: types.RebalanceTargets{ + { + Source: vals[4], + Target: vals[0], + Amount: math.NewInt(2), + }, + { + Source: vals[4], + Target: vals[1], + Amount: math.NewInt(2), + }, + { + Source: vals[4], + Target: vals[2], + Amount: math.NewInt(2), + }, + { + Source: vals[4], + Target: vals[3], + Amount: math.NewInt(2), + }, + }, + }, + { + name: "Add one validator, No Existing Redelegations", + allocations: map[string]math.Int{ + vals[0]: math.NewInt(10), + vals[1]: math.NewInt(10), + vals[2]: math.NewInt(10), + vals[3]: math.NewInt(10), + }, + target: types.ValidatorIntents{ + &types.ValidatorIntent{ + ValoperAddress: vals[0], + Weight: sdk.NewDecWithPrec(20, 2), + }, + &types.ValidatorIntent{ + ValoperAddress: vals[1], + Weight: sdk.NewDecWithPrec(20, 2), + }, + &types.ValidatorIntent{ + ValoperAddress: vals[2], + Weight: sdk.NewDecWithPrec(20, 2), + }, + &types.ValidatorIntent{ + ValoperAddress: vals[3], + Weight: sdk.NewDecWithPrec(20, 2), + }, + &types.ValidatorIntent{ + ValoperAddress: vals[4], + Weight: sdk.NewDecWithPrec(20, 2), + }, + }, + locked: map[string]bool{}, + expected: types.RebalanceTargets{ + { + Source: vals[0], + Target: vals[4], + Amount: math.NewInt(2), + }, + { + Source: vals[1], + Target: vals[4], + Amount: math.NewInt(2), + }, + { + Source: vals[2], + Target: vals[4], + Amount: math.NewInt(2), + }, + { + Source: vals[3], + Target: vals[4], + Amount: math.NewInt(2), + }, + }, + }, + { + name: "Attempt redelegate away from locked validator; no-op", + allocations: map[string]math.Int{ + vals[0]: math.NewInt(10), + vals[1]: math.NewInt(10), + vals[2]: math.NewInt(10), + vals[3]: math.NewInt(10), + vals[4]: math.NewInt(10), + }, + target: types.ValidatorIntents{ + &types.ValidatorIntent{ + ValoperAddress: vals[0], + Weight: sdk.NewDecWithPrec(10, 2), + }, + &types.ValidatorIntent{ + ValoperAddress: vals[1], + Weight: sdk.NewDecWithPrec(225, 3), + }, + &types.ValidatorIntent{ + ValoperAddress: vals[2], + Weight: sdk.NewDecWithPrec(225, 3), + }, + &types.ValidatorIntent{ + ValoperAddress: vals[3], + Weight: sdk.NewDecWithPrec(225, 3), + }, + &types.ValidatorIntent{ + ValoperAddress: vals[4], + Weight: sdk.NewDecWithPrec(225, 3), + }, + }, + locked: map[string]bool{ + vals[0]: true, + }, + expected: types.RebalanceTargets{}, + }, + { + name: "Delegate away from 2; 1 locked validator; v1 -15; v2 + 10; v3 +5", + allocations: map[string]math.Int{ + vals[0]: math.NewInt(20), + vals[1]: math.NewInt(20), + vals[2]: math.NewInt(20), + vals[3]: math.NewInt(20), + vals[4]: math.NewInt(20), + }, + target: types.ValidatorIntents{ + &types.ValidatorIntent{ + ValoperAddress: vals[0], + Weight: sdk.NewDecWithPrec(5, 2), + }, + &types.ValidatorIntent{ + ValoperAddress: vals[1], + Weight: sdk.NewDecWithPrec(5, 2), + }, + &types.ValidatorIntent{ + ValoperAddress: vals[2], + Weight: sdk.NewDecWithPrec(30, 2), + }, + &types.ValidatorIntent{ + ValoperAddress: vals[3], + Weight: sdk.NewDecWithPrec(30, 2), + }, + &types.ValidatorIntent{ + ValoperAddress: vals[4], + Weight: sdk.NewDecWithPrec(30, 2), + }, + }, + locked: map[string]bool{ + vals[0]: true, + }, + expected: types.RebalanceTargets{ + { + Source: vals[1], + Target: vals[2], + Amount: math.NewInt(10), + }, + { + Source: vals[1], + Target: vals[3], + Amount: math.NewInt(5), + }, + }, + }, + { + name: "v0 missing, v1 zero; one new vals. Should delegate v0: -50; v1: -25; v2: +25; v3: +50", + allocations: map[string]math.Int{ + vals[0]: math.NewInt(50), + vals[1]: math.NewInt(50), + vals[2]: math.NewInt(50), + }, + target: types.ValidatorIntents{ + &types.ValidatorIntent{ + ValoperAddress: vals[1], + Weight: sdk.ZeroDec(), + }, + &types.ValidatorIntent{ + ValoperAddress: vals[2], + Weight: sdk.NewDecWithPrec(5, 1), + }, + &types.ValidatorIntent{ + ValoperAddress: vals[3], + Weight: sdk.NewDecWithPrec(5, 1), + }, + }, + locked: map[string]bool{}, + expected: types.RebalanceTargets{ + { + Source: vals[0], + Target: vals[2], + Amount: math.NewInt(25), + }, + { + Source: vals[0], + Target: vals[3], + Amount: math.NewInt(25), + }, + { + Source: vals[1], + Target: vals[3], + Amount: math.NewInt(25), + }, + }, + }, + } + + for _, tt := range tcs { + t.Run(tt.name, func(t *testing.T) { + + currentSum, lockedSum := func(in map[string]math.Int, locked map[string]bool) (sum, lockedsum math.Int) { + sum = math.ZeroInt() + lockedsum = math.ZeroInt() + for k, v := range in { + sum = sum.Add(v) + if locked[k] { + lockedsum = lockedsum.Add(v) + } + } + return + }(tt.allocations, tt.locked) + + actual := types.DetermineAllocationsForRebalancing( + tt.allocations, tt.locked, currentSum, lockedSum, tt.target, nil, + ) + + require.ElementsMatch(t, tt.expected, actual) + }) + } +} From e67fe07e493d7ce3bd65ee709dc07b42c2f1b3d2 Mon Sep 17 00:00:00 2001 From: Joe Bowman Date: Mon, 10 Jul 2023 21:51:08 +0100 Subject: [PATCH 2/8] lint --- x/interchainstaking/keeper/delegation.go | 2 +- x/interchainstaking/types/rebalance.go | 12 ++++++------ x/interchainstaking/types/rebalance_test.go | 9 ++++----- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/x/interchainstaking/keeper/delegation.go b/x/interchainstaking/keeper/delegation.go index 30c94a8c4..f61c020db 100644 --- a/x/interchainstaking/keeper/delegation.go +++ b/x/interchainstaking/keeper/delegation.go @@ -264,7 +264,7 @@ func (k *Keeper) GetDelegationMap(ctx sdk.Context, zone *types.Zone) (out map[st return false }) - return + return out, sum, locked, lockedSum } func (k *Keeper) MakePerformanceDelegation(ctx sdk.Context, zone *types.Zone, validator string) error { diff --git a/x/interchainstaking/types/rebalance.go b/x/interchainstaking/types/rebalance.go index 28eab36a6..585c7d4d2 100644 --- a/x/interchainstaking/types/rebalance.go +++ b/x/interchainstaking/types/rebalance.go @@ -102,7 +102,7 @@ func CalculateDeltasNew(currentAllocations map[string]sdkmath.Int, locked map[st targets.Sort() sources.Sort() - return + return targets, sources } type Delta struct { @@ -113,15 +113,14 @@ type Delta struct { type Deltas []*Delta func (d Deltas) Sort() { - // filter zeros - new := make(Deltas, 0) + newDeltas := make(Deltas, 0) for _, delta := range d { if !delta.Amount.IsZero() { - new = append(new, delta) + newDeltas = append(newDeltas, delta) } } - d = new + d = newDeltas // sort keys by relative value of delta sort.SliceStable(d, func(i, j int) bool { @@ -162,7 +161,8 @@ func (t RebalanceTargets) Sort() { }) } -// DetermineAllocationsForRebalancing takes +// DetermineAllocationsForRebalancing takes maps of current and locked delegations, and based upon the target allocations, +// attempts to satisfy the target allocations in the fewest number of transformations. It returns a slice of RebalanceTargets. func DetermineAllocationsForRebalancing( currentAllocations map[string]sdkmath.Int, currentLocked map[string]bool, diff --git a/x/interchainstaking/types/rebalance_test.go b/x/interchainstaking/types/rebalance_test.go index 36c644641..f1dfcfa21 100644 --- a/x/interchainstaking/types/rebalance_test.go +++ b/x/interchainstaking/types/rebalance_test.go @@ -373,17 +373,16 @@ func TestDetermineAllocationsForRebalancing(t *testing.T) { for _, tt := range tcs { t.Run(tt.name, func(t *testing.T) { - - currentSum, lockedSum := func(in map[string]math.Int, locked map[string]bool) (sum, lockedsum math.Int) { + currentSum, lockedSum := func(in map[string]math.Int, locked map[string]bool) (sum, lockedSum math.Int) { sum = math.ZeroInt() - lockedsum = math.ZeroInt() + lockedSum = math.ZeroInt() for k, v := range in { sum = sum.Add(v) if locked[k] { - lockedsum = lockedsum.Add(v) + lockedSum = lockedSum.Add(v) } } - return + return sum, lockedSum }(tt.allocations, tt.locked) actual := types.DetermineAllocationsForRebalancing( From 39400bb3917812eea04b3c929cbf4ba01d0e3c9b Mon Sep 17 00:00:00 2001 From: Joe Bowman Date: Mon, 10 Jul 2023 23:00:08 +0100 Subject: [PATCH 3/8] Update x/interchainstaking/types/rebalance_test.go Co-authored-by: Alex Johnson --- x/interchainstaking/types/rebalance_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x/interchainstaking/types/rebalance_test.go b/x/interchainstaking/types/rebalance_test.go index f1dfcfa21..7b145610b 100644 --- a/x/interchainstaking/types/rebalance_test.go +++ b/x/interchainstaking/types/rebalance_test.go @@ -11,7 +11,7 @@ import ( "github.com/stretchr/testify/require" ) -func GenerateDeterministicValidators(n int) (out []string) { +func GenerateValidatorsDeterministic(n int) (out []string) { out = make([]string, 0, n) for i := 0; i < n; i++ { out = append(out, addressutils.GenerateAddressForTestWithPrefix("cosmosvaloper")) From 9a0d03cff3565b25f4a9e4ccec2b6b292d3bc123 Mon Sep 17 00:00:00 2001 From: Joe Bowman Date: Mon, 10 Jul 2023 23:10:14 +0100 Subject: [PATCH 4/8] nits --- x/interchainstaking/types/delegation.go | 2 +- x/interchainstaking/types/rebalance.go | 64 ++++++++++++--------- x/interchainstaking/types/rebalance_test.go | 2 +- x/interchainstaking/types/redemptions.go | 2 +- 4 files changed, 41 insertions(+), 29 deletions(-) diff --git a/x/interchainstaking/types/delegation.go b/x/interchainstaking/types/delegation.go index 31c6cb6cf..065682b34 100644 --- a/x/interchainstaking/types/delegation.go +++ b/x/interchainstaking/types/delegation.go @@ -120,7 +120,7 @@ func (vi ValidatorIntents) Normalize() ValidatorIntents { func DetermineAllocationsForDelegation(currentAllocations map[string]sdkmath.Int, currentSum sdkmath.Int, targetAllocations ValidatorIntents, amount sdk.Coins) map[string]sdkmath.Int { input := amount[0].Amount deltas := CalculateDeltas(currentAllocations, currentSum, targetAllocations) - minValue := MinDeltas(deltas) + minValue := MinDelta(deltas) sum := sdk.ZeroInt() // raise all deltas such that the minimum value is zero. diff --git a/x/interchainstaking/types/rebalance.go b/x/interchainstaking/types/rebalance.go index 585c7d4d2..3a6e6cf51 100644 --- a/x/interchainstaking/types/rebalance.go +++ b/x/interchainstaking/types/rebalance.go @@ -1,6 +1,7 @@ package types import ( + fmt "fmt" "math" "sort" @@ -57,12 +58,18 @@ func CalculateDeltas(currentAllocations map[string]sdkmath.Int, currentSum sdkma return deltas } -// CalculateDeltas determines, for the current delegations, in delta between actual allocations and the target intent. -// Positive delta represents current allocation is below target, and vice versa. -func CalculateDeltasNew(currentAllocations map[string]sdkmath.Int, locked map[string]bool, currentSum sdkmath.Int, targetAllocations ValidatorIntents) (targets, sources Deltas) { - targets = make(Deltas, 0) - sources = make(Deltas, 0) +// CalculateAllocationDeltas determines, for the current delegations, in delta between actual allocations and the target intent. +// Returns a slice of deltas for each of target allocations (underallocated) and source allocations (overallocated). +func CalculateAllocationDeltas( + currentAllocations map[string]sdkmath.Int, + locked map[string]bool, + currentSum sdkmath.Int, + targetAllocations ValidatorIntents, +) (targets, sources AllocationDeltas) { + targets = make(AllocationDeltas, 0) + sources = make(AllocationDeltas, 0) + // reduce ValidatorIntents to slice of Valoper addresses. targetValopers := func(in ValidatorIntents) []string { out := make([]string, 0, len(in)) for _, i := range in { @@ -71,9 +78,11 @@ func CalculateDeltasNew(currentAllocations map[string]sdkmath.Int, locked map[st return out }(targetAllocations) + // create a slide of unique valopers across current and target allocations. keySet := utils.Unique(append(targetValopers, utils.Keys(currentAllocations)...)) sort.Strings(keySet) - // for target allocations, raise the intent weight by the total delegated value to get target amount + + // for target allocations, raise the intent weight by the total delegated value to get target amount. for _, valoper := range keySet { current, ok := currentAllocations[valoper] if !ok { @@ -85,42 +94,44 @@ func CalculateDeltasNew(currentAllocations map[string]sdkmath.Int, locked map[st target = &ValidatorIntent{ValoperAddress: valoper, Weight: sdk.ZeroDec()} } targetAmount := target.Weight.MulInt(currentSum).TruncateInt() + // diff between target and current allocations - // positive == below target, negative == above target + // positive == below target (target), negative == above target (source) delta := targetAmount.Sub(current) if delta.IsPositive() { - targets = append(targets, &Delta{Amount: delta, ValoperAddress: valoper}) + targets = append(targets, &AllocationDelta{Amount: delta, ValoperAddress: valoper}) } else { if _, found := locked[valoper]; !found { // only append to sources if the delegation is not locked - i.e. it doesn't have an incoming redelegation. - sources = append(sources, &Delta{Amount: delta.Abs(), ValoperAddress: valoper}) + sources = append(sources, &AllocationDelta{Amount: delta.Abs(), ValoperAddress: valoper}) } } } + // sort for determinism. targets.Sort() sources.Sort() return targets, sources } -type Delta struct { +type AllocationDelta struct { ValoperAddress string Amount sdkmath.Int } -type Deltas []*Delta +type AllocationDeltas []*AllocationDelta -func (d Deltas) Sort() { +func (d AllocationDeltas) Sort() { // filter zeros - newDeltas := make(Deltas, 0) + newAllocationDeltas := make(AllocationDeltas, 0) for _, delta := range d { if !delta.Amount.IsZero() { - newDeltas = append(newDeltas, delta) + newAllocationDeltas = append(newAllocationDeltas, delta) } } - d = newDeltas + d = newAllocationDeltas // sort keys by relative value of delta sort.SliceStable(d, func(i, j int) bool { @@ -142,6 +153,7 @@ type RebalanceTarget struct { type RebalanceTargets []*RebalanceTarget +// Sort RebalanceTargets deterministically. func (t RebalanceTargets) Sort() { // sort keys by relative value of delta sort.SliceStable(t, func(i, j int) bool { @@ -172,7 +184,7 @@ func DetermineAllocationsForRebalancing( logger log.Logger, ) RebalanceTargets { out := make(RebalanceTargets, 0) - targets, sources := CalculateDeltasNew(currentAllocations, currentLocked, currentSum, targetAllocations) + targets, sources := CalculateAllocationDeltas(currentAllocations, currentLocked, currentSum, targetAllocations) // rebalanceBudget = (total_delegations - locked)/2 == 50% of (total_delegations - locked) // TODO: make this 2 (max_redelegation_factor) a param. @@ -231,15 +243,15 @@ TARGET: return out } -// func (d Deltas) Render() (out string) { -// for _, delta := range d { -// out = out + fmt.Sprintf("%s:\t%d\n", delta.ValoperAddress, delta.Amount.Int64()) -// } -// return -// } +func (d AllocationDeltas) String() (out string) { + for _, delta := range d { + out = fmt.Sprintf("%s%s:\t%d\n", out, delta.ValoperAddress, delta.Amount.Int64()) + } + return out +} -// MinDeltas returns the lowest value in a slice of Deltas. -func MinDeltas(deltas ValidatorIntents) sdkmath.Int { +// MinDelta returns the lowest value in a slice of Deltas. +func MinDelta(deltas ValidatorIntents) sdkmath.Int { minValue := sdk.NewInt(math.MaxInt64) for _, intent := range deltas { if minValue.GT(intent.Weight.TruncateInt()) { @@ -250,8 +262,8 @@ func MinDeltas(deltas ValidatorIntents) sdkmath.Int { return minValue } -// MaxDeltas returns the greatest value in a slice of Deltas. -func MaxDeltas(deltas ValidatorIntents) sdkmath.Int { +// MaxDelta returns the greatest value in a slice of Deltas. +func MaxDelta(deltas ValidatorIntents) sdkmath.Int { maxValue := sdk.NewInt(math.MinInt64) for _, intent := range deltas { if maxValue.LT(intent.Weight.TruncateInt()) { diff --git a/x/interchainstaking/types/rebalance_test.go b/x/interchainstaking/types/rebalance_test.go index 7b145610b..532fc209f 100644 --- a/x/interchainstaking/types/rebalance_test.go +++ b/x/interchainstaking/types/rebalance_test.go @@ -21,7 +21,7 @@ func GenerateValidatorsDeterministic(n int) (out []string) { } func TestDetermineAllocationsForRebalancing(t *testing.T) { - vals := GenerateDeterministicValidators(5) + vals := GenerateValidatorsDeterministic(5) type testcase struct { name string diff --git a/x/interchainstaking/types/redemptions.go b/x/interchainstaking/types/redemptions.go index 573ec5945..ae66c8a2c 100644 --- a/x/interchainstaking/types/redemptions.go +++ b/x/interchainstaking/types/redemptions.go @@ -49,7 +49,7 @@ func DetermineAllocationsForUndelegation(currentAllocations map[string]math.Int, return outWeights } - maxValue := MaxDeltas(deltas) + maxValue := MaxDelta(deltas) sum = sdk.ZeroInt() // drop all deltas such that the maximum value is zero, and invert. From 0596046b0e85b8fab8265295b6c50ddaf208cfd8 Mon Sep 17 00:00:00 2001 From: Ajaz Ahmed Ansari Date: Tue, 11 Jul 2023 19:54:04 +0530 Subject: [PATCH 5/8] Update x/interchainstaking/types/rebalance.go Co-authored-by: Alex Johnson --- x/interchainstaking/types/rebalance.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x/interchainstaking/types/rebalance.go b/x/interchainstaking/types/rebalance.go index 3a6e6cf51..fb7fde448 100644 --- a/x/interchainstaking/types/rebalance.go +++ b/x/interchainstaking/types/rebalance.go @@ -1,7 +1,7 @@ package types import ( - fmt "fmt" + "fmt" "math" "sort" From 632a3341436c6cf4e5a5ccd1999d4a49854990c2 Mon Sep 17 00:00:00 2001 From: Ajaz Ahmed Ansari Date: Tue, 11 Jul 2023 19:54:57 +0530 Subject: [PATCH 6/8] Update x/interchainstaking/types/rebalance.go --- x/interchainstaking/types/rebalance.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x/interchainstaking/types/rebalance.go b/x/interchainstaking/types/rebalance.go index fb7fde448..e149e73a1 100644 --- a/x/interchainstaking/types/rebalance.go +++ b/x/interchainstaking/types/rebalance.go @@ -1,7 +1,7 @@ package types import ( - "fmt" + "fmt" "math" "sort" From 04ac03efc8cf0ba161781dc5df26d952548f5965 Mon Sep 17 00:00:00 2001 From: Ajaz Ahmed Ansari Date: Tue, 11 Jul 2023 19:55:33 +0530 Subject: [PATCH 7/8] Update x/interchainstaking/types/rebalance.go --- x/interchainstaking/types/rebalance.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x/interchainstaking/types/rebalance.go b/x/interchainstaking/types/rebalance.go index e149e73a1..1a3d045ab 100644 --- a/x/interchainstaking/types/rebalance.go +++ b/x/interchainstaking/types/rebalance.go @@ -1,7 +1,7 @@ package types import ( - "fmt" + "fmt" "math" "sort" From 8adec8c1ea6c87b06d86bb7ac582aaac7dbab95c Mon Sep 17 00:00:00 2001 From: Ajaz Ahmed Ansari Date: Tue, 11 Jul 2023 20:00:17 +0530 Subject: [PATCH 8/8] lint fix --- x/interchainstaking/types/rebalance.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x/interchainstaking/types/rebalance.go b/x/interchainstaking/types/rebalance.go index 1a3d045ab..b3195c21b 100644 --- a/x/interchainstaking/types/rebalance.go +++ b/x/interchainstaking/types/rebalance.go @@ -1,7 +1,7 @@ package types import ( - "fmt" + "fmt" "math" "sort"