From b58c73239da824e7eebafaa236e846e34e1b94eb Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Fri, 19 May 2023 22:33:19 -0500 Subject: [PATCH] [CL Message Audit]: MsgUnlockAndMigrateSharesToFullRangeConcentratedPosition [2/2] (#5160) * initial push * update readme * initial push audit changes * use enum for migration type * add back proto tag * rename to validateGammLockForSuperfluidStaking * remove unused errors * Update x/superfluid/keeper/migrate.go Co-authored-by: Matt, Park <45252226+mattverse@users.noreply.github.com> * add check for both bonded and unbonding * update gomod * allow max length 1 for linked synth locks * feat: partial superfluid undelegate (#5162) * partial superfluid undelegate * further reduction of gas * Update x/superfluid/keeper/migrate.go Co-authored-by: Roman * reduce code duplication lock and lockNoSend * lockNoSend as default * add bug fix with expanded tests * unit test for CreateLockNoSend * Update x/superfluid/keeper/migrate.go Co-authored-by: Matt, Park <45252226+mattverse@users.noreply.github.com> * fix merge * partialSuperfluidUndelegate named return values * expand test checks for migrateDelegated * expanded bonded checks * check new lock and exact amount * assign vars directly * update merge * check newly created lock * add extra logic branch for fail case * split up partial superfluid undelegate func * expand partial undelegate test case * roman's review * Update x/lockup/keeper/lock_test.go * nit * fix test * expand CreateLockNoSend comment * rename lockNoSend back to lock * expand partial unlock comment --------- Co-authored-by: Roman Co-authored-by: Matt, Park <45252226+mattverse@users.noreply.github.com> * remove unused error * lint --------- Co-authored-by: Matt, Park <45252226+mattverse@users.noreply.github.com> Co-authored-by: Roman --- tests/cl-go-client/go.mod | 2 +- x/concentrated-liquidity/position.go | 11 +- .../types/expected_keepers.go | 1 + x/lockup/keeper/lock.go | 40 +- x/lockup/keeper/lock_test.go | 75 ++- x/superfluid/keeper/export_test.go | 30 +- x/superfluid/keeper/migrate.go | 325 +++++------ x/superfluid/keeper/migrate_test.go | 538 ++++++++++++------ x/superfluid/keeper/msg_server.go | 3 +- x/superfluid/keeper/stake.go | 66 ++- x/superfluid/keeper/stake_test.go | 160 ++++++ x/superfluid/types/errors.go | 26 + x/superfluid/types/expected_keepers.go | 2 + 13 files changed, 878 insertions(+), 401 deletions(-) diff --git a/tests/cl-go-client/go.mod b/tests/cl-go-client/go.mod index ddd4003daf8..67222456bed 100644 --- a/tests/cl-go-client/go.mod +++ b/tests/cl-go-client/go.mod @@ -5,7 +5,7 @@ go 1.20 require ( github.com/cosmos/cosmos-sdk v0.47.2 github.com/ignite/cli v0.23.0 - github.com/osmosis-labs/osmosis/v15 v15.0.0-20230502194055-13c81d83ef0d + github.com/osmosis-labs/osmosis/v15 v15.0.0-20230511223858-61e374113afc ) diff --git a/x/concentrated-liquidity/position.go b/x/concentrated-liquidity/position.go index dc6f1a3e5cb..becc633a8a6 100644 --- a/x/concentrated-liquidity/position.go +++ b/x/concentrated-liquidity/position.go @@ -426,17 +426,12 @@ func (k Keeper) mintSharesAndLock(ctx sdk.Context, concentratedPoolId, positionI if err != nil { return 0, sdk.Coins{}, err } - err = k.bankKeeper.SendCoinsFromModuleToAccount(ctx, lockuptypes.ModuleName, owner, underlyingLiquidityTokenized) - if err != nil { - return 0, sdk.Coins{}, err - } // Lock the position for the specified duration. + // We don't need to send the coins from the owner to the lockup module account because the coins were minted directly to the module account above. // Note, the end blocker for the lockup module contains an exception for this CL denom. When a lock with a denom of cl/pool/{poolId} is mature, - // it does not send the coins to the owner account but instead burns them. - // This is implemented in such a way to use well-tested pre-existing methods rather than - // completely re-implementing concentrated liquidity superfluid infrastructure that has a risk of introducing bugs with new logic and methods. - concentratedLock, err := k.lockupKeeper.CreateLock(ctx, owner, underlyingLiquidityTokenized, remainingLockDuration) + // it does not send the coins to the owner account and instead burns them. This is strictly to use well tested pre-existing methods rather than potentially introducing bugs with new logic and methods. + concentratedLock, err := k.lockupKeeper.CreateLockNoSend(ctx, owner, underlyingLiquidityTokenized, remainingLockDuration) if err != nil { return 0, sdk.Coins{}, err } diff --git a/x/concentrated-liquidity/types/expected_keepers.go b/x/concentrated-liquidity/types/expected_keepers.go index 507f40fc1f4..b66b63540d1 100644 --- a/x/concentrated-liquidity/types/expected_keepers.go +++ b/x/concentrated-liquidity/types/expected_keepers.go @@ -58,6 +58,7 @@ type LockupKeeper interface { BeginForceUnlock(ctx sdk.Context, lockID uint64, coins sdk.Coins) (uint64, error) ForceUnlock(ctx sdk.Context, lock lockuptypes.PeriodLock) error CreateLock(ctx sdk.Context, owner sdk.AccAddress, coins sdk.Coins, duration time.Duration) (lockuptypes.PeriodLock, error) + CreateLockNoSend(ctx sdk.Context, owner sdk.AccAddress, coins sdk.Coins, duration time.Duration) (lockuptypes.PeriodLock, error) SlashTokensFromLockByID(ctx sdk.Context, lockID uint64, coins sdk.Coins) (*lockuptypes.PeriodLock, error) GetLockedDenom(ctx sdk.Context, denom string, duration time.Duration) sdk.Int } diff --git a/x/lockup/keeper/lock.go b/x/lockup/keeper/lock.go index 076ed76537d..16ec390dcba 100644 --- a/x/lockup/keeper/lock.go +++ b/x/lockup/keeper/lock.go @@ -94,6 +94,12 @@ func (k Keeper) AddTokensToLockByID(ctx sdk.Context, lockID uint64, owner sdk.Ac } lock.Coins = lock.Coins.Add(tokensToAdd) + + // Send the tokens we are about to add to lock to the lockup module account. + if err := k.bk.SendCoinsFromAccountToModule(ctx, owner, types.ModuleName, sdk.NewCoins(tokensToAdd)); err != nil { + return nil, err + } + err = k.lock(ctx, *lock, sdk.NewCoins(tokensToAdd)) if err != nil { return nil, err @@ -116,10 +122,31 @@ func (k Keeper) AddTokensToLockByID(ctx sdk.Context, lockID uint64, owner sdk.Ac // Returns an error in the following conditions: // - account does not have enough balance func (k Keeper) CreateLock(ctx sdk.Context, owner sdk.AccAddress, coins sdk.Coins, duration time.Duration) (types.PeriodLock, error) { + // Send the coins we are about to lock to the lockup module account. + if err := k.bk.SendCoinsFromAccountToModule(ctx, owner, types.ModuleName, coins); err != nil { + return types.PeriodLock{}, err + } + + // Run the createLock logic without the send since we sent the coins above. + lock, err := k.CreateLockNoSend(ctx, owner, coins, duration) + if err != nil { + return types.PeriodLock{}, err + } + return lock, nil +} + +// CreateLockNoSend behaves the same as CreateLock, but does not send the coins to the lockup module account. +// This method is used in the concentrated liquidity module since we mint coins directly to the lockup module account. +// We do not want to mint the coins to send to the user just to send them back to the lockup module account for two reasons: +// - it is gas inefficient +// - users should not be able to have cl shares in their account, so this is an extra safety measure +func (k Keeper) CreateLockNoSend(ctx sdk.Context, owner sdk.AccAddress, coins sdk.Coins, duration time.Duration) (types.PeriodLock, error) { ID := k.GetLastLockID(ctx) + 1 // unlock time is initially set without a value, gets set as unlock start time + duration // when unlocking starts. lock := types.NewPeriodLock(ID, owner, duration, time.Time{}, coins) + + // lock the coins without sending them to the lockup module account err := k.lock(ctx, lock, lock.Coins) if err != nil { return lock, err @@ -139,14 +166,13 @@ func (k Keeper) CreateLock(ctx sdk.Context, owner sdk.AccAddress, coins sdk.Coin // This is only called by either of the two possible entry points to lock tokens. // 1. CreateLock // 2. AddTokensToLockByID +// WARNING: this method does not send the underlying coins to the lockup module account. +// This must be done by the caller. func (k Keeper) lock(ctx sdk.Context, lock types.PeriodLock, tokensToLock sdk.Coins) error { owner, err := sdk.AccAddressFromBech32(lock.Owner) if err != nil { return err } - if err := k.bk.SendCoinsFromAccountToModule(ctx, owner, types.ModuleName, tokensToLock); err != nil { - return err - } // store lock object into the store err = k.setLock(ctx, lock) @@ -218,7 +244,7 @@ func (k Keeper) beginUnlock(ctx sdk.Context, lock types.PeriodLock, coins sdk.Co // Otherwise, split the lock into two locks, and fully unlock the newly created lock. // (By virtue, the newly created lock we split into should have the unlock amount) if len(coins) != 0 && !coins.IsEqual(lock.Coins) { - splitLock, err := k.splitLock(ctx, lock, coins, false) + splitLock, err := k.SplitLock(ctx, lock, coins, false) if err != nil { return 0, err } @@ -336,7 +362,7 @@ func (k Keeper) PartialForceUnlock(ctx sdk.Context, lock types.PeriodLock, coins // split lock to support partial force unlock. // (By virtue, the newly created lock we split into should have the unlock amount) if len(coins) != 0 && !coins.IsEqual(lock.Coins) { - splitLock, err := k.splitLock(ctx, lock, coins, true) + splitLock, err := k.SplitLock(ctx, lock, coins, true) if err != nil { return err } @@ -750,9 +776,9 @@ func (k Keeper) deleteLock(ctx sdk.Context, id uint64) { store.Delete(lockStoreKey(id)) } -// splitLock splits a lock with the given amount, and stores split new lock to the state. +// SplitLock splits a lock with the given amount, and stores split new lock to the state. // Returns the new lock after modifying the state of the old lock. -func (k Keeper) splitLock(ctx sdk.Context, lock types.PeriodLock, coins sdk.Coins, forceUnlock bool) (types.PeriodLock, error) { +func (k Keeper) SplitLock(ctx sdk.Context, lock types.PeriodLock, coins sdk.Coins, forceUnlock bool) (types.PeriodLock, error) { if !forceUnlock && lock.IsUnlocking() { return types.PeriodLock{}, fmt.Errorf("cannot split unlocking lock") } diff --git a/x/lockup/keeper/lock_test.go b/x/lockup/keeper/lock_test.go index 3dc948735bd..c1433f60211 100644 --- a/x/lockup/keeper/lock_test.go +++ b/x/lockup/keeper/lock_test.go @@ -533,6 +533,61 @@ func (s *KeeperTestSuite) TestCreateLock() { s.Require().Equal(sdk.NewInt(30), balance.Amount) } +func (s *KeeperTestSuite) TestCreateLockNoSend() { + s.SetupTest() + + addr1 := sdk.AccAddress([]byte("addr1---------------")) + coins := sdk.Coins{sdk.NewInt64Coin("stake", 10)} + + // test locking without balance + lock, err := s.App.LockupKeeper.CreateLockNoSend(s.Ctx, addr1, coins, time.Second) + s.Require().NoError(err) + + // check new lock + s.Require().Equal(coins, lock.Coins) + s.Require().Equal(time.Second, lock.Duration) + s.Require().Equal(time.Time{}, lock.EndTime) + s.Require().Equal(uint64(1), lock.ID) + + lockID := s.App.LockupKeeper.GetLastLockID(s.Ctx) + s.Require().Equal(uint64(1), lockID) + + // check accumulation store + accum := s.App.LockupKeeper.GetPeriodLocksAccumulation(s.Ctx, types.QueryCondition{ + LockQueryType: types.ByDuration, + Denom: "stake", + Duration: time.Second, + }) + s.Require().Equal(accum.String(), "10") + + // create new lock (this time with a balance) + originalLockBalance := int64(20) + coins = sdk.Coins{sdk.NewInt64Coin("stake", originalLockBalance)} + s.FundAcc(addr1, coins) + + lock, err = s.App.LockupKeeper.CreateLockNoSend(s.Ctx, addr1, coins, time.Second) + s.Require().NoError(err) + + lockID = s.App.LockupKeeper.GetLastLockID(s.Ctx) + s.Require().Equal(uint64(2), lockID) + + // check accumulation store + accum = s.App.LockupKeeper.GetPeriodLocksAccumulation(s.Ctx, types.QueryCondition{ + LockQueryType: types.ByDuration, + Denom: "stake", + Duration: time.Second, + }) + s.Require().Equal(accum.String(), "30") + + // check that send did not occur and balances are unchanged + balance := s.App.BankKeeper.GetBalance(s.Ctx, addr1, "stake") + s.Require().Equal(sdk.NewInt(originalLockBalance).String(), balance.Amount.String()) + + acc := s.App.AccountKeeper.GetModuleAccount(s.Ctx, types.ModuleName) + balance = s.App.BankKeeper.GetBalance(s.Ctx, acc.GetAddress(), "stake") + s.Require().Equal(sdk.ZeroInt().String(), balance.Amount.String()) +} + func (s *KeeperTestSuite) TestAddTokensToLock() { initialLockCoin := sdk.NewInt64Coin("stake", 10) addr1 := sdk.AccAddress([]byte("addr1---------------")) @@ -717,9 +772,9 @@ func (s *KeeperTestSuite) TestLock() { Coins: coins, } - // test locking without balance + // test locking without balance (should work since we don't send the underlying balance) err := s.App.LockupKeeper.Lock(s.Ctx, lock, coins) - s.Require().Error(err) + s.Require().NoError(err) // check accumulation store accum := s.App.LockupKeeper.GetPeriodLocksAccumulation(s.Ctx, types.QueryCondition{ @@ -727,7 +782,7 @@ func (s *KeeperTestSuite) TestLock() { Denom: "stake", Duration: time.Second, }) - s.Require().Equal(accum.String(), "0") + s.Require().Equal(accum.String(), "10") s.FundAcc(addr1, coins) err = s.App.LockupKeeper.Lock(s.Ctx, lock, coins) @@ -739,14 +794,15 @@ func (s *KeeperTestSuite) TestLock() { Denom: "stake", Duration: time.Second, }) - s.Require().Equal(accum.String(), "10") + s.Require().Equal(accum.String(), "20") + // Since lock method no longer sends the underlying coins, the account balance should be unchanged balance := s.App.BankKeeper.GetBalance(s.Ctx, addr1, "stake") - s.Require().Equal(sdk.ZeroInt(), balance.Amount) + s.Require().Equal(sdk.NewInt(10).String(), balance.Amount.String()) acc := s.App.AccountKeeper.GetModuleAccount(s.Ctx, types.ModuleName) balance = s.App.BankKeeper.GetBalance(s.Ctx, acc.GetAddress(), "stake") - s.Require().Equal(sdk.NewInt(10), balance.Amount) + s.Require().Equal(sdk.NewInt(0).String(), balance.Amount.String()) } func (s *KeeperTestSuite) AddTokensToLockForSynth() { @@ -1272,6 +1328,7 @@ func (s *KeeperTestSuite) TestPartialForceUnlock() { defaultDenomToLock := "stake" defaultAmountToLock := sdk.NewInt(10000000) + coinsToLock := sdk.NewCoins(sdk.NewCoin("stake", defaultAmountToLock)) testCases := []struct { name string @@ -1280,7 +1337,7 @@ func (s *KeeperTestSuite) TestPartialForceUnlock() { }{ { name: "unlock full amount", - coinsToForceUnlock: sdk.Coins{sdk.NewCoin(defaultDenomToLock, defaultAmountToLock)}, + coinsToForceUnlock: coinsToLock, expectedPass: true, }, { @@ -1302,9 +1359,9 @@ func (s *KeeperTestSuite) TestPartialForceUnlock() { for _, tc := range testCases { // set up test and create default lock s.SetupTest() - coinsToLock := sdk.NewCoins(sdk.NewCoin("stake", defaultAmountToLock)) + s.FundAcc(addr1, sdk.NewCoins(coinsToLock...)) - // balanceBeforeLock := s.App.BankKeeper.GetAllBalances(s.Ctx, addr1) + lock, err := s.App.LockupKeeper.CreateLock(s.Ctx, addr1, coinsToLock, time.Minute) s.Require().NoError(err) diff --git a/x/superfluid/keeper/export_test.go b/x/superfluid/keeper/export_test.go index f030757b4ff..c830be7462f 100644 --- a/x/superfluid/keeper/export_test.go +++ b/x/superfluid/keeper/export_test.go @@ -5,8 +5,8 @@ import ( sdk "github.com/cosmos/cosmos-sdk/types" - cltypes "github.com/osmosis-labs/osmosis/v15/x/concentrated-liquidity/types" lockuptypes "github.com/osmosis-labs/osmosis/v15/x/lockup/types" + "github.com/osmosis-labs/osmosis/v15/x/superfluid/types" ) var ( @@ -22,24 +22,28 @@ func (k Keeper) PrepareConcentratedLockForSlash(ctx sdk.Context, lock *lockuptyp return k.prepareConcentratedLockForSlash(ctx, lock, slashAmt) } -func (k Keeper) MigrateSuperfluidBondedBalancerToConcentrated(ctx sdk.Context, sender sdk.AccAddress, poolIdLeaving, poolIdEntering uint64, preMigrationLock *lockuptypes.PeriodLock, lockId uint64, sharesToMigrate sdk.Coin, synthDenomBeforeMigration string, concentratedPool cltypes.ConcentratedPoolExtension, remainingLockTime time.Duration, tokenOutMins sdk.Coins) (positionId uint64, amount0, amount1 sdk.Int, liquidity sdk.Dec, joinTime time.Time, gammLockId, concentratedLockId uint64, err error) { - return k.migrateSuperfluidBondedBalancerToConcentrated(ctx, sender, poolIdLeaving, poolIdEntering, preMigrationLock, lockId, sharesToMigrate, synthDenomBeforeMigration, concentratedPool, remainingLockTime, tokenOutMins) +func (k Keeper) MigrateSuperfluidBondedBalancerToConcentrated(ctx sdk.Context, sender sdk.AccAddress, lockId uint64, sharesToMigrate sdk.Coin, synthDenomBeforeMigration string, tokenOutMins sdk.Coins) (positionId uint64, amount0, amount1 sdk.Int, liquidity sdk.Dec, joinTime time.Time, concentratedLockId, poolIdLeaving, poolIdEntering uint64, err error) { + return k.migrateSuperfluidBondedBalancerToConcentrated(ctx, sender, lockId, sharesToMigrate, synthDenomBeforeMigration, tokenOutMins) } -func (k Keeper) MigrateSuperfluidUnbondingBalancerToConcentrated(ctx sdk.Context, sender sdk.AccAddress, poolIdLeaving, poolIdEntering uint64, preMigrationLock *lockuptypes.PeriodLock, sharesToMigrate sdk.Coin, synthDenomBeforeMigration string, concentratedPool cltypes.ConcentratedPoolExtension, remainingLockTime time.Duration, tokenOutMins sdk.Coins) (positionId uint64, amount0, amount1 sdk.Int, liquidity sdk.Dec, joinTime time.Time, gammLockId, concentratedLockId uint64, err error) { - return k.migrateSuperfluidUnbondingBalancerToConcentrated(ctx, sender, poolIdLeaving, poolIdEntering, preMigrationLock, sharesToMigrate, synthDenomBeforeMigration, concentratedPool, remainingLockTime, tokenOutMins) +func (k Keeper) MigrateSuperfluidUnbondingBalancerToConcentrated(ctx sdk.Context, sender sdk.AccAddress, lockId uint64, sharesToMigrate sdk.Coin, synthDenomBeforeMigration string, tokenOutMins sdk.Coins) (positionId uint64, amount0, amount1 sdk.Int, liquidity sdk.Dec, joinTime time.Time, concentratedLockId, poolIdLeaving, poolIdEntering uint64, err error) { + return k.migrateSuperfluidUnbondingBalancerToConcentrated(ctx, sender, lockId, sharesToMigrate, synthDenomBeforeMigration, tokenOutMins) } -func (k Keeper) MigrateNonSuperfluidLockBalancerToConcentrated(ctx sdk.Context, sender sdk.AccAddress, poolIdLeaving, poolIdEntering uint64, preMigrationLock *lockuptypes.PeriodLock, sharesToMigrate sdk.Coin, concentratedPool cltypes.ConcentratedPoolExtension, remainingLockTime time.Duration, tokenOutMins sdk.Coins) (positionId uint64, amount0, amount1 sdk.Int, liquidity sdk.Dec, joinTime time.Time, gammLockId, concentratedLockId uint64, err error) { - return k.migrateNonSuperfluidLockBalancerToConcentrated(ctx, sender, poolIdLeaving, poolIdEntering, preMigrationLock, sharesToMigrate, concentratedPool, remainingLockTime, tokenOutMins) +func (k Keeper) MigrateNonSuperfluidLockBalancerToConcentrated(ctx sdk.Context, sender sdk.AccAddress, lockId uint64, sharesToMigrate sdk.Coin, tokenOutMins sdk.Coins) (positionId uint64, amount0, amount1 sdk.Int, liquidity sdk.Dec, joinTime time.Time, concentratedLockId, poolIdLeaving, poolIdEntering uint64, err error) { + return k.migrateNonSuperfluidLockBalancerToConcentrated(ctx, sender, lockId, sharesToMigrate, tokenOutMins) } -func (k Keeper) ValidateSharesToMigrateUnlockAndExitBalancerPool(ctx sdk.Context, sender sdk.AccAddress, poolIdLeaving uint64, lock *lockuptypes.PeriodLock, sharesToMigrate sdk.Coin, tokenOutMins sdk.Coins) (exitCoins sdk.Coins, err error) { - return k.validateSharesToMigrateUnlockAndExitBalancerPool(ctx, sender, poolIdLeaving, lock, sharesToMigrate, tokenOutMins) +func (k Keeper) ValidateSharesToMigrateUnlockAndExitBalancerPool(ctx sdk.Context, sender sdk.AccAddress, poolIdLeaving uint64, lock *lockuptypes.PeriodLock, sharesToMigrate sdk.Coin, tokenOutMins sdk.Coins, remainingLockTime time.Duration) (exitCoins sdk.Coins, err error) { + return k.validateSharesToMigrateUnlockAndExitBalancerPool(ctx, sender, poolIdLeaving, lock, sharesToMigrate, tokenOutMins, remainingLockTime) } -func (k Keeper) PrepareMigration(ctx sdk.Context, sender sdk.AccAddress, lockId uint64, sharesToMigrate sdk.Coin) (poolIdLeaving, poolIdEntering uint64, concentratedPool cltypes.ConcentratedPoolExtension, preMigrationLock *lockuptypes.PeriodLock, remainingLockTime time.Duration, synthLockBeforeMigration []lockuptypes.SyntheticLock, isSuperfluidBonded, isSuperfluidUnbonding bool, err error) { - return k.prepareMigration(ctx, sender, lockId, sharesToMigrate) +func (k Keeper) RouteMigration(ctx sdk.Context, sender sdk.AccAddress, lockId uint64, sharesToMigrate sdk.Coin) (synthLocksBeforeMigration []lockuptypes.SyntheticLock, migrationType MigrationType, err error) { + return k.routeMigration(ctx, sender, lockId, sharesToMigrate) +} + +func (k Keeper) ValidateMigration(ctx sdk.Context, sender sdk.AccAddress, lockId uint64, sharesToMigrate sdk.Coin) (poolIdLeaving, poolIdEntering uint64, preMigrationLock *lockuptypes.PeriodLock, remainingLockTime time.Duration, err error) { + return k.validateMigration(ctx, sender, lockId, sharesToMigrate) } func (k Keeper) AddToConcentratedLiquiditySuperfluidPosition(ctx sdk.Context, owner sdk.AccAddress, positionId uint64, amount0Added, amount1Added sdk.Int) (uint64, sdk.Int, sdk.Int, sdk.Dec, uint64, error) { @@ -53,3 +57,7 @@ func (k Keeper) ValidateGammLockForSuperfluidStaking(ctx sdk.Context, sender sdk func (k Keeper) GetExistingLockRemainingDuration(ctx sdk.Context, lock *lockuptypes.PeriodLock) (time.Duration, error) { return k.getExistingLockRemainingDuration(ctx, lock) } + +func (k Keeper) PartialSuperfluidUndelegateToConcentratedPosition(ctx sdk.Context, sender string, lockID uint64, amountToUndelegate sdk.Coin) (intermediaryAcc types.SuperfluidIntermediaryAccount, newlock *lockuptypes.PeriodLock, err error) { + return k.partialSuperfluidUndelegateToConcentratedPosition(ctx, sender, lockID, amountToUndelegate) +} diff --git a/x/superfluid/keeper/migrate.go b/x/superfluid/keeper/migrate.go index 78314aa9c51..7d001eb27b4 100644 --- a/x/superfluid/keeper/migrate.go +++ b/x/superfluid/keeper/migrate.go @@ -10,102 +10,108 @@ import ( cltypes "github.com/osmosis-labs/osmosis/v15/x/concentrated-liquidity/types" gammtypes "github.com/osmosis-labs/osmosis/v15/x/gamm/types" lockuptypes "github.com/osmosis-labs/osmosis/v15/x/lockup/types" + "github.com/osmosis-labs/osmosis/v15/x/superfluid/types" +) + +type MigrationType int + +const ( + SuperfluidBonded MigrationType = iota + SuperfluidUnbonding + NonSuperfluid + Unsupported ) // RouteLockedBalancerToConcentratedMigration routes the provided lock to the proper migration function based on the lock status. -// If the lock is superfluid delegated, it will undelegate the superfluid position and redelegate it as a concentrated liquidity position. +// If the lock is superfluid delegated, it will instantly undelegate the superfluid position and redelegate it as a concentrated liquidity position. // If the lock is superfluid undelegating, it will instantly undelegate the superfluid position and redelegate it as a concentrated liquidity position, but continue to unlock where it left off. // If the lock is locked or unlocking but not superfluid delegated/undelegating, it will migrate the position and either start unlocking or continue unlocking where it left off. // Errors if the lock is not found, if the lock is not a balancer pool lock, or if the lock is not owned by the sender. -func (k Keeper) RouteLockedBalancerToConcentratedMigration(ctx sdk.Context, sender sdk.AccAddress, lockId uint64, sharesToMigrate sdk.Coin, tokenOutMins sdk.Coins) (positionId uint64, amount0, amount1 sdk.Int, liquidity sdk.Dec, joinTime time.Time, poolIdLeaving, poolIdEntering, gammLockId, concentratedLockId uint64, err error) { - // Validate and retrieve pertinent data required for migration - poolIdLeaving, poolIdEntering, concentratedPool, preMigrationLock, remainingLockTime, synthLockBeforeMigration, isSuperfluidBonded, isSuperfluidUnbonding, err := k.prepareMigration(ctx, sender, lockId, sharesToMigrate) +func (k Keeper) RouteLockedBalancerToConcentratedMigration(ctx sdk.Context, sender sdk.AccAddress, lockId uint64, sharesToMigrate sdk.Coin, tokenOutMins sdk.Coins) (positionId uint64, amount0, amount1 sdk.Int, liquidity sdk.Dec, joinTime time.Time, poolIdLeaving, poolIdEntering, concentratedLockId uint64, err error) { + synthLocksBeforeMigration, migrationType, err := k.routeMigration(ctx, sender, lockId, sharesToMigrate) if err != nil { - return 0, sdk.Int{}, sdk.Int{}, sdk.Dec{}, time.Time{}, 0, 0, 0, 0, err + return 0, sdk.Int{}, sdk.Int{}, sdk.Dec{}, time.Time{}, 0, 0, 0, err } - if isSuperfluidBonded { - // Migration logic for superfluid bonded locks - positionId, amount0, amount1, liquidity, joinTime, gammLockId, concentratedLockId, err = k.migrateSuperfluidBondedBalancerToConcentrated(ctx, sender, poolIdLeaving, poolIdEntering, preMigrationLock, lockId, sharesToMigrate, synthLockBeforeMigration[0].SynthDenom, concentratedPool, remainingLockTime, tokenOutMins) - } else if isSuperfluidUnbonding { - // Migration logic for superfluid unbonding locks - positionId, amount0, amount1, liquidity, joinTime, gammLockId, concentratedLockId, err = k.migrateSuperfluidUnbondingBalancerToConcentrated(ctx, sender, poolIdLeaving, poolIdEntering, preMigrationLock, sharesToMigrate, synthLockBeforeMigration[0].SynthDenom, concentratedPool, remainingLockTime, tokenOutMins) - } else if !isSuperfluidBonded && !isSuperfluidUnbonding && len(synthLockBeforeMigration) == 0 { - // Migration logic for non-superfluid locks - positionId, amount0, amount1, liquidity, joinTime, gammLockId, concentratedLockId, err = k.migrateNonSuperfluidLockBalancerToConcentrated(ctx, sender, poolIdLeaving, poolIdEntering, preMigrationLock, sharesToMigrate, concentratedPool, remainingLockTime, tokenOutMins) - } else { - // Unsupported migration - return 0, sdk.Int{}, sdk.Int{}, sdk.Dec{}, time.Time{}, 0, 0, 0, 0, fmt.Errorf("unexpected synth lock state for lock %d", lockId) + switch migrationType { + case SuperfluidBonded: + positionId, amount0, amount1, liquidity, joinTime, concentratedLockId, poolIdLeaving, poolIdEntering, err = k.migrateSuperfluidBondedBalancerToConcentrated(ctx, sender, lockId, sharesToMigrate, synthLocksBeforeMigration[0].SynthDenom, tokenOutMins) + case SuperfluidUnbonding: + positionId, amount0, amount1, liquidity, joinTime, concentratedLockId, poolIdLeaving, poolIdEntering, err = k.migrateSuperfluidUnbondingBalancerToConcentrated(ctx, sender, lockId, sharesToMigrate, synthLocksBeforeMigration[0].SynthDenom, tokenOutMins) + case NonSuperfluid: + positionId, amount0, amount1, liquidity, joinTime, concentratedLockId, poolIdLeaving, poolIdEntering, err = k.migrateNonSuperfluidLockBalancerToConcentrated(ctx, sender, lockId, sharesToMigrate, tokenOutMins) + default: + return 0, sdk.Int{}, sdk.Int{}, sdk.Dec{}, time.Time{}, 0, 0, 0, fmt.Errorf("unsupported migration type") } - return positionId, amount0, amount1, liquidity, joinTime, poolIdLeaving, poolIdEntering, gammLockId, concentratedLockId, err + + return positionId, amount0, amount1, liquidity, joinTime, poolIdLeaving, poolIdEntering, concentratedLockId, err } // migrateSuperfluidBondedBalancerToConcentrated migrates a user's superfluid bonded balancer position to a superfluid bonded concentrated liquidity position. -// The function first undelegates the superfluid delegated position, force unlocks and exits the balancer pool, creates a full range concentrated liquidity position, and locks it while superfluid delegating it. -// If there are any remaining gamm shares, they are re-locked and superfluid delegated as normal. The function returns the concentrated liquidity position ID, amounts of -// tokens in the position, the liquidity amount, join time, and IDs of the involved pools and locks. +// The function first undelegates the superfluid delegated position, force unlocks and exits the balancer pool, creates a full range concentrated liquidity position, locks it, then superfluid delegates it. +// Any remaining gamm shares stay locked in the original gamm pool (utilizing the same lock and lockID that the shares originated from) and remain superfluid delegated / undelegating / vanilla locked as they +// were when the migration was initiated. The function returns the concentrated liquidity position ID, amounts of tokens in the position, the liquidity amount, join time, and IDs of the involved pools and locks. func (k Keeper) migrateSuperfluidBondedBalancerToConcentrated(ctx sdk.Context, sender sdk.AccAddress, - poolIdLeaving, poolIdEntering uint64, - preMigrationLock *lockuptypes.PeriodLock, - lockId uint64, + originalLockId uint64, sharesToMigrate sdk.Coin, synthDenomBeforeMigration string, - concentratedPool cltypes.ConcentratedPoolExtension, - remainingLockTime time.Duration, tokenOutMins sdk.Coins, -) (positionId uint64, amount0, amount1 sdk.Int, liquidity sdk.Dec, joinTime time.Time, gammLockId, concentratedLockId uint64, err error) { +) (positionId uint64, amount0, amount1 sdk.Int, liquidity sdk.Dec, joinTime time.Time, concentratedLockId, poolIdLeaving, poolIdEntering uint64, err error) { + poolIdLeaving, poolIdEntering, preMigrationLock, remainingLockTime, err := k.validateMigration(ctx, sender, originalLockId, sharesToMigrate) + if err != nil { + return 0, sdk.Int{}, sdk.Int{}, sdk.Dec{}, time.Time{}, 0, 0, 0, err + } + + isPartialMigration := sharesToMigrate.Amount.LT(preMigrationLock.Coins[0].Amount) + // Get the validator address from the synth denom and ensure it is a valid address. valAddr := strings.Split(synthDenomBeforeMigration, "/")[4] _, err = sdk.ValAddressFromBech32(valAddr) if err != nil { - return 0, sdk.Int{}, sdk.Int{}, sdk.Dec{}, time.Time{}, 0, 0, err + return 0, sdk.Int{}, sdk.Int{}, sdk.Dec{}, time.Time{}, 0, 0, 0, err } - gammSharesInLock := preMigrationLock.Coins[0] - - // Superfluid undelegate the superfluid delegated position. - // This deletes the connection between the lock and the intermediate account, deletes the synthetic lock, and burns the synthetic osmo. - intermediateAccount, err := k.SuperfluidUndelegateToConcentratedPosition(ctx, sender.String(), lockId) - if err != nil { - return 0, sdk.Int{}, sdk.Int{}, sdk.Dec{}, time.Time{}, 0, 0, err + // Superfluid undelegate the portion of shares the user is migrating from the superfluid delegated position. + // If all shares are being migrated, this deletes the connection between the gamm lock and the intermediate account, deletes the synthetic lock, and burns the synthetic osmo. + intermediateAccount := types.SuperfluidIntermediaryAccount{} + var gammLockToMigrate *lockuptypes.PeriodLock + if isPartialMigration { + // Note that lock's id is different from the originalLockId sinec it was split. + // The original lock id stays in gamm. + intermediateAccount, gammLockToMigrate, err = k.partialSuperfluidUndelegateToConcentratedPosition(ctx, sender.String(), originalLockId, sharesToMigrate) + if err != nil { + return 0, sdk.Int{}, sdk.Int{}, sdk.Dec{}, time.Time{}, 0, 0, 0, err + } + } else { + // Note that lock's id is the same as the originalLockId since all shares are being migrated + // and old lock is deleted + gammLockToMigrate = preMigrationLock + intermediateAccount, err = k.SuperfluidUndelegateToConcentratedPosition(ctx, sender.String(), originalLockId) + if err != nil { + return 0, sdk.Int{}, sdk.Int{}, sdk.Dec{}, time.Time{}, 0, 0, 0, err + } } // Force unlock, validate the provided sharesToMigrate, and exit the balancer pool. // This will return the coins that will be used to create the concentrated liquidity position. - exitCoins, err := k.validateSharesToMigrateUnlockAndExitBalancerPool(ctx, sender, poolIdLeaving, preMigrationLock, sharesToMigrate, tokenOutMins) + // It also returns the lock object that contains the remaining shares that were not used in this migration. + exitCoins, err := k.validateSharesToMigrateUnlockAndExitBalancerPool(ctx, sender, poolIdLeaving, gammLockToMigrate, sharesToMigrate, tokenOutMins, remainingLockTime) if err != nil { - return 0, sdk.Int{}, sdk.Int{}, sdk.Dec{}, time.Time{}, 0, 0, err + return 0, sdk.Int{}, sdk.Int{}, sdk.Dec{}, time.Time{}, 0, 0, 0, err } // Create a full range (min to max tick) concentrated liquidity position, lock it, and superfluid delegate it. positionId, amount0, amount1, liquidity, joinTime, concentratedLockId, err = k.clk.CreateFullRangePositionLocked(ctx, poolIdEntering, sender, exitCoins, remainingLockTime) if err != nil { - return 0, sdk.Int{}, sdk.Int{}, sdk.Dec{}, time.Time{}, 0, 0, err + return 0, sdk.Int{}, sdk.Int{}, sdk.Dec{}, time.Time{}, 0, 0, 0, err } err = k.SuperfluidDelegate(ctx, sender.String(), concentratedLockId, intermediateAccount.ValAddr) if err != nil { - return 0, sdk.Int{}, sdk.Int{}, sdk.Dec{}, time.Time{}, 0, 0, err + return 0, sdk.Int{}, sdk.Int{}, sdk.Dec{}, time.Time{}, 0, 0, 0, err } - // If there are remaining gamm shares, we must re-lock them. - remainingGammShares := gammSharesInLock.Sub(sharesToMigrate) - if !remainingGammShares.IsZero() { - // Create a new lock with the remaining gamm shares for the remaining lock time. - newLock, err := k.lk.CreateLock(ctx, sender, sdk.NewCoins(remainingGammShares), remainingLockTime) - gammLockId = newLock.ID - if err != nil { - return 0, sdk.Int{}, sdk.Int{}, sdk.Dec{}, time.Time{}, 0, 0, err - } - - // If the gamm lock was previously superfluid bonded, superfluid delegate the gamm like normal - err = k.SuperfluidDelegate(ctx, sender.String(), gammLockId, valAddr) - if err != nil { - return 0, sdk.Int{}, sdk.Int{}, sdk.Dec{}, time.Time{}, 0, 0, err - } - } - - return positionId, amount0, amount1, liquidity, joinTime, gammLockId, concentratedLockId, nil + return positionId, amount0, amount1, liquidity, joinTime, concentratedLockId, poolIdLeaving, poolIdEntering, nil } // migrateSuperfluidUnbondingBalancerToConcentrated migrates a user's superfluid unbonding balancer position to a superfluid unbonding concentrated liquidity position. @@ -115,38 +121,36 @@ func (k Keeper) migrateSuperfluidBondedBalancerToConcentrated(ctx sdk.Context, // The function returns the concentrated liquidity position ID, amounts of tokens in the position, the liquidity amount, join time, and IDs of the involved pools and locks. func (k Keeper) migrateSuperfluidUnbondingBalancerToConcentrated(ctx sdk.Context, sender sdk.AccAddress, - poolIdLeaving, poolIdEntering uint64, - preMigrationLock *lockuptypes.PeriodLock, + lockId uint64, sharesToMigrate sdk.Coin, synthDenomBeforeMigration string, - concentratedPool cltypes.ConcentratedPoolExtension, - remainingLockTime time.Duration, tokenOutMins sdk.Coins, -) (positionId uint64, amount0, amount1 sdk.Int, liquidity sdk.Dec, joinTime time.Time, gammLockId, concentratedLockId uint64, err error) { +) (positionId uint64, amount0, amount1 sdk.Int, liquidity sdk.Dec, joinTime time.Time, concentratedLockId, poolIdLeaving, poolIdEntering uint64, err error) { + poolIdLeaving, poolIdEntering, preMigrationLock, remainingLockTime, err := k.validateMigration(ctx, sender, lockId, sharesToMigrate) + if err != nil { + return 0, sdk.Int{}, sdk.Int{}, sdk.Dec{}, time.Time{}, 0, 0, 0, err + } + // Get the validator address from the synth denom and ensure it is a valid address. valAddr := strings.Split(synthDenomBeforeMigration, "/")[4] _, err = sdk.ValAddressFromBech32(valAddr) if err != nil { - return 0, sdk.Int{}, sdk.Int{}, sdk.Dec{}, time.Time{}, 0, 0, err + return 0, sdk.Int{}, sdk.Int{}, sdk.Dec{}, time.Time{}, 0, 0, 0, err } - gammSharesInLock := preMigrationLock.Coins[0] - - // Save unlocking state of lock before force unlocking - wasUnlocking := preMigrationLock.IsUnlocking() - // Force unlock, validate the provided sharesToMigrate, and exit the balancer pool. // This will return the coins that will be used to create the concentrated liquidity position. - exitCoins, err := k.validateSharesToMigrateUnlockAndExitBalancerPool(ctx, sender, poolIdLeaving, preMigrationLock, sharesToMigrate, tokenOutMins) + // It also returns the lock object that contains the remaining shares that were not used in this migration. + exitCoins, err := k.validateSharesToMigrateUnlockAndExitBalancerPool(ctx, sender, poolIdLeaving, preMigrationLock, sharesToMigrate, tokenOutMins, remainingLockTime) if err != nil { - return 0, sdk.Int{}, sdk.Int{}, sdk.Dec{}, time.Time{}, 0, 0, err + return 0, sdk.Int{}, sdk.Int{}, sdk.Dec{}, time.Time{}, 0, 0, 0, err } // Create a full range (min to max tick) concentrated liquidity position. // If the lock was unlocking, we create a new lock that is unlocking for the remaining time of the old lock. positionId, amount0, amount1, liquidity, joinTime, concentratedLockId, err = k.clk.CreateFullRangePositionUnlocking(ctx, poolIdEntering, sender, exitCoins, remainingLockTime) if err != nil { - return 0, sdk.Int{}, sdk.Int{}, sdk.Dec{}, time.Time{}, 0, 0, err + return 0, sdk.Int{}, sdk.Int{}, sdk.Dec{}, time.Time{}, 0, 0, 0, err } // The previous gamm intermediary account is now invalid for the new lock, since the underlying denom has changed and intermediary accounts are @@ -155,160 +159,118 @@ func (k Keeper) migrateSuperfluidUnbondingBalancerToConcentrated(ctx sdk.Context concentratedLockupDenom := cltypes.GetConcentratedLockupDenomFromPoolId(poolIdEntering) clIntermediateAccount, err := k.GetOrCreateIntermediaryAccount(ctx, concentratedLockupDenom, valAddr) if err != nil { - return 0, sdk.Int{}, sdk.Int{}, sdk.Dec{}, time.Time{}, 0, 0, err + return 0, sdk.Int{}, sdk.Int{}, sdk.Dec{}, time.Time{}, 0, 0, 0, err } // Create a new synthetic lockup for the new intermediary account in an unlocking status err = k.createSyntheticLockup(ctx, concentratedLockId, clIntermediateAccount, unlockingStatus) if err != nil { - return 0, sdk.Int{}, sdk.Int{}, sdk.Dec{}, time.Time{}, 0, 0, err - } - - // If there are remaining gamm shares, we must re-lock them. - remainingGammShares := gammSharesInLock.Sub(sharesToMigrate) - if !remainingGammShares.IsZero() { - // Create a new lock with the remaining gamm shares for the remaining lock time. - newLock, err := k.lk.CreateLock(ctx, sender, sdk.NewCoins(remainingGammShares), remainingLockTime) - gammLockId = newLock.ID - if err != nil { - return 0, sdk.Int{}, sdk.Int{}, sdk.Dec{}, time.Time{}, 0, 0, err - } - - // Get the previous gamm intermediary account, create a new gamm synthetic lockup, and set it to unlocking - gammIntermediateAccount, err := k.GetOrCreateIntermediaryAccount(ctx, remainingGammShares.Denom, valAddr) - if err != nil { - return 0, sdk.Int{}, sdk.Int{}, sdk.Dec{}, time.Time{}, 0, 0, err - } - err = k.createSyntheticLockup(ctx, gammLockId, gammIntermediateAccount, unlockingStatus) - if err != nil { - return 0, sdk.Int{}, sdk.Int{}, sdk.Dec{}, time.Time{}, 0, 0, err - } - - // If the gamm lock was unlocking, we begin the unlock from where it left off. - if wasUnlocking { - _, err := k.lk.BeginForceUnlock(ctx, newLock.ID, newLock.Coins) - if err != nil { - return 0, sdk.Int{}, sdk.Int{}, sdk.Dec{}, time.Time{}, 0, 0, err - } - } + return 0, sdk.Int{}, sdk.Int{}, sdk.Dec{}, time.Time{}, 0, 0, 0, err } - return positionId, amount0, amount1, liquidity, joinTime, gammLockId, concentratedLockId, nil + return positionId, amount0, amount1, liquidity, joinTime, concentratedLockId, poolIdLeaving, poolIdEntering, nil } // migrateNonSuperfluidLockBalancerToConcentrated migrates a user's non-superfluid locked or unlocking balancer position to an unlocking concentrated liquidity position. // The function force unlocks and exits the balancer pool, creates a full range concentrated liquidity position, locks it, and begins unlocking from where the locked or unlocking lock left off. -// If there are any remaining gamm shares, they are re-locked. The function returns the concentrated liquidity position ID, amounts of tokens in the position, the liquidity amount, join time, and IDs of the involved pools and locks. +// If there are any remaining gamm shares, they are re-locked back in the gamm pool. The function returns the concentrated liquidity position ID, amounts of tokens in the position, +// the liquidity amount, join time, and IDs of the involved pools and locks. func (k Keeper) migrateNonSuperfluidLockBalancerToConcentrated(ctx sdk.Context, sender sdk.AccAddress, - poolIdLeaving, poolIdEntering uint64, - preMigrationLock *lockuptypes.PeriodLock, + lockId uint64, sharesToMigrate sdk.Coin, - concentratedPool cltypes.ConcentratedPoolExtension, - remainingLockTime time.Duration, tokenOutMins sdk.Coins, -) (positionId uint64, amount0, amount1 sdk.Int, liquidity sdk.Dec, joinTime time.Time, gammLockId, concentratedLockId uint64, err error) { - // Save unlocking state of lock before force unlocking - wasUnlocking := preMigrationLock.IsUnlocking() - - gammSharesInLock := preMigrationLock.Coins[0] +) (positionId uint64, amount0, amount1 sdk.Int, liquidity sdk.Dec, joinTime time.Time, concentratedLockId, poolIdLeaving, poolIdEntering uint64, err error) { + poolIdLeaving, poolIdEntering, preMigrationLock, remainingLockTime, err := k.validateMigration(ctx, sender, lockId, sharesToMigrate) + if err != nil { + return 0, sdk.Int{}, sdk.Int{}, sdk.Dec{}, time.Time{}, 0, 0, 0, err + } // Force unlock, validate the provided sharesToMigrate, and exit the balancer pool. // This will return the coins that will be used to create the concentrated liquidity position. - exitCoins, err := k.validateSharesToMigrateUnlockAndExitBalancerPool(ctx, sender, poolIdLeaving, preMigrationLock, sharesToMigrate, tokenOutMins) + // It also returns the lock object that contains the remaining shares that were not used in this migration. + exitCoins, err := k.validateSharesToMigrateUnlockAndExitBalancerPool(ctx, sender, poolIdLeaving, preMigrationLock, sharesToMigrate, tokenOutMins, remainingLockTime) if err != nil { - return 0, sdk.Int{}, sdk.Int{}, sdk.Dec{}, time.Time{}, 0, 0, err + return 0, sdk.Int{}, sdk.Int{}, sdk.Dec{}, time.Time{}, 0, 0, 0, err } // Create a new lock that is unlocking for the remaining time of the old lock. // Regardless of the previous lock's status, we create a new lock that is unlocking. + // This is because locking without superfluid is pointless in the context of concentrated liquidity. positionId, amount0, amount1, liquidity, joinTime, concentratedLockId, err = k.clk.CreateFullRangePositionUnlocking(ctx, poolIdEntering, sender, exitCoins, remainingLockTime) if err != nil { - return 0, sdk.Int{}, sdk.Int{}, sdk.Dec{}, time.Time{}, 0, 0, err + return 0, sdk.Int{}, sdk.Int{}, sdk.Dec{}, time.Time{}, 0, 0, 0, err } - // If there are remaining gamm shares, we must re-lock them. - remainingGammShares := gammSharesInLock.Sub(sharesToMigrate) - if !remainingGammShares.IsZero() { - // Create a new lock with the remaining gamm shares for the remaining lock time. - newLock, err := k.lk.CreateLock(ctx, sender, sdk.NewCoins(remainingGammShares), remainingLockTime) - gammLockId = newLock.ID - if err != nil { - return 0, sdk.Int{}, sdk.Int{}, sdk.Dec{}, time.Time{}, 0, 0, err - } + return positionId, amount0, amount1, liquidity, joinTime, concentratedLockId, poolIdLeaving, poolIdEntering, nil +} - // If the gamm lock was unlocking, we begin the unlock from where it left off. - if wasUnlocking { - _, err := k.lk.BeginForceUnlock(ctx, newLock.ID, newLock.Coins) - if err != nil { - return 0, sdk.Int{}, sdk.Int{}, sdk.Dec{}, time.Time{}, 0, 0, err - } - } +// routeMigration determines the status of the provided lock which is used to determine the method for migration. +// It also returns the underlying synthetic locks of the provided lock, if any exist. +func (k Keeper) routeMigration(ctx sdk.Context, sender sdk.AccAddress, lockId uint64, sharesToMigrate sdk.Coin) (synthLocksBeforeMigration []lockuptypes.SyntheticLock, migrationType MigrationType, err error) { + synthLocksBeforeMigration = k.lk.GetAllSyntheticLockupsByLockup(ctx, lockId) + if len(synthLocksBeforeMigration) > 1 { + return nil, Unsupported, fmt.Errorf("lock %d contains more than one synthetic lock", lockId) + } + + if len(synthLocksBeforeMigration) == 0 { + migrationType = NonSuperfluid + } else if strings.Contains(synthLocksBeforeMigration[0].SynthDenom, "superbonding") { + migrationType = SuperfluidBonded + } else if strings.Contains(synthLocksBeforeMigration[0].SynthDenom, "superunbonding") { + migrationType = SuperfluidUnbonding + } else { + return nil, Unsupported, fmt.Errorf("lock %d contains an unsupported synthetic lock", lockId) } - return positionId, amount0, amount1, liquidity, joinTime, gammLockId, concentratedLockId, nil + return synthLocksBeforeMigration, migrationType, nil } -// prepareMigration prepares for the migration of gamm LP tokens from the Balancer pool to the Concentrated pool. It performs the following steps: +// validateMigration performs validation for the migration of gamm LP tokens from a Balancer pool to the canonical Concentrated pool. It performs the following steps: // // 1. Gets the pool ID of the Balancer pool from the gamm share denomination. // 2. Ensures a governance-sanctioned link exists between the Balancer pool and the Concentrated pool. -// 3. Validates that the lock corresponds to the sender, contains the correct denomination of LP shares, and retrieves the gamm shares from the lock. +// 3. Validates that the provided lock corresponds to the sender and contains the correct denomination of LP shares. // 4. Determines the remaining time on the lock. -// 5. Checks if the lock has a corresponding synthetic lock, indicating it is superfluid delegated or undelegating. // // The function returns the following values: // // poolIdLeaving: The ID of the balancer pool being migrated from. // poolIdEntering: The ID of the concentrated pool being migrated to. -// gammSharesInLock: The GAMM shares contained in the lock. -// concentratedPool: The concentrated pool that will be entered. // preMigrationLock: The original lock before migration. // remainingLockTime: The remaining time on the lock before it expires. -// synthLockBeforeMigration: The synthetic lock associated with the lock before migration, if any. -// isSuperfluidBonded: A boolean indicating if the lock is superfluid delegated. -// isSuperfluidUnbonding: A boolean indicating if the lock is superfluid undelegating. // err: An error, if any occurred. -func (k Keeper) prepareMigration(ctx sdk.Context, sender sdk.AccAddress, lockId uint64, sharesToMigrate sdk.Coin) (poolIdLeaving, poolIdEntering uint64, concentratedPool cltypes.ConcentratedPoolExtension, preMigrationLock *lockuptypes.PeriodLock, remainingLockTime time.Duration, synthLockBeforeMigration []lockuptypes.SyntheticLock, isSuperfluidBonded, isSuperfluidUnbonding bool, err error) { - // Get the balancer poolId by parsing the gamm share denom. - poolIdLeaving = gammtypes.MustGetPoolIdFromShareDenom(sharesToMigrate.Denom) +func (k Keeper) validateMigration(ctx sdk.Context, sender sdk.AccAddress, lockId uint64, sharesToMigrate sdk.Coin) (poolIdLeaving, poolIdEntering uint64, preMigrationLock *lockuptypes.PeriodLock, remainingLockTime time.Duration, err error) { + // Defense in depth, ensuring the sharesToMigrate contains gamm pool share prefix. + if !strings.HasPrefix(sharesToMigrate.Denom, gammtypes.GAMMTokenPrefix) { + return 0, 0, &lockuptypes.PeriodLock{}, 0, types.SharesToMigrateDenomPrefixError{Denom: sharesToMigrate.Denom, ExpectedDenomPrefix: gammtypes.GAMMTokenPrefix} + } - // Ensure a governance sanctioned link exists between the balancer pool and the concentrated pool. - poolIdEntering, err = k.gk.GetLinkedConcentratedPoolID(ctx, poolIdLeaving) + // Get the balancer poolId by parsing the gamm share denom. + poolIdLeaving, err = gammtypes.GetPoolIdFromShareDenom(sharesToMigrate.Denom) if err != nil { - return 0, 0, nil, &lockuptypes.PeriodLock{}, 0, nil, false, false, err + return 0, 0, &lockuptypes.PeriodLock{}, 0, err } - // Get the concentrated pool from the provided ID and type cast it to ConcentratedPoolExtension. - concentratedPool, err = k.clk.GetConcentratedPoolById(ctx, poolIdEntering) + // Ensure a governance sanctioned link exists between the balancer pool and a concentrated pool. + poolIdEntering, err = k.gk.GetLinkedConcentratedPoolID(ctx, poolIdLeaving) if err != nil { - return 0, 0, nil, &lockuptypes.PeriodLock{}, 0, nil, false, false, err + return 0, 0, &lockuptypes.PeriodLock{}, 0, err } - // Check that lockID corresponds to sender, and contains correct denomination of LP shares. + // Check that lockID corresponds to sender and that the denomination of LP shares corresponds to the poolId. preMigrationLock, err = k.validateGammLockForSuperfluidStaking(ctx, sender, poolIdLeaving, lockId) if err != nil { - return 0, 0, nil, &lockuptypes.PeriodLock{}, 0, nil, false, false, err + return 0, 0, &lockuptypes.PeriodLock{}, 0, err } // Before we break the lock, we must note the time remaining on the lock. remainingLockTime, err = k.getExistingLockRemainingDuration(ctx, preMigrationLock) if err != nil { - return 0, 0, nil, &lockuptypes.PeriodLock{}, 0, nil, false, false, err + return 0, 0, &lockuptypes.PeriodLock{}, 0, err } - // Check if the lock has a corresponding synthetic lock. - // Synthetic lock existence implies that the lock is superfluid delegated or undelegating. - synthLockBeforeMigration = k.lk.GetAllSyntheticLockupsByLockup(ctx, lockId) - - isSuperfluidBonded = len(synthLockBeforeMigration) > 0 && strings.Contains(synthLockBeforeMigration[0].SynthDenom, "superbonding") - isSuperfluidUnbonding = len(synthLockBeforeMigration) > 0 && strings.Contains(synthLockBeforeMigration[0].SynthDenom, "superunbonding") - if isSuperfluidBonded && isSuperfluidUnbonding { - // This should never happen, but if it does, we don't support it. - return 0, 0, nil, &lockuptypes.PeriodLock{}, 0, nil, false, false, fmt.Errorf("synthetic lock %d must be either superfluid delegated or superfluid undelegating, not both", lockId) - } - - return poolIdLeaving, poolIdEntering, concentratedPool, preMigrationLock, remainingLockTime, synthLockBeforeMigration, isSuperfluidBonded, isSuperfluidUnbonding, nil + return poolIdLeaving, poolIdEntering, preMigrationLock, remainingLockTime, nil } // validateSharesToMigrateUnlockAndExitBalancerPool validates the unlocking and exiting of gamm LP tokens from the Balancer pool. It performs the following steps: @@ -318,14 +280,9 @@ func (k Keeper) prepareMigration(ctx sdk.Context, sender sdk.AccAddress, lockId // 3. Ensures that the number of shares to migrate is less than or equal to the number of shares in the lock. // 4. Exits the position in the Balancer pool. // 5. Ensures that exactly two coins are returned. -func (k Keeper) validateSharesToMigrateUnlockAndExitBalancerPool(ctx sdk.Context, sender sdk.AccAddress, poolIdLeaving uint64, lock *lockuptypes.PeriodLock, sharesToMigrate sdk.Coin, tokenOutMins sdk.Coins) (exitCoins sdk.Coins, err error) { - // Finish unlocking directly for locked or unlocking locks - // This also breaks and deletes associated synthetic locks. - err = k.lk.ForceUnlock(ctx, *lock) - if err != nil { - return sdk.Coins{}, err - } - +// 6. Any remaining shares that were not migrated are re-locked as a new lock for the remaining time on the lock. +func (k Keeper) validateSharesToMigrateUnlockAndExitBalancerPool(ctx sdk.Context, sender sdk.AccAddress, poolIdLeaving uint64, lock *lockuptypes.PeriodLock, sharesToMigrate sdk.Coin, tokenOutMins sdk.Coins, remainingLockTime time.Duration) (exitCoins sdk.Coins, err error) { + // validateMigration ensures that the preMigrationLock contains coins of length 1. gammSharesInLock := lock.Coins[0] // If shares to migrate is not specified, we migrate all shares. @@ -335,7 +292,24 @@ func (k Keeper) validateSharesToMigrateUnlockAndExitBalancerPool(ctx sdk.Context // Otherwise, we must ensure that the shares to migrate is less than or equal to the shares in the lock. if sharesToMigrate.Amount.GT(gammSharesInLock.Amount) { - return sdk.Coins{}, fmt.Errorf("shares to migrate must be less than or equal to shares in lock") + return sdk.Coins{}, types.MigrateMoreSharesThanLockHasError{SharesToMigrate: sharesToMigrate.Amount.String(), SharesInLock: gammSharesInLock.Amount.String()} + } + + // Finish unlocking directly for locked or unlocking locks + if sharesToMigrate.Equal(gammSharesInLock) { + // If migrating the entire lock, force unlock. + // This breaks and deletes associated synthetic locks. + err = k.lk.ForceUnlock(ctx, *lock) + if err != nil { + return sdk.Coins{}, err + } + } else { + // Otherwise, we must split the lock and force unlock the partial shares to migrate. + // This breaks and deletes associated synthetic locks. + err = k.lk.PartialForceUnlock(ctx, *lock, sdk.NewCoins(sharesToMigrate)) + if err != nil { + return sdk.Coins{}, err + } } // Exit the balancer pool position. @@ -346,7 +320,8 @@ func (k Keeper) validateSharesToMigrateUnlockAndExitBalancerPool(ctx sdk.Context // Defense in depth, ensuring we are returning exactly two coins. if len(exitCoins) != 2 { - return sdk.Coins{}, fmt.Errorf("Balancer pool must have exactly two tokens") + return sdk.Coins{}, types.TwoTokenBalancerPoolError{NumberOfTokens: len(exitCoins)} } + return exitCoins, nil } diff --git a/x/superfluid/keeper/migrate_test.go b/x/superfluid/keeper/migrate_test.go index f4f66899962..e5fd70a5c98 100644 --- a/x/superfluid/keeper/migrate_test.go +++ b/x/superfluid/keeper/migrate_test.go @@ -53,15 +53,28 @@ func (s *KeeperTestSuite) TestRouteLockedBalancerToConcentratedMigration() { "lock that is superfluid delegated, not unlocking (partial shares)": { // migrateSuperfluidBondedBalancerToConcentrated superfluidDelegated: true, - percentOfSharesToMigrate: sdk.MustNewDecFromStr("0.5"), + percentOfSharesToMigrate: sdk.MustNewDecFromStr("0.4"), }, - "lock that is superfluid undelegating, not unlocking": { + "lock that is superfluid undelegating, not unlocking (full shares)": { // migrateSuperfluidUnbondingBalancerToConcentrated superfluidDelegated: true, superfluidUndelegating: true, - percentOfSharesToMigrate: sdk.MustNewDecFromStr("0.5"), + percentOfSharesToMigrate: sdk.MustNewDecFromStr("1"), + }, + "lock that is superfluid undelegating, not unlocking (partial shares)": { + // migrateSuperfluidUnbondingBalancerToConcentrated + superfluidDelegated: true, + superfluidUndelegating: true, + percentOfSharesToMigrate: sdk.MustNewDecFromStr("0.4"), }, - "lock that is superfluid undelegating, unlocking": { + "lock that is superfluid undelegating, unlocking (full shares)": { + // migrateSuperfluidUnbondingBalancerToConcentrated + superfluidDelegated: true, + superfluidUndelegating: true, + unlocking: true, + percentOfSharesToMigrate: sdk.MustNewDecFromStr("1"), + }, + "lock that is superfluid undelegating, unlocking (partial shares)": { // migrateSuperfluidUnbondingBalancerToConcentrated superfluidDelegated: true, superfluidUndelegating: true, @@ -141,8 +154,10 @@ func (s *KeeperTestSuite) TestRouteLockedBalancerToConcentratedMigration() { originalGammLockId = originalGammLockId + 1 } + balancerDelegationPre, _ := stakingKeeper.GetDelegation(ctx, balancerIntermediaryAcc.GetAccAddress(), valAddr) + // Run the migration logic. - positionId, amount0, amount1, _, _, poolIdLeaving, poolIdEntering, newGammLockId, concentratedLockId, err := superfluidKeeper.RouteLockedBalancerToConcentratedMigration(ctx, poolJoinAcc, originalGammLockId, coinsToMigrate, tc.minExitCoins) + positionId, amount0, amount1, _, _, poolIdLeaving, poolIdEntering, concentratedLockId, err := superfluidKeeper.RouteLockedBalancerToConcentratedMigration(ctx, poolJoinAcc, originalGammLockId, coinsToMigrate, tc.minExitCoins) if tc.expectedError != nil { s.Require().Error(err) s.Require().ErrorIs(err, tc.expectedError) @@ -153,7 +168,7 @@ func (s *KeeperTestSuite) TestRouteLockedBalancerToConcentratedMigration() { s.ValidateMigrateResult( ctx, - positionId, balancerPooId, poolIdLeaving, clPoolId, poolIdEntering, originalGammLockId, newGammLockId, + positionId, balancerPooId, poolIdLeaving, clPoolId, poolIdEntering, tc.percentOfSharesToMigrate, *balancerLock, joinPoolAmt, @@ -161,26 +176,73 @@ func (s *KeeperTestSuite) TestRouteLockedBalancerToConcentratedMigration() { amount0, amount1, ) - // Additional checks if the original gamm lock was superfluid staked. - if tc.superfluidDelegated { - // Check if migration deleted intermediary account connection. + // If the lock was superfluid delegated: + if tc.superfluidDelegated && !tc.superfluidUndelegating { + if tc.percentOfSharesToMigrate.Equal(sdk.OneDec()) { + // If we migrated all the shares: + + // The intermediary account connection to the old gamm lock should be deleted. + addr := superfluidKeeper.GetLockIdIntermediaryAccountConnection(ctx, originalGammLockId) + s.Require().Equal(addr.String(), "") + + // The synthetic lockup should be deleted. + _, err = lockupKeeper.GetSyntheticLockup(ctx, originalGammLockId, keeper.StakingSyntheticDenom(balancerLock.Coins[0].Denom, valAddr.String())) + s.Require().Error(err) + + // The delegation from the balancer intermediary account holder should not exist. + delegation, found := stakingKeeper.GetDelegation(ctx, balancerIntermediaryAcc.GetAccAddress(), valAddr) + s.Require().False(found, "expected no delegation, found delegation w/ %d shares", delegation.Shares) + + // Check that the original gamm lockup is deleted. + _, err := s.App.LockupKeeper.GetLockByID(ctx, originalGammLockId) + s.Require().Error(err) + } else if tc.percentOfSharesToMigrate.LT(sdk.OneDec()) { + // If we migrated part of the shares: + // The intermediary account connection to the old gamm lock should still be present. + addr := superfluidKeeper.GetLockIdIntermediaryAccountConnection(ctx, originalGammLockId) + s.Require().Equal(balancerIntermediaryAcc.GetAccAddress().String(), addr.String()) + + // Check if migration deleted synthetic lockup. + _, err = lockupKeeper.GetSyntheticLockup(ctx, originalGammLockId, keeper.StakingSyntheticDenom(balancerLock.Coins[0].Denom, valAddr.String())) + s.Require().NoError(err) + + // The delegation from the balancer intermediary account holder should still exist. + delegation, found := stakingKeeper.GetDelegation(ctx, balancerIntermediaryAcc.GetAccAddress(), valAddr) + s.Require().True(found, "expected delegation, found delegation no delegation") + s.Require().Equal(balancerDelegationPre.Shares.Sub(balancerDelegationPre.Shares.Mul(tc.percentOfSharesToMigrate)).RoundInt().String(), delegation.Shares.RoundInt().String(), "expected %d shares, found %d shares", balancerDelegationPre.Shares.Mul(tc.percentOfSharesToMigrate).RoundInt().String(), delegation.Shares.String()) + + // Check what is remaining in the original gamm lock. + lock, err := s.App.LockupKeeper.GetLockByID(ctx, originalGammLockId) + s.Require().NoError(err) + s.Require().Equal(balancerPoolShareOut.Amount.Sub(coinsToMigrate.Amount).String(), lock.Coins[0].Amount.String(), "expected %s shares, found %s shares", lock.Coins[0].Amount.String(), balancerPoolShareOut.Amount.Sub(coinsToMigrate.Amount).String()) + } + // Check the new superfluid staked amount. + clIntermediaryAcc := superfluidKeeper.GetLockIdIntermediaryAccountConnection(ctx, concentratedLockId) + delegation, found := stakingKeeper.GetDelegation(ctx, clIntermediaryAcc, valAddr) + s.Require().True(found, "expected delegation, found delegation no delegation") + s.Require().Equal(balancerDelegationPre.Shares.Mul(tc.percentOfSharesToMigrate).RoundInt().Sub(sdk.OneInt()).String(), delegation.Shares.RoundInt().String(), "expected %d shares, found %d shares", balancerDelegationPre.Shares.Mul(tc.percentOfSharesToMigrate).RoundInt().String(), delegation.Shares.String()) + } + + // If the lock was superfluid undelegating: + if tc.superfluidDelegated && tc.superfluidUndelegating { + // Regardless oh how many shares we migrated: + + // The intermediary account connection to the old gamm lock should be deleted. addr := superfluidKeeper.GetLockIdIntermediaryAccountConnection(ctx, originalGammLockId) s.Require().Equal(addr.String(), "") - // Check if migration deleted synthetic lockup. + // The synthetic lockup should be deleted. _, err = lockupKeeper.GetSyntheticLockup(ctx, originalGammLockId, keeper.StakingSyntheticDenom(balancerLock.Coins[0].Denom, valAddr.String())) s.Require().Error(err) - // If a new gamm position was not created and restaked, check if delegation has reduced from intermediary account. - if tc.percentOfSharesToMigrate.Equal(sdk.OneDec()) { - delegation, found := stakingKeeper.GetDelegation(ctx, balancerIntermediaryAcc.GetAccAddress(), valAddr) - s.Require().False(found, "expected no delegation, found delegation w/ %d shares", delegation.Shares) - } + // The delegation from the intermediary account holder does not exist. + delegation, found := stakingKeeper.GetDelegation(ctx, balancerIntermediaryAcc.GetAccAddress(), valAddr) + s.Require().False(found, "expected no delegation, found delegation w/ %d shares", delegation.Shares) } // Run slashing logic if the test case is superfluid staked or superfluid undelegating and check if the new and old locks are slashed. slashExpected := tc.superfluidDelegated || tc.superfluidUndelegating - s.SlashAndValidateResult(ctx, newGammLockId, concentratedLockId, poolIdEntering, tc.percentOfSharesToMigrate, valAddr, *balancerLock, slashExpected) + s.SlashAndValidateResult(ctx, originalGammLockId, concentratedLockId, poolIdEntering, tc.percentOfSharesToMigrate, valAddr, *balancerLock, slashExpected) }) } } @@ -190,8 +252,6 @@ func (s *KeeperTestSuite) TestMigrateSuperfluidBondedBalancerToConcentrated() { type sendTest struct { overwriteValidatorAddress bool overwriteLockId bool - overwriteShares bool - overwritePool bool percentOfSharesToMigrate sdk.Dec tokenOutMins sdk.Coins expectedError error @@ -203,6 +263,10 @@ func (s *KeeperTestSuite) TestMigrateSuperfluidBondedBalancerToConcentrated() { "lock that is superfluid delegated, not unlocking (partial shares)": { percentOfSharesToMigrate: sdk.MustNewDecFromStr("0.5"), }, + "error: migrate more shares than lock has": { + percentOfSharesToMigrate: sdk.MustNewDecFromStr("1.1"), + expectedError: types.MigrateMoreSharesThanLockHasError{SharesToMigrate: "55000000000000000000", SharesInLock: "50000000000000000000"}, + }, "error: invalid validator address": { overwriteValidatorAddress: true, percentOfSharesToMigrate: sdk.MustNewDecFromStr("1"), @@ -213,16 +277,6 @@ func (s *KeeperTestSuite) TestMigrateSuperfluidBondedBalancerToConcentrated() { percentOfSharesToMigrate: sdk.MustNewDecFromStr("1"), expectedError: lockuptypes.ErrLockupNotFound, }, - "error: attempt to migrate more shares than the lock has": { - overwriteShares: true, - percentOfSharesToMigrate: sdk.MustNewDecFromStr("1"), - expectedError: fmt.Errorf("shares to migrate must be less than or equal to shares in lock"), - }, - "error: pool has more than two assets": { - overwritePool: true, - percentOfSharesToMigrate: sdk.MustNewDecFromStr("1"), - expectedError: fmt.Errorf("Balancer pool must have exactly two tokens"), - }, "error: lock that is superfluid delegated, not unlocking (full shares), token out mins is more than exit coins": { percentOfSharesToMigrate: sdk.MustNewDecFromStr("1"), tokenOutMins: sdk.NewCoins(sdk.NewCoin("foo", sdk.NewInt(100000))), @@ -247,9 +301,11 @@ func (s *KeeperTestSuite) TestMigrateSuperfluidBondedBalancerToConcentrated() { coinsToMigrate := balancerPoolShareOut coinsToMigrate.Amount = coinsToMigrate.Amount.ToDec().Mul(tc.percentOfSharesToMigrate).RoundInt() - // PrepareMigration is called via the migration message router and is always run prior to the migration itself - poolIdLeaving, poolIdEntering, concentratedPool, preMigrationLock, remainingLockTime, synthLockBeforeMigration, _, _, err := superfluidKeeper.PrepareMigration(ctx, poolJoinAcc, originalGammLockId, coinsToMigrate) + // RouteMigration is called via the migration message router and is always run prior to the migration itself. + // We use it here just to retrieve the synthetic lock before the migration. + synthLockBeforeMigration, migrationType, err := superfluidKeeper.RouteMigration(ctx, poolJoinAcc, originalGammLockId, coinsToMigrate) s.Require().NoError(err) + s.Require().Equal(migrationType, keeper.SuperfluidBonded) // Modify migration inputs if necessary @@ -264,20 +320,10 @@ func (s *KeeperTestSuite) TestMigrateSuperfluidBondedBalancerToConcentrated() { originalGammLockId = originalGammLockId + 1 } - if tc.overwriteShares { - coinsToMigrate.Amount = preMigrationLock.Coins[0].Amount.Add(sdk.NewInt(1)) - } - - if tc.overwritePool { - multiCoinBalancerPoolId := s.PrepareBalancerPool() - poolIdLeaving = multiCoinBalancerPoolId - shareAmt := sdk.MustNewDecFromStr("50000000000000000000").TruncateInt() - newShares := sdk.NewCoin(fmt.Sprintf("gamm/pool/%d", multiCoinBalancerPoolId), shareAmt) - s.FundAcc(poolJoinAcc, sdk.NewCoins(newShares)) - } + balancerDelegationPre, _ := stakingKeeper.GetDelegation(ctx, balancerIntermediaryAcc.GetAccAddress(), valAddr) // System under test. - positionId, amount0, amount1, _, _, newGammLockId, concentratedLockId, err := superfluidKeeper.MigrateSuperfluidBondedBalancerToConcentrated(ctx, poolJoinAcc, poolIdLeaving, poolIdEntering, preMigrationLock, originalGammLockId, coinsToMigrate, synthLockBeforeMigration[0].SynthDenom, concentratedPool, remainingLockTime, tc.tokenOutMins) + positionId, amount0, amount1, liquidityMigrated, _, concentratedLockId, poolIdLeaving, poolIdEntering, err := superfluidKeeper.MigrateSuperfluidBondedBalancerToConcentrated(ctx, poolJoinAcc, originalGammLockId, coinsToMigrate, synthLockBeforeMigration[0].SynthDenom, tc.tokenOutMins) if tc.expectedError != nil { s.Require().Error(err) s.Require().ErrorContains(err, tc.expectedError.Error()) @@ -288,7 +334,7 @@ func (s *KeeperTestSuite) TestMigrateSuperfluidBondedBalancerToConcentrated() { s.ValidateMigrateResult( ctx, - positionId, balancerPooId, poolIdLeaving, clPoolId, poolIdEntering, originalGammLockId, newGammLockId, + positionId, balancerPooId, poolIdLeaving, clPoolId, poolIdEntering, tc.percentOfSharesToMigrate, *balancerLock, joinPoolAmt, @@ -296,32 +342,70 @@ func (s *KeeperTestSuite) TestMigrateSuperfluidBondedBalancerToConcentrated() { amount0, amount1, ) - // Check if migration deleted intermediary account connection. - originalGammIntermediaryAccount := superfluidKeeper.GetLockIdIntermediaryAccountConnection(ctx, originalGammLockId) - s.Require().Equal(originalGammIntermediaryAccount.String(), "") + if tc.percentOfSharesToMigrate.Equal(sdk.OneDec()) { + // If we migrated all the shares: + + // The intermediary account connection to the old gamm lock should be deleted. + addr := superfluidKeeper.GetLockIdIntermediaryAccountConnection(ctx, originalGammLockId) + s.Require().Equal(addr.String(), "") + + // The synthetic lockup should be deleted. + _, err = lockupKeeper.GetSyntheticLockup(ctx, originalGammLockId, keeper.StakingSyntheticDenom(balancerLock.Coins[0].Denom, valAddr.String())) + s.Require().Error(err) + + // The delegation from the intermediary account holder should not exist. + delegation, found := stakingKeeper.GetDelegation(ctx, balancerIntermediaryAcc.GetAccAddress(), valAddr) + s.Require().False(found, "expected no delegation, found delegation w/ %d shares", delegation.Shares) + + // Check that the original gamm lockup is deleted. + _, err := s.App.LockupKeeper.GetLockByID(ctx, originalGammLockId) + s.Require().Error(err) + } else if tc.percentOfSharesToMigrate.LT(sdk.OneDec()) { + // If we migrated part of the shares: + // The intermediary account connection to the old gamm lock should still be present. + addr := superfluidKeeper.GetLockIdIntermediaryAccountConnection(ctx, originalGammLockId) + s.Require().Equal(balancerIntermediaryAcc.GetAccAddress().String(), addr.String()) + + // Confirm that migration did not delete synthetic lockup. + gammSynthLock, err := lockupKeeper.GetSyntheticLockup(ctx, originalGammLockId, keeper.StakingSyntheticDenom(balancerLock.Coins[0].Denom, valAddr.String())) + s.Require().NoError(err) - // Check if migration deleted synthetic lockup. - _, err = lockupKeeper.GetSyntheticLockup(ctx, originalGammLockId, keeper.StakingSyntheticDenom(balancerLock.Coins[0].Denom, valAddr.String())) - s.Require().Error(err) + s.Require().Equal(originalGammLockId, gammSynthLock.UnderlyingLockId) + + // The delegation from the intermediary account holder should still exist. + _, found := stakingKeeper.GetDelegation(ctx, balancerIntermediaryAcc.GetAccAddress(), valAddr) + s.Require().True(found, "expected delegation, found delegation no delegation") + + // Check what is remaining in the original gamm lock. + lock, err := s.App.LockupKeeper.GetLockByID(ctx, originalGammLockId) + s.Require().NoError(err) + s.Require().Equal(balancerPoolShareOut.Amount.Sub(coinsToMigrate.Amount).String(), lock.Coins[0].Amount.String(), "expected %s shares, found %s shares", lock.Coins[0].Amount.String(), balancerPoolShareOut.Amount.Sub(coinsToMigrate.Amount).String()) + } + // Check the new superfluid staked amount. + clIntermediaryAcc := superfluidKeeper.GetLockIdIntermediaryAccountConnection(ctx, concentratedLockId) + delegation, found := stakingKeeper.GetDelegation(ctx, clIntermediaryAcc, valAddr) + s.Require().True(found, "expected delegation, found delegation no delegation") + s.Require().Equal(balancerDelegationPre.Shares.Mul(tc.percentOfSharesToMigrate).RoundInt().Sub(sdk.OneInt()).String(), delegation.Shares.RoundInt().String(), "expected %d shares, found %d shares", balancerDelegationPre.Shares.Mul(tc.percentOfSharesToMigrate).RoundInt().String(), delegation.Shares.String()) // Check if the new intermediary account connection was created. newConcentratedIntermediaryAccount := superfluidKeeper.GetLockIdIntermediaryAccountConnection(ctx, concentratedLockId) s.Require().NotEqual(newConcentratedIntermediaryAccount.String(), "") - // Check if the new synthetic bonded lockup was created. + // Check newly created concentrated lock. concentratedLock, err := lockupKeeper.GetLockByID(ctx, concentratedLockId) s.Require().NoError(err) - _, err = lockupKeeper.GetSyntheticLockup(ctx, concentratedLockId, keeper.StakingSyntheticDenom(concentratedLock.Coins[0].Denom, valAddr.String())) + s.Require().Equal(liquidityMigrated.TruncateInt().String(), concentratedLock.Coins[0].Amount.String(), "expected %s shares, found %s shares", coinsToMigrate.Amount.String(), concentratedLock.Coins[0].Amount.String()) + s.Require().Equal(balancerLock.Duration, concentratedLock.Duration) + s.Require().Equal(balancerLock.EndTime, concentratedLock.EndTime) + + // Check if the new synthetic bonded lockup was created. + clSynthLock, err := lockupKeeper.GetSyntheticLockup(ctx, concentratedLockId, keeper.StakingSyntheticDenom(concentratedLock.Coins[0].Denom, valAddr.String())) s.Require().NoError(err) - // If a new gamm position was not created and restaked, check if delegation has reduced from intermediary account. - if tc.percentOfSharesToMigrate.Equal(sdk.OneDec()) { - delegation, found := stakingKeeper.GetDelegation(ctx, balancerIntermediaryAcc.GetAccAddress(), valAddr) - s.Require().False(found, "expected no delegation, found delegation w/ %d shares", delegation.Shares) - } + s.Require().Equal(concentratedLockId, clSynthLock.UnderlyingLockId) // Run slashing logic and check if the new and old locks are slashed. - s.SlashAndValidateResult(ctx, newGammLockId, concentratedLockId, poolIdEntering, tc.percentOfSharesToMigrate, valAddr, *balancerLock, true) + s.SlashAndValidateResult(ctx, originalGammLockId, concentratedLockId, clPoolId, tc.percentOfSharesToMigrate, valAddr, *balancerLock, true) }) } } @@ -331,9 +415,6 @@ func (s *KeeperTestSuite) TestMigrateSuperfluidUnbondingBalancerToConcentrated() type sendTest struct { unlocking bool overwriteValidatorAddress bool - overwritePreMigrationLock bool - overwriteShares bool - overwritePool bool percentOfSharesToMigrate sdk.Dec tokenOutMins sdk.Coins expectedError error @@ -358,21 +439,6 @@ func (s *KeeperTestSuite) TestMigrateSuperfluidUnbondingBalancerToConcentrated() percentOfSharesToMigrate: sdk.MustNewDecFromStr("1"), expectedError: fmt.Errorf("decoding bech32 failed: invalid checksum"), }, - "error: non-existent pre migration lock": { - overwritePreMigrationLock: true, - percentOfSharesToMigrate: sdk.MustNewDecFromStr("1"), - expectedError: lockuptypes.ErrLockupNotFound, - }, - "error: attempt to migrate more shares than the lock has": { - overwriteShares: true, - percentOfSharesToMigrate: sdk.MustNewDecFromStr("1"), - expectedError: fmt.Errorf("shares to migrate must be less than or equal to shares in lock"), - }, - "error: pool has more than two assets": { - overwritePool: true, - percentOfSharesToMigrate: sdk.MustNewDecFromStr("1"), - expectedError: fmt.Errorf("Balancer pool must have exactly two tokens"), - }, "error: lock that is superfluid undelegating, not unlocking (full shares), token out mins is more than exit coins": { percentOfSharesToMigrate: sdk.MustNewDecFromStr("1"), tokenOutMins: sdk.NewCoins(sdk.NewCoin("foo", sdk.NewInt(100000))), @@ -396,9 +462,10 @@ func (s *KeeperTestSuite) TestMigrateSuperfluidUnbondingBalancerToConcentrated() coinsToMigrate := balancerPoolShareOut coinsToMigrate.Amount = coinsToMigrate.Amount.ToDec().Mul(tc.percentOfSharesToMigrate).RoundInt() - // PrepareMigration is called via the migration message router and is always run prior to the migration itself - poolIdLeaving, poolIdEntering, concentratedPool, preMigrationLock, remainingLockTime, synthLockBeforeMigration, _, _, err := superfluidKeeper.PrepareMigration(ctx, poolJoinAcc, originalGammLockId, coinsToMigrate) + // RouteMigration is called via the migration message router and is always run prior to the migration itself + synthLockBeforeMigration, migrationType, err := superfluidKeeper.RouteMigration(ctx, poolJoinAcc, originalGammLockId, coinsToMigrate) s.Require().NoError(err) + s.Require().Equal(migrationType, keeper.SuperfluidUnbonding) // Modify migration inputs if necessary @@ -409,24 +476,115 @@ func (s *KeeperTestSuite) TestMigrateSuperfluidUnbondingBalancerToConcentrated() synthLockBeforeMigration[0].SynthDenom = newSynthDenom } - if tc.overwritePreMigrationLock { - preMigrationLock.ID = preMigrationLock.ID + 1 + // System under test. + positionId, amount0, amount1, liquidityMigrated, _, concentratedLockId, poolIdLeaving, poolIdEntering, err := superfluidKeeper.MigrateSuperfluidUnbondingBalancerToConcentrated(ctx, poolJoinAcc, originalGammLockId, coinsToMigrate, synthLockBeforeMigration[0].SynthDenom, tc.tokenOutMins) + if tc.expectedError != nil { + s.Require().Error(err) + s.Require().ErrorContains(err, tc.expectedError.Error()) + return } + s.Require().NoError(err) + s.AssertEventEmitted(ctx, gammtypes.TypeEvtPoolExited, 1) - if tc.overwriteShares { - coinsToMigrate.Amount = preMigrationLock.Coins[0].Amount.Add(sdk.NewInt(1)) - } + s.ValidateMigrateResult( + ctx, + positionId, balancerPooId, poolIdLeaving, clPoolId, poolIdEntering, + tc.percentOfSharesToMigrate, + *balancerLock, + joinPoolAmt, + balancerPoolShareOut, coinsToMigrate, + amount0, amount1, + ) - if tc.overwritePool { - multiCoinBalancerPoolId := s.PrepareBalancerPool() - poolIdLeaving = multiCoinBalancerPoolId - shareAmt := sdk.MustNewDecFromStr("50000000000000000000").TruncateInt() - newShares := sdk.NewCoin(fmt.Sprintf("gamm/pool/%d", multiCoinBalancerPoolId), shareAmt) - s.FundAcc(poolJoinAcc, sdk.NewCoins(newShares)) + if tc.percentOfSharesToMigrate.Equal(sdk.OneDec()) { + // If we migrated all the shares: + + // The synthetic lockup should be deleted. + _, err = lockupKeeper.GetSyntheticLockup(ctx, originalGammLockId, keeper.UnstakingSyntheticDenom(balancerLock.Coins[0].Denom, valAddr.String())) + s.Require().Error(err) + } else if tc.percentOfSharesToMigrate.LT(sdk.OneDec()) { + // If we migrated part of the shares: + + // The synthetic lockup should not be deleted. + gammSynthLock, err := lockupKeeper.GetSyntheticLockup(ctx, originalGammLockId, keeper.UnstakingSyntheticDenom(balancerLock.Coins[0].Denom, valAddr.String())) + s.Require().NoError(err) + + s.Require().Equal(originalGammLockId, gammSynthLock.UnderlyingLockId) } + // Check newly created concentrated lock. + concentratedLock, err := lockupKeeper.GetLockByID(ctx, concentratedLockId) + s.Require().NoError(err) + s.Require().Equal(liquidityMigrated.TruncateInt().String(), concentratedLock.Coins[0].Amount.String(), "expected %s shares, found %s shares", coinsToMigrate.Amount.String(), concentratedLock.Coins[0].Amount.String()) + s.Require().Equal(balancerLock.Duration, concentratedLock.Duration) + s.Require().Equal(s.Ctx.BlockTime().Add(balancerLock.Duration), concentratedLock.EndTime) + + // Check if the new synthetic unbonding lockup was created. + clSynthLock, err := lockupKeeper.GetSyntheticLockup(ctx, concentratedLockId, keeper.UnstakingSyntheticDenom(concentratedLock.Coins[0].Denom, valAddr.String())) + s.Require().NoError(err) + + s.Require().Equal(concentratedLockId, clSynthLock.UnderlyingLockId) + + // Run slashing logic and check if the new and old locks are slashed. + s.SlashAndValidateResult(ctx, originalGammLockId, concentratedLockId, clPoolId, tc.percentOfSharesToMigrate, valAddr, *balancerLock, true) + }) + } +} + +func (s *KeeperTestSuite) TestMigrateNonSuperfluidLockBalancerToConcentrated() { + defaultJoinTime := s.Ctx.BlockTime() + type sendTest struct { + unlocking bool + percentOfSharesToMigrate sdk.Dec + tokenOutMins sdk.Coins + expectedError error + } + testCases := map[string]sendTest{ + "lock that is not superfluid delegated, not unlocking (full shares)": { + percentOfSharesToMigrate: sdk.MustNewDecFromStr("1"), + }, + "lock that is not superfluid delegated, not unlocking (partial shares)": { + percentOfSharesToMigrate: sdk.MustNewDecFromStr("0.9"), + }, + "lock that is not superfluid delegated, unlocking (full shares)": { + unlocking: true, + percentOfSharesToMigrate: sdk.MustNewDecFromStr("1"), + }, + "lock that is not superfluid delegated, unlocking (partial shares)": { + unlocking: true, + percentOfSharesToMigrate: sdk.MustNewDecFromStr("0.6"), + }, + "error: lock that is not superfluid delegated, not unlocking (full shares), token out mins is more than exit coins": { + percentOfSharesToMigrate: sdk.MustNewDecFromStr("1"), + tokenOutMins: sdk.NewCoins(sdk.NewCoin("foo", sdk.NewInt(10000))), + expectedError: gammtypes.ErrLimitMinAmount, + }, + } + + for name, tc := range testCases { + s.Run(name, func() { + s.SetupTest() + s.Ctx = s.Ctx.WithBlockTime(defaultJoinTime) + ctx := s.Ctx + superfluidKeeper := s.App.SuperfluidKeeper + lockupKeeper := s.App.LockupKeeper + + // We bundle all migration setup into a single function to avoid repeating the same code for each test case. + joinPoolAmt, _, balancerLock, _, poolJoinAcc, balancerPooId, clPoolId, balancerPoolShareOut, valAddr := s.SetupMigrationTest(ctx, false, false, tc.unlocking, tc.percentOfSharesToMigrate) + originalGammLockId := balancerLock.GetID() + + // Depending on the test case, we attempt to migrate a subset of the balancer LP tokens we originally created. + coinsToMigrate := balancerPoolShareOut + coinsToMigrate.Amount = coinsToMigrate.Amount.ToDec().Mul(tc.percentOfSharesToMigrate).RoundInt() + + // RouteMigration is called via the migration message router and is always run prior to the migration itself + synthLockBeforeMigration, migrationType, err := superfluidKeeper.RouteMigration(ctx, poolJoinAcc, originalGammLockId, coinsToMigrate) + s.Require().NoError(err) + s.Require().Equal(0, len(synthLockBeforeMigration)) + s.Require().Equal(migrationType, keeper.NonSuperfluid) + // System under test. - positionId, amount0, amount1, _, _, newGammLockId, concentratedLockId, err := superfluidKeeper.MigrateSuperfluidUnbondingBalancerToConcentrated(ctx, poolJoinAcc, poolIdLeaving, poolIdEntering, preMigrationLock, coinsToMigrate, synthLockBeforeMigration[0].SynthDenom, concentratedPool, remainingLockTime, tc.tokenOutMins) + positionId, amount0, amount1, liquidityMigrated, _, concentratedLockId, poolIdLeaving, poolIdEntering, err := superfluidKeeper.MigrateNonSuperfluidLockBalancerToConcentrated(ctx, poolJoinAcc, originalGammLockId, coinsToMigrate, tc.tokenOutMins) if tc.expectedError != nil { s.Require().Error(err) s.Require().ErrorContains(err, tc.expectedError.Error()) @@ -437,7 +595,7 @@ func (s *KeeperTestSuite) TestMigrateSuperfluidUnbondingBalancerToConcentrated() s.ValidateMigrateResult( ctx, - positionId, balancerPooId, poolIdLeaving, clPoolId, poolIdEntering, originalGammLockId, newGammLockId, + positionId, balancerPooId, poolIdLeaving, clPoolId, poolIdEntering, tc.percentOfSharesToMigrate, *balancerLock, joinPoolAmt, @@ -445,25 +603,29 @@ func (s *KeeperTestSuite) TestMigrateSuperfluidUnbondingBalancerToConcentrated() amount0, amount1, ) - // Check if the new synthetic unbonding lockup was created. + // Check newly created concentrated lock. concentratedLock, err := lockupKeeper.GetLockByID(ctx, concentratedLockId) s.Require().NoError(err) - _, err = lockupKeeper.GetSyntheticLockup(ctx, concentratedLockId, keeper.UnstakingSyntheticDenom(concentratedLock.Coins[0].Denom, valAddr.String())) - s.Require().NoError(err) + s.Require().Equal(liquidityMigrated.TruncateInt().String(), concentratedLock.Coins[0].Amount.String(), "expected %s shares, found %s shares", coinsToMigrate.Amount.String(), concentratedLock.Coins[0].Amount.String()) + s.Require().Equal(balancerLock.Duration, concentratedLock.Duration) + s.Require().Equal(s.Ctx.BlockTime().Add(balancerLock.Duration), concentratedLock.EndTime) - // Run slashing logic and check if the new and old locks are slashed. - s.SlashAndValidateResult(ctx, newGammLockId, concentratedLockId, poolIdEntering, tc.percentOfSharesToMigrate, valAddr, *balancerLock, true) + // Run slashing logic and check if the new and old locks are not slashed. + s.SlashAndValidateResult(ctx, originalGammLockId, concentratedLockId, clPoolId, tc.percentOfSharesToMigrate, valAddr, *balancerLock, false) }) } } -func (s *KeeperTestSuite) TestMigrateNonSuperfluidLockBalancerToConcentrated() { +func (s *KeeperTestSuite) TestValidateMigration() { defaultJoinTime := s.Ctx.BlockTime() type sendTest struct { + isSuperfluidDelegated bool + isSuperfluidUndelegating bool unlocking bool overwritePreMigrationLock bool - overwriteShares bool - overwritePool bool + overwriteSender bool + overwriteSharesDenomValue string + overwriteLockId bool percentOfSharesToMigrate sdk.Dec tokenOutMins sdk.Coins expectedError error @@ -471,37 +633,75 @@ func (s *KeeperTestSuite) TestMigrateNonSuperfluidLockBalancerToConcentrated() { testCases := map[string]sendTest{ "lock that is not superfluid delegated, not unlocking (full shares)": { percentOfSharesToMigrate: sdk.MustNewDecFromStr("1"), + isSuperfluidDelegated: false, + isSuperfluidUndelegating: false, }, "lock that is not superfluid delegated, not unlocking (partial shares)": { percentOfSharesToMigrate: sdk.MustNewDecFromStr("0.9"), + isSuperfluidDelegated: false, + isSuperfluidUndelegating: false, }, "lock that is not superfluid delegated, unlocking (full shares)": { unlocking: true, percentOfSharesToMigrate: sdk.MustNewDecFromStr("1"), + isSuperfluidDelegated: false, + isSuperfluidUndelegating: false, }, "lock that is not superfluid delegated, unlocking (partial shares)": { unlocking: true, percentOfSharesToMigrate: sdk.MustNewDecFromStr("0.6"), + isSuperfluidDelegated: false, + isSuperfluidUndelegating: false, }, - "error: non-existent pre migration lock": { - overwritePreMigrationLock: true, - percentOfSharesToMigrate: sdk.MustNewDecFromStr("1"), - expectedError: lockuptypes.ErrLockupNotFound, + "lock that is superfluid undelegating, not unlocking (full shares)": { + percentOfSharesToMigrate: sdk.MustNewDecFromStr("1"), + isSuperfluidDelegated: true, + isSuperfluidUndelegating: true, }, - "error: attempt to migrate more shares than the lock has": { - overwriteShares: true, + "lock that is superfluid undelegating, not unlocking (partial shares)": { + percentOfSharesToMigrate: sdk.MustNewDecFromStr("0.5"), + isSuperfluidDelegated: true, + isSuperfluidUndelegating: true, + }, + "lock that is superfluid undelegating, unlocking (full shares)": { + unlocking: true, percentOfSharesToMigrate: sdk.MustNewDecFromStr("1"), - expectedError: fmt.Errorf("shares to migrate must be less than or equal to shares in lock"), + isSuperfluidDelegated: true, + isSuperfluidUndelegating: true, }, - "error: pool has more than two assets": { - overwritePool: true, + "lock that is superfluid undelegating, unlocking (partial shares)": { + unlocking: true, + percentOfSharesToMigrate: sdk.MustNewDecFromStr("0.3"), + isSuperfluidDelegated: true, + isSuperfluidUndelegating: true, + }, + "lock that is superfluid delegated, not unlocking (full shares)": { percentOfSharesToMigrate: sdk.MustNewDecFromStr("1"), - expectedError: fmt.Errorf("Balancer pool must have exactly two tokens"), + isSuperfluidDelegated: true, }, - "error: lock that is not superfluid delegated, not unlocking (full shares), token out mins is more than exit coins": { + "lock that is superfluid delegated, not unlocking (partial shares)": { + percentOfSharesToMigrate: sdk.MustNewDecFromStr("0.5"), + isSuperfluidDelegated: true, + }, + "error: denom prefix error": { + overwriteSharesDenomValue: "cl/pool/2", + percentOfSharesToMigrate: sdk.MustNewDecFromStr("1"), + expectedError: types.SharesToMigrateDenomPrefixError{Denom: "cl/pool/2", ExpectedDenomPrefix: gammtypes.GAMMTokenPrefix}, + }, + "error: no canonical link": { + overwriteSharesDenomValue: "gamm/pool/2", + percentOfSharesToMigrate: sdk.MustNewDecFromStr("1"), + expectedError: gammtypes.ConcentratedPoolMigrationLinkNotFoundError{PoolIdLeaving: 2}, + }, + "error: wrong sender": { + overwriteSender: true, percentOfSharesToMigrate: sdk.MustNewDecFromStr("1"), - tokenOutMins: sdk.NewCoins(sdk.NewCoin("foo", sdk.NewInt(10000))), - expectedError: gammtypes.ErrLimitMinAmount, + expectedError: lockuptypes.ErrNotLockOwner, + }, + "error: wrong lock ID": { + overwriteLockId: true, + percentOfSharesToMigrate: sdk.MustNewDecFromStr("1"), + expectedError: lockuptypes.ErrLockupNotFound, }, } @@ -513,57 +713,40 @@ func (s *KeeperTestSuite) TestMigrateNonSuperfluidLockBalancerToConcentrated() { superfluidKeeper := s.App.SuperfluidKeeper // We bundle all migration setup into a single function to avoid repeating the same code for each test case. - joinPoolAmt, _, balancerLock, _, poolJoinAcc, balancerPooId, clPoolId, balancerPoolShareOut, valAddr := s.SetupMigrationTest(ctx, false, false, tc.unlocking, tc.percentOfSharesToMigrate) + _, _, balancerLock, _, poolJoinAcc, balancerPooId, clPoolId, balancerPoolShareOut, _ := s.SetupMigrationTest(ctx, tc.isSuperfluidDelegated, tc.isSuperfluidUndelegating, tc.unlocking, tc.percentOfSharesToMigrate) originalGammLockId := balancerLock.GetID() // Depending on the test case, we attempt to migrate a subset of the balancer LP tokens we originally created. coinsToMigrate := balancerPoolShareOut coinsToMigrate.Amount = coinsToMigrate.Amount.ToDec().Mul(tc.percentOfSharesToMigrate).RoundInt() - // PrepareMigration is called via the migration message router and is always run prior to the migration itself - poolIdLeaving, poolIdEntering, concentratedPool, preMigrationLock, remainingLockTime, _, _, _, err := superfluidKeeper.PrepareMigration(ctx, poolJoinAcc, originalGammLockId, coinsToMigrate) - s.Require().NoError(err) - // Modify migration inputs if necessary - - if tc.overwritePreMigrationLock { - preMigrationLock.ID = preMigrationLock.ID + 1 + if tc.overwriteSender { + poolJoinAcc = s.TestAccs[0] } - if tc.overwriteShares { - coinsToMigrate.Amount = preMigrationLock.Coins[0].Amount.Add(sdk.NewInt(1)) + if tc.overwriteLockId { + originalGammLockId = originalGammLockId + 10 } - if tc.overwritePool { - multiCoinBalancerPoolId := s.PrepareBalancerPool() - poolIdLeaving = multiCoinBalancerPoolId - shareAmt := sdk.MustNewDecFromStr("50000000000000000000").TruncateInt() - newShares := sdk.NewCoin(fmt.Sprintf("gamm/pool/%d", multiCoinBalancerPoolId), shareAmt) - s.FundAcc(poolJoinAcc, sdk.NewCoins(newShares)) + if tc.overwriteSharesDenomValue != "" { + coinsToMigrate.Denom = tc.overwriteSharesDenomValue } // System under test. - positionId, amount0, amount1, _, _, newGammLockId, concentratedLockId, err := superfluidKeeper.MigrateNonSuperfluidLockBalancerToConcentrated(ctx, poolJoinAcc, poolIdLeaving, poolIdEntering, preMigrationLock, coinsToMigrate, concentratedPool, remainingLockTime, tc.tokenOutMins) + poolIdLeaving, poolIdEntering, preMigrationLock, remainingLockTime, err := superfluidKeeper.ValidateMigration(ctx, poolJoinAcc, originalGammLockId, coinsToMigrate) if tc.expectedError != nil { s.Require().Error(err) s.Require().ErrorContains(err, tc.expectedError.Error()) return } - s.Require().NoError(err) - s.AssertEventEmitted(ctx, gammtypes.TypeEvtPoolExited, 1) - s.ValidateMigrateResult( - ctx, - positionId, balancerPooId, poolIdLeaving, clPoolId, poolIdEntering, originalGammLockId, newGammLockId, - tc.percentOfSharesToMigrate, - *balancerLock, - joinPoolAmt, - balancerPoolShareOut, coinsToMigrate, - amount0, amount1, - ) - - // Run slashing logic and check if the new and old locks are not slashed. - s.SlashAndValidateResult(ctx, newGammLockId, concentratedLockId, poolIdEntering, tc.percentOfSharesToMigrate, valAddr, *balancerLock, false) + s.Require().NoError(err) + s.Require().Equal(poolIdLeaving, balancerPooId) + s.Require().Equal(poolIdEntering, clPoolId) + s.Require().Equal(preMigrationLock.GetID(), originalGammLockId) + s.Require().Equal(preMigrationLock.GetCoins(), sdk.NewCoins(balancerPoolShareOut)) + s.Require().Equal(preMigrationLock.GetDuration(), remainingLockTime) }) } } @@ -594,7 +777,7 @@ func (s *KeeperTestSuite) TestValidateSharesToMigrateUnlockAndExitBalancerPool() "error: attempt to migrate more than lock has": { percentOfSharesToMigrate: sdk.MustNewDecFromStr("1"), overwriteShares: true, - expectedError: fmt.Errorf("shares to migrate must be less than or equal to shares in lock"), + expectedError: types.MigrateMoreSharesThanLockHasError{SharesToMigrate: "50000000000000000001", SharesInLock: "50000000000000000000"}, }, "error: attempt to leave a pool that does not exist": { percentOfSharesToMigrate: sdk.MustNewDecFromStr("1"), @@ -604,7 +787,7 @@ func (s *KeeperTestSuite) TestValidateSharesToMigrateUnlockAndExitBalancerPool() "error: attempt to leave a pool that has more than two denoms": { percentOfSharesToMigrate: sdk.MustNewDecFromStr("1"), overwritePool: true, - expectedError: fmt.Errorf("Balancer pool must have exactly two tokens"), + expectedError: types.TwoTokenBalancerPoolError{NumberOfTokens: 4}, }, "error: happy path (full shares), token out mins is more than exit coins": { percentOfSharesToMigrate: sdk.MustNewDecFromStr("1"), @@ -661,7 +844,7 @@ func (s *KeeperTestSuite) TestValidateSharesToMigrateUnlockAndExitBalancerPool() unbondingDuration := stakingKeeper.GetParams(ctx).UnbondingTime // Lock the LP tokens for the duration of the unbonding period. - originalGammLockId := s.LockTokens(poolJoinAcc, sdk.NewCoins(coinsToMigrate), unbondingDuration) + originalGammLockId := s.LockTokens(poolJoinAcc, sdk.NewCoins(balancerPoolShareOut), unbondingDuration) lock, err := lockupKeeper.GetLockByID(ctx, originalGammLockId) s.Require().NoError(err) @@ -687,7 +870,7 @@ func (s *KeeperTestSuite) TestValidateSharesToMigrateUnlockAndExitBalancerPool() } // System under test - exitCoins, err := superfluidKeeper.ValidateSharesToMigrateUnlockAndExitBalancerPool(ctx, poolJoinAcc, balancerPooId, lock, coinsToMigrate, tc.tokenOutMins) + exitCoins, err := superfluidKeeper.ValidateSharesToMigrateUnlockAndExitBalancerPool(ctx, poolJoinAcc, balancerPooId, lock, coinsToMigrate, tc.tokenOutMins, lock.Duration) if tc.expectedError != nil { s.Require().Error(err) s.Require().ErrorContains(err, tc.expectedError.Error()) @@ -700,6 +883,18 @@ func (s *KeeperTestSuite) TestValidateSharesToMigrateUnlockAndExitBalancerPool() RoundingDir: osmomath.RoundDown, } + if tc.percentOfSharesToMigrate.Equal(sdk.OneDec()) { + // If all of the shares were migrated, the original lock should be deleted + _, err := lockupKeeper.GetLockByID(ctx, originalGammLockId) + s.Require().Error(err) + } else { + // If only a portion of the shares were migrated, the original lock should still exist (with the remaining shares) + lock, err := lockupKeeper.GetLockByID(ctx, originalGammLockId) + s.Require().NoError(err) + expectedSharesStillInOldLock := balancerPoolShareOut.Amount.Sub(sharesToMigrate) + s.Require().Equal(expectedSharesStillInOldLock.String(), lock.Coins[0].Amount.String()) + } + for _, coin := range exitCoins { // Check that the exit coin is the same amount that we joined with (with one unit rounding down) s.Require().Equal(0, defaultErrorTolerance.Compare(tokensIn.AmountOf(coin.Denom).ToDec().Mul(tc.percentOfSharesToMigrate).RoundInt(), coin.Amount)) @@ -827,13 +1022,15 @@ func (s *KeeperTestSuite) SetupMigrationTest(ctx sdk.Context, superfluidDelegate return joinPoolAmt, balancerIntermediaryAcc, balancerLock, poolCreateAcc, poolJoinAcc, balancerPooId, clPoolId, balancerPoolShareOut, valAddr } -func (s *KeeperTestSuite) SlashAndValidateResult(ctx sdk.Context, newGammLockId, concentratedLockId, poolIdEntering uint64, percentOfSharesToMigrate sdk.Dec, valAddr sdk.ValAddress, balancerLock lockuptypes.PeriodLock, expectSlash bool) { +func (s *KeeperTestSuite) SlashAndValidateResult(ctx sdk.Context, gammLockId, concentratedLockId, poolIdEntering uint64, percentOfSharesToMigrate sdk.Dec, valAddr sdk.ValAddress, balancerLock lockuptypes.PeriodLock, expectSlash bool) { // Retrieve the concentrated lock and gamm lock prior to slashing. concentratedLockPreSlash, err := s.App.LockupKeeper.GetLockByID(s.Ctx, concentratedLockId) s.Require().NoError(err) - gammLockPreSlash, err := s.App.LockupKeeper.GetLockByID(s.Ctx, newGammLockId) - if err != nil && newGammLockId != 0 { + gammLockPreSlash, err := s.App.LockupKeeper.GetLockByID(s.Ctx, gammLockId) + if percentOfSharesToMigrate.LT(sdk.OneDec()) { s.Require().NoError(err) + } else { + s.Require().Error(err) } // Slash the validator. @@ -847,9 +1044,11 @@ func (s *KeeperTestSuite) SlashAndValidateResult(ctx sdk.Context, newGammLockId, // Retrieve the concentrated lock and gamm lock after slashing. concentratedLockPostSlash, err := s.App.LockupKeeper.GetLockByID(s.Ctx, concentratedLockId) s.Require().NoError(err) - gammLockPostSlash, err := s.App.LockupKeeper.GetLockByID(s.Ctx, newGammLockId) - if err != nil && newGammLockId != 0 { + gammLockPostSlash, err := s.App.LockupKeeper.GetLockByID(s.Ctx, gammLockId) + if percentOfSharesToMigrate.LT(sdk.OneDec()) { s.Require().NoError(err) + } else { + s.Require().Error(err) } // Check if the concentrated lock was slashed. @@ -875,26 +1074,13 @@ func (s *KeeperTestSuite) SlashAndValidateResult(ctx sdk.Context, newGammLockId, func (s *KeeperTestSuite) ValidateMigrateResult( ctx sdk.Context, - positionId, balancerPooId, poolIdLeaving, clPoolId, poolIdEntering, originalGammLockId, newGammLockId uint64, + positionId, balancerPooId, poolIdLeaving, clPoolId, poolIdEntering uint64, percentOfSharesToMigrate sdk.Dec, balancerLock lockuptypes.PeriodLock, joinPoolAmt sdk.Coins, balancerPoolShareOut, coinsToMigrate sdk.Coin, amount0, amount1 sdk.Int, ) { - lockupKeeper := s.App.LockupKeeper - - newGammLock, err := lockupKeeper.GetLockByID(ctx, newGammLockId) - if percentOfSharesToMigrate.LT(sdk.OneDec()) { - // If we migrated a subset of the balancer LP tokens, we expect the new gamm lock to have a the same end time. - s.Require().NoError(err) - s.Require().Equal(balancerLock.EndTime, newGammLock.EndTime) - } else { - // If we migrated all of the balancer LP tokens, we expect no new gamm lock to be created. - s.Require().Error(err) - s.Require().Nil(newGammLock) - } - // Check that the concentrated liquidity position now exists position, err := s.App.ConcentratedLiquidityKeeper.GetPositionLiquidity(ctx, positionId) s.Require().NoError(err) @@ -913,26 +1099,4 @@ func (s *KeeperTestSuite) ValidateMigrateResult( } s.Require().Equal(0, defaultErrorTolerance.Compare(joinPoolAmt.AmountOf(defaultPoolAssets[0].Token.Denom).ToDec().Mul(percentOfSharesToMigrate).RoundInt(), amount0)) s.Require().Equal(0, defaultErrorTolerance.Compare(joinPoolAmt.AmountOf(defaultPoolAssets[1].Token.Denom).ToDec().Mul(percentOfSharesToMigrate).RoundInt(), amount1)) - - // Check if the original gamm lock was deleted. - _, err = lockupKeeper.GetLockByID(ctx, originalGammLockId) - s.Require().Error(err) - - // If we didn't migrate the entire gamm lock, we expect a new gamm lock to be created with the remaining lock time and coins associated with it. - if percentOfSharesToMigrate.LT(sdk.OneDec()) { - // Check if the new gamm lock was created. - newGammLock, err := lockupKeeper.GetLockByID(ctx, newGammLockId) - s.Require().NoError(err) - // The new gamm lock should have the same owner and end time. - // The new gamm lock should have the difference in coins between the original lock and the coins migrated. - s.Require().Equal(sdk.NewCoins(balancerPoolShareOut.Sub(coinsToMigrate)).String(), newGammLock.Coins.String()) - s.Require().Equal(balancerLock.Owner, newGammLock.Owner) - s.Require().Equal(balancerLock.EndTime.String(), newGammLock.EndTime.String()) - // If original gamm lock was unlocking, the new gamm lock should also be unlocking. - if balancerLock.IsUnlocking() { - s.Require().True(newGammLock.IsUnlocking()) - } - } else { - s.Require().Equal(uint64(0), newGammLockId) - } } diff --git a/x/superfluid/keeper/msg_server.go b/x/superfluid/keeper/msg_server.go index c60f3e3387c..7a9b3df12bc 100644 --- a/x/superfluid/keeper/msg_server.go +++ b/x/superfluid/keeper/msg_server.go @@ -205,7 +205,7 @@ func (server msgServer) UnlockAndMigrateSharesToFullRangeConcentratedPosition(go return nil, err } - positionId, amount0, amount1, liquidity, joinTime, poolIdLeaving, poolIdEntering, gammLockId, clLockId, err := server.keeper.RouteLockedBalancerToConcentratedMigration(ctx, sender, msg.LockId, msg.SharesToMigrate, msg.TokenOutMins) + positionId, amount0, amount1, liquidity, joinTime, poolIdLeaving, poolIdEntering, clLockId, err := server.keeper.RouteLockedBalancerToConcentratedMigration(ctx, sender, msg.LockId, msg.SharesToMigrate, msg.TokenOutMins) if err != nil { return nil, err } @@ -215,7 +215,6 @@ func (server msgServer) UnlockAndMigrateSharesToFullRangeConcentratedPosition(go types.TypeEvtUnlockAndMigrateShares, sdk.NewAttribute(types.AttributeKeyPoolIdEntering, strconv.FormatUint(poolIdEntering, 10)), sdk.NewAttribute(types.AttributeKeyPoolIdLeaving, strconv.FormatUint(poolIdLeaving, 10)), - sdk.NewAttribute(types.AttributeGammLockId, strconv.FormatUint(gammLockId, 10)), sdk.NewAttribute(types.AttributeConcentratedLockId, strconv.FormatUint(clLockId, 10)), sdk.NewAttribute(sdk.AttributeKeySender, msg.Sender), sdk.NewAttribute(types.AttributePositionId, strconv.FormatUint(positionId, 10)), diff --git a/x/superfluid/keeper/stake.go b/x/superfluid/keeper/stake.go index 20a0ae0dbfd..9fd3fb273f3 100644 --- a/x/superfluid/keeper/stake.go +++ b/x/superfluid/keeper/stake.go @@ -303,11 +303,75 @@ func (k Keeper) SuperfluidUndelegate(ctx sdk.Context, sender string, lockID uint // SuperfluidUndelegateToConcentratedPosition starts undelegating superfluid delegated position for the given lock. It behaves similarly to SuperfluidUndelegate, // however it does not create a new synthetic lockup representing the unstaking side. This is because after the time this function is called, we might // want to perform more operations prior to creating a lock. Once the actual lock is created, the synthetic lockup representing the unstaking side -// should eventually be created as well. Use thi function with caution to avoid accidentally missing synthetic lock creation. +// should eventually be created as well. Use this function with caution to avoid accidentally missing synthetic lock creation. func (k Keeper) SuperfluidUndelegateToConcentratedPosition(ctx sdk.Context, sender string, gammLockID uint64) (types.SuperfluidIntermediaryAccount, error) { return k.undelegateCommon(ctx, sender, gammLockID) } +// partialUndelegateCommon acts similarly to undelegateCommon, but undelegates a partial amount of the lock's delegation rather than the full amount. The amount +// that is undelegated is placed in a new lock. This function returns the intermediary account associated with the original lock ID as well as the new lock that was created. +// An error is returned if the amount to undelegate is greater than the locked amount. +func (k Keeper) partialUndelegateCommon(ctx sdk.Context, sender string, lockID uint64, amountToUndelegate sdk.Coin) (intermediaryAcc types.SuperfluidIntermediaryAccount, newlock *lockuptypes.PeriodLock, err error) { + lock, err := k.lk.GetLockByID(ctx, lockID) + if err != nil { + return types.SuperfluidIntermediaryAccount{}, &lockuptypes.PeriodLock{}, err + } + err = k.validateLockForSF(ctx, lock, sender) + if err != nil { + return types.SuperfluidIntermediaryAccount{}, &lockuptypes.PeriodLock{}, err + } + + if amountToUndelegate.Amount.GTE(lock.Coins[0].Amount) { + return types.SuperfluidIntermediaryAccount{}, &lockuptypes.PeriodLock{}, fmt.Errorf("partial undelegate amount must be less than the locked amount") + } + + // get the intermediate account associated with lock id, and delete the connection. + intermediaryAcc, found := k.GetIntermediaryAccountFromLockId(ctx, lockID) + if !found { + return types.SuperfluidIntermediaryAccount{}, &lockuptypes.PeriodLock{}, types.ErrNotSuperfluidUsedLockup + } + + // undelegate the desired lock amount, and burn the minted osmo. + amount, err := k.GetSuperfluidOSMOTokens(ctx, intermediaryAcc.Denom, amountToUndelegate.Amount) + if err != nil { + return types.SuperfluidIntermediaryAccount{}, &lockuptypes.PeriodLock{}, err + } + err = k.forceUndelegateAndBurnOsmoTokens(ctx, amount, intermediaryAcc) + if err != nil { + return types.SuperfluidIntermediaryAccount{}, &lockuptypes.PeriodLock{}, err + } + + // Move the funds from the old lock to a new lock with the remaining amount. + newLock, err := k.lk.SplitLock(ctx, *lock, sdk.NewCoins(amountToUndelegate), true) + if err != nil { + return types.SuperfluidIntermediaryAccount{}, &lockuptypes.PeriodLock{}, err + } + + return intermediaryAcc, &newLock, nil +} + +// partialSuperfluidUndelegate starts undelegating a portion of a superfluid delegated position for the given lock. +// Undelegation is done instantly and the equivalent amount is sent to the module account +// where it is burnt. Note that this method does not include unbonding the lock +// itself. +// nolint: unused +func (k Keeper) partialSuperfluidUndelegate(ctx sdk.Context, sender string, lockID uint64, amountToUndelegate sdk.Coin) error { + intermediaryAcc, newLock, err := k.partialUndelegateCommon(ctx, sender, lockID, amountToUndelegate) + if err != nil { + return err + } + // Create a new synthetic lockup representing the unstaking side. + return k.createSyntheticLockup(ctx, newLock.ID, intermediaryAcc, unlockingStatus) +} + +// partialSuperfluidUndelegateToConcentratedPosition starts undelegating a portion of a superfluid delegated position for the given lock. It behaves similarly to partialSuperfluidUndelegate, +// however it does not create a new synthetic lockup representing the unstaking side. This is because after the time this function is called, we might +// want to perform more operations prior to creating a lock. Once the actual lock is created, the synthetic lockup representing the unstaking side +// should eventually be created as well. Use this function with caution to avoid accidentally missing synthetic lock creation. +func (k Keeper) partialSuperfluidUndelegateToConcentratedPosition(ctx sdk.Context, sender string, gammLockID uint64, amountToUndelegate sdk.Coin) (types.SuperfluidIntermediaryAccount, *lockuptypes.PeriodLock, error) { + return k.partialUndelegateCommon(ctx, sender, gammLockID, amountToUndelegate) +} + // SuperfluidUnbondLock unbonds the lock that has been used for superfluid staking. // This method would return an error if the underlying lock is not superfluid undelegating. func (k Keeper) SuperfluidUnbondLock(ctx sdk.Context, underlyingLockId uint64, sender string) error { diff --git a/x/superfluid/keeper/stake_test.go b/x/superfluid/keeper/stake_test.go index e35a3971d7f..56cf8920cbf 100644 --- a/x/superfluid/keeper/stake_test.go +++ b/x/superfluid/keeper/stake_test.go @@ -1028,6 +1028,166 @@ func (s *KeeperTestSuite) TestRefreshIntermediaryDelegationAmounts() { } } +func (suite *KeeperTestSuite) TestPartialSuperfluidUndelegateToConcentratedPosition() { + testCases := []struct { + name string + validatorStats []stakingtypes.BondStatus + superDelegations []superfluidDelegation + undelegateAmounts []sdk.Coin + superUnbondingLockIds []uint64 + expSuperUnbondingErr []bool + // expected amount of delegation to intermediary account + expInterDelegation []sdk.Dec + }{ + { + "with single validator and single superfluid delegation and single undelegation", + []stakingtypes.BondStatus{stakingtypes.Bonded}, + []superfluidDelegation{{0, 0, 0, 1000000}}, + []sdk.Coin{sdk.NewCoin("gamm/pool/1", sdk.NewInt(400000))}, + []uint64{1}, + []bool{false}, + []sdk.Dec{sdk.NewDec(6000000)}, + }, + { + "with single validator and additional superfluid delegations and single undelegation", + []stakingtypes.BondStatus{stakingtypes.Bonded}, + []superfluidDelegation{{0, 0, 0, 1000000}, {0, 0, 0, 1000000}}, + []sdk.Coin{sdk.NewCoin("gamm/pool/1", sdk.NewInt(900000))}, + []uint64{1}, + []bool{false}, + []sdk.Dec{sdk.NewDec(11000000)}, + }, + { + "with multiple validators and multiple superfluid delegations and multiple undelegations", + []stakingtypes.BondStatus{stakingtypes.Bonded, stakingtypes.Bonded}, + []superfluidDelegation{{0, 0, 0, 1000000}, {1, 1, 0, 1000000}}, + []sdk.Coin{sdk.NewCoin("gamm/pool/1", sdk.NewInt(600000)), sdk.NewCoin("gamm/pool/1", sdk.NewInt(400000))}, + []uint64{1, 2}, + []bool{false, false}, + []sdk.Dec{sdk.NewDec(4000000), sdk.NewDec(6000000)}, + }, + { + "add unbonding validator", + []stakingtypes.BondStatus{stakingtypes.Bonded, stakingtypes.Unbonding}, + []superfluidDelegation{{0, 0, 0, 1000000}, {1, 1, 0, 1000000}}, + []sdk.Coin{sdk.NewCoin("gamm/pool/1", sdk.NewInt(600000)), sdk.NewCoin("gamm/pool/1", sdk.NewInt(400000))}, + []uint64{1, 2}, + []bool{false, false}, + []sdk.Dec{sdk.NewDec(4000000), sdk.NewDec(6000000)}, + }, + { + "add unbonded validator", + []stakingtypes.BondStatus{stakingtypes.Bonded, stakingtypes.Unbonded}, + []superfluidDelegation{{0, 0, 0, 1000000}, {1, 1, 0, 1000000}}, + []sdk.Coin{sdk.NewCoin("gamm/pool/1", sdk.NewInt(600000)), sdk.NewCoin("gamm/pool/1", sdk.NewInt(400000))}, + []uint64{1, 2}, + []bool{false, false}, + []sdk.Dec{sdk.NewDec(4000000), sdk.NewDec(6000000)}, + }, + { + "undelegating not available lock id", + []stakingtypes.BondStatus{stakingtypes.Bonded}, + []superfluidDelegation{{0, 0, 0, 1000000}}, + []sdk.Coin{sdk.NewCoin("gamm/pool/1", sdk.NewInt(600000))}, + []uint64{2}, + []bool{true}, + []sdk.Dec{}, + }, + } + + for _, tc := range testCases { + tc := tc + suite.Run(tc.name, func() { + suite.SetupTest() + + bondDenom := suite.App.StakingKeeper.GetParams(suite.Ctx).BondDenom + + // setup validators + valAddrs := suite.SetupValidators(tc.validatorStats) + + denoms, _ := suite.SetupGammPoolsAndSuperfluidAssets([]sdk.Dec{sdk.NewDec(20), sdk.NewDec(20)}) + + // setup superfluid delegations + _, intermediaryAccs, _ := suite.setupSuperfluidDelegations(valAddrs, tc.superDelegations, denoms) + suite.checkIntermediaryAccountDelegations(intermediaryAccs) + + suite.Greater(len(tc.superUnbondingLockIds), 0) + for index, lockId := range tc.superUnbondingLockIds { + // get intermediary account + accAddr := suite.App.SuperfluidKeeper.GetLockIdIntermediaryAccountConnection(suite.Ctx, lockId) + intermediaryAcc := suite.App.SuperfluidKeeper.GetIntermediaryAccount(suite.Ctx, accAddr) + valAddr := intermediaryAcc.ValAddr + + lock, err := suite.App.LockupKeeper.GetLockByID(suite.Ctx, lockId) + if tc.expSuperUnbondingErr[index] { + // manually set the lock to nil if we expect an error so we don't fail early + suite.Require().Error(err) + lock = &lockuptypes.PeriodLock{} + } else { + suite.Require().NoError(err) + } + + // get pre-superfluid delgations osmo supply and supplyWithOffset + presupply := suite.App.BankKeeper.GetSupply(suite.Ctx, bondDenom) + presupplyWithOffset := suite.App.BankKeeper.GetSupplyWithOffset(suite.Ctx, bondDenom) + + // superfluid undelegate + intermediaryAcc, newLock, err := suite.App.SuperfluidKeeper.PartialSuperfluidUndelegateToConcentratedPosition(suite.Ctx, lock.Owner, lockId, tc.undelegateAmounts[index]) + if tc.expSuperUnbondingErr[index] { + suite.Require().Error(err) + continue + } + suite.Require().NoError(err) + + // the new lock should be equal to the amount we partially undelegated + suite.Require().Equal(tc.undelegateAmounts[index].Amount.String(), newLock.Coins[0].Amount.String()) + + // ensure post-superfluid delegations osmo supplywithoffset is the same while supply is not + postsupply := suite.App.BankKeeper.GetSupply(suite.Ctx, bondDenom) + postsupplyWithOffset := suite.App.BankKeeper.GetSupplyWithOffset(suite.Ctx, bondDenom) + + suite.Require().Equal(presupply.Amount.Sub(tc.undelegateAmounts[index].Amount.Mul(sdk.NewInt(10))).String(), postsupply.Amount.String()) + suite.Require().Equal(presupplyWithOffset, postsupplyWithOffset) + + // check lockId and intermediary account connection is not deleted + addr := suite.App.SuperfluidKeeper.GetLockIdIntermediaryAccountConnection(suite.Ctx, lockId) + suite.Require().Equal(intermediaryAcc.GetAccAddress().String(), addr.String()) + + // check bonding synthetic lockup is not deleted + _, err = suite.App.LockupKeeper.GetSyntheticLockup(suite.Ctx, lockId, keeper.StakingSyntheticDenom(lock.Coins[0].Denom, valAddr)) + suite.Require().NoError(err) + + // check unbonding synthetic lockup creation + // since this is the concentrated liquidity path, no new synthetic lockup should be created + synthLock, err := suite.App.LockupKeeper.GetSyntheticLockup(suite.Ctx, lockId, keeper.UnstakingSyntheticDenom(lock.Coins[0].Denom, valAddr)) + suite.Require().Error(err) + suite.Require().Nil(synthLock) + synthLock, err = suite.App.LockupKeeper.GetSyntheticLockup(suite.Ctx, newLock.ID, keeper.UnstakingSyntheticDenom(lock.Coins[0].Denom, valAddr)) + suite.Require().Error(err) + suite.Require().Nil(synthLock) + } + + // check invariant is fine + reason, broken := keeper.AllInvariants(*suite.App.SuperfluidKeeper)(suite.Ctx) + suite.Require().False(broken, reason) + + // check remaining intermediary account delegation + for index, expDelegation := range tc.expInterDelegation { + acc := intermediaryAccs[index] + valAddr, err := sdk.ValAddressFromBech32(acc.ValAddr) + suite.Require().NoError(err) + delegation, found := suite.App.StakingKeeper.GetDelegation(suite.Ctx, acc.GetAccAddress(), valAddr) + if expDelegation.IsZero() { + suite.Require().False(found, "expected no delegation, found delegation w/ %d shares", delegation.Shares) + } else { + suite.Require().True(found) + suite.Require().Equal(expDelegation, delegation.Shares) + } + } + }) + } +} + // type superfluidRedelegation struct { // lockId uint64 // oldValIndex int64 diff --git a/x/superfluid/types/errors.go b/x/superfluid/types/errors.go index 1a59a025b89..cd1552ccb58 100644 --- a/x/superfluid/types/errors.go +++ b/x/superfluid/types/errors.go @@ -55,6 +55,32 @@ func (e LockOwnerMismatchError) Error() string { return fmt.Sprintf("lock ID %d owner %s does not match provided owner %s.", e.LockId, e.LockOwner, e.ProvidedOwner) } +type SharesToMigrateDenomPrefixError struct { + Denom string + ExpectedDenomPrefix string +} + +func (e SharesToMigrateDenomPrefixError) Error() string { + return fmt.Sprintf("shares to migrate denom %s does not have expected prefix %s.", e.Denom, e.ExpectedDenomPrefix) +} + +type MigrateMoreSharesThanLockHasError struct { + SharesToMigrate string + SharesInLock string +} + +func (e MigrateMoreSharesThanLockHasError) Error() string { + return fmt.Sprintf("cannot migrate more shares (%s) than lock has (%s)", e.SharesToMigrate, e.SharesInLock) +} + +type TwoTokenBalancerPoolError struct { + NumberOfTokens int +} + +func (e TwoTokenBalancerPoolError) Error() string { + return fmt.Sprintf("balancer pool must have two tokens, got %d tokens", e.NumberOfTokens) +} + type ConcentratedTickRangeNotFullError struct { ActualLowerTick int64 ActualUpperTick int64 diff --git a/x/superfluid/types/expected_keepers.go b/x/superfluid/types/expected_keepers.go index 24bba8eb954..394728f3c0d 100644 --- a/x/superfluid/types/expected_keepers.go +++ b/x/superfluid/types/expected_keepers.go @@ -28,6 +28,8 @@ type LockupKeeper interface { // TODO: Fix this in future code update BeginForceUnlock(ctx sdk.Context, lockID uint64, coins sdk.Coins) (uint64, error) ForceUnlock(ctx sdk.Context, lock lockuptypes.PeriodLock) error + PartialForceUnlock(ctx sdk.Context, lock lockuptypes.PeriodLock, coins sdk.Coins) error + SplitLock(ctx sdk.Context, lock lockuptypes.PeriodLock, coins sdk.Coins, forceUnlock bool) (lockuptypes.PeriodLock, error) CreateLock(ctx sdk.Context, owner sdk.AccAddress, coins sdk.Coins, duration time.Duration) (lockuptypes.PeriodLock, error)