Skip to content

Commit debeb29

Browse files
mconcatValarDragon
andauthored
Partial unlocking implementation (#893)
* add partial unlock logic * add partial unlock test * fix lint * Apply suggestions from code review Co-authored-by: Dev Ojha <ValarDragon@users.noreply.github.com> * add test for case after unlocking period Co-authored-by: Dev Ojha <ValarDragon@users.noreply.github.com>
1 parent 1652506 commit debeb29

File tree

12 files changed

+288
-53
lines changed

12 files changed

+288
-53
lines changed

proto/osmosis/lockup/tx.proto

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,5 +42,10 @@ message MsgBeginUnlockingAllResponse { repeated PeriodLock unlocks = 1; }
4242
message MsgBeginUnlocking {
4343
string owner = 1 [ (gogoproto.moretags) = "yaml:\"owner\"" ];
4444
uint64 ID = 2;
45+
// Amount of unlocking coins. Unlock all if not set.
46+
repeated cosmos.base.v1beta1.Coin coins = 3 [
47+
(gogoproto.nullable) = false,
48+
(gogoproto.castrepeated) = "github.com/cosmos/cosmos-sdk/types.Coins"
49+
];
4550
}
4651
message MsgBeginUnlockingResponse { bool success = 1; }

x/lockup/client/cli/tx.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ func NewBeginUnlockByIDCmd() *cobra.Command {
128128
msg := types.NewMsgBeginUnlocking(
129129
clientCtx.GetFromAddress(),
130130
uint64(id),
131+
nil,
131132
)
132133

133134
return tx.GenerateOrBroadcastTxWithFactory(clientCtx, txf, msg)

x/lockup/keeper/iterator.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ func (k Keeper) beginUnlockFromIterator(ctx sdk.Context, iterator db.Iterator) (
201201

202202
locks := k.getLocksFromIterator(ctx, iterator)
203203
for _, lock := range locks {
204-
err := k.BeginUnlock(ctx, lock)
204+
err := k.BeginUnlock(ctx, lock, nil)
205205
if err != nil {
206206
return locks, err
207207
}

x/lockup/keeper/lock.go

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -429,22 +429,60 @@ func (k Keeper) Lock(ctx sdk.Context, lock types.PeriodLock) error {
429429
return nil
430430
}
431431

432+
// splitLock splits a lock with the given amount, and stores split new lock to the state
433+
func (k Keeper) splitLock(ctx sdk.Context, lock types.PeriodLock, coins sdk.Coins) (types.PeriodLock, error) {
434+
if lock.IsUnlocking() {
435+
return types.PeriodLock{}, fmt.Errorf("cannot split unlocking lock")
436+
}
437+
lock.Coins = lock.Coins.Sub(coins)
438+
err := k.setLock(ctx, lock)
439+
if err != nil {
440+
return types.PeriodLock{}, err
441+
}
442+
443+
splitLockID := k.GetLastLockID(ctx) + 1
444+
k.SetLastLockID(ctx, splitLockID)
445+
446+
splitLock := types.NewPeriodLock(splitLockID, lock.OwnerAddress(), lock.Duration, lock.EndTime, coins)
447+
err = k.setLock(ctx, splitLock)
448+
return splitLock, err
449+
}
450+
432451
// BeginUnlock is a utility to start unlocking coins from NotUnlocking queue
433-
func (k Keeper) BeginUnlock(ctx sdk.Context, lock types.PeriodLock) error {
434-
// remove lock refs from not unlocking queue
452+
func (k Keeper) BeginUnlock(ctx sdk.Context, lock types.PeriodLock, coins sdk.Coins) error {
453+
// sanity check
454+
if !coins.IsAllLTE(lock.Coins) {
455+
return fmt.Errorf("requested amount to unlock exceedes locked tokens")
456+
}
457+
458+
// If the amount were unlocking is empty, or the entire coins amount, unlock the entire lock.
459+
// Otherwise, split the lock into two locks, and fully unlock the newly created lock.
460+
// (By virtue, the newly created lock we split into should have the unlock amount)
461+
if len(coins) != 0 && !coins.IsEqual(lock.Coins) {
462+
// prohibit partial unlock if other locks are referring
463+
if k.HasAnySyntheticLockups(ctx, lock.ID) {
464+
return fmt.Errorf("cannot partial unlock a lock with synthetic lockup")
465+
}
466+
467+
splitLock, err := k.splitLock(ctx, lock, coins)
468+
if err != nil {
469+
return err
470+
}
471+
lock = splitLock
472+
}
473+
474+
// remove lock refs from not unlocking queue if exists
435475
err := k.deleteLockRefs(ctx, types.KeyPrefixNotUnlocking, lock)
436476
if err != nil {
437477
return err
438478
}
439479

440480
// store lock with end time set
441481
lock.EndTime = ctx.BlockTime().Add(lock.Duration)
442-
store := ctx.KVStore(k.storeKey)
443-
bz, err := proto.Marshal(&lock)
482+
err = k.setLock(ctx, lock)
444483
if err != nil {
445484
return err
446485
}
447-
store.Set(lockStoreKey(lock.ID), bz)
448486

449487
// add lock refs into unlocking queue
450488
err = k.addLockRefs(ctx, types.KeyPrefixUnlocking, lock)
@@ -456,11 +494,7 @@ func (k Keeper) BeginUnlock(ctx sdk.Context, lock types.PeriodLock) error {
456494
return nil
457495
}
458496

459-
lockOwner, err := sdk.AccAddressFromBech32(lock.Owner)
460-
if err != nil {
461-
panic(err)
462-
}
463-
k.hooks.OnStartUnlock(ctx, lockOwner, lock.ID, lock.Coins, lock.Duration, lock.EndTime)
497+
k.hooks.OnStartUnlock(ctx, lock.OwnerAddress(), lock.ID, lock.Coins, lock.Duration, lock.EndTime)
464498

465499
return nil
466500
}
@@ -484,7 +518,7 @@ func (k Keeper) Unlock(ctx sdk.Context, lock types.PeriodLock) error {
484518
// TODO: Revisit for Superfluid Staking
485519
func (k Keeper) ForceUnlock(ctx sdk.Context, lock types.PeriodLock) error {
486520
if !lock.IsUnlocking() {
487-
err := k.BeginUnlock(ctx, lock)
521+
err := k.BeginUnlock(ctx, lock, nil)
488522
if err != nil {
489523
return err
490524
}

x/lockup/keeper/lock_test.go

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ func (suite *KeeperTestSuite) TestBeginUnlockPeriodLock() {
6565
suite.Require().Equal(locks[0].IsUnlocking(), false)
6666

6767
// begin unlock
68-
err = suite.app.LockupKeeper.BeginUnlock(suite.ctx, locks[0])
68+
err = suite.app.LockupKeeper.BeginUnlock(suite.ctx, locks[0], nil)
6969
suite.Require().NoError(err)
7070

7171
// check locks
@@ -127,7 +127,7 @@ func (suite *KeeperTestSuite) TestUnlockPeriodLockByID() {
127127
// begin unlock
128128
lock, err = lockKeeper.GetLockByID(suite.ctx, 1)
129129
suite.Require().NoError(err)
130-
err = lockKeeper.BeginUnlock(suite.ctx, *lock)
130+
err = lockKeeper.BeginUnlock(suite.ctx, *lock, nil)
131131
suite.Require().NoError(err)
132132

133133
// unlock 1s after begin unlock
@@ -190,14 +190,82 @@ func (suite *KeeperTestSuite) TestUnlock() {
190190
suite.Require().NoError(err)
191191

192192
// begin unlock with lock object
193-
err = suite.app.LockupKeeper.BeginUnlock(suite.ctx, lock)
193+
err = suite.app.LockupKeeper.BeginUnlock(suite.ctx, lock, nil)
194194
suite.Require().NoError(err)
195195

196196
// unlock with lock object
197197
err = suite.app.LockupKeeper.Unlock(suite.ctx.WithBlockTime(now.Add(time.Second)), lock)
198198
suite.Require().NoError(err)
199199
}
200200

201+
func (suite *KeeperTestSuite) TestPartialUnlock() {
202+
suite.SetupTest()
203+
now := suite.ctx.BlockTime()
204+
205+
addr1 := sdk.AccAddress([]byte("addr1---------------"))
206+
coins := sdk.Coins{sdk.NewInt64Coin("stake", 10)}
207+
208+
// lock with balance
209+
err := simapp.FundAccount(suite.app.BankKeeper, suite.ctx, addr1, coins)
210+
suite.Require().NoError(err)
211+
lock, err := suite.app.LockupKeeper.LockTokens(suite.ctx, addr1, coins, time.Second)
212+
suite.Require().NoError(err)
213+
214+
// check unlocking coins
215+
unlockings := suite.app.LockupKeeper.GetAccountUnlockingCoins(suite.ctx, addr1)
216+
suite.Require().Equal(len(unlockings), 0)
217+
218+
// check locked coins
219+
locked := suite.app.LockupKeeper.GetAccountLockedCoins(suite.ctx, addr1)
220+
suite.Require().Equal(len(locked), 1)
221+
suite.Require().Equal(locked[0].Amount.Int64(), int64(10))
222+
223+
// test exceeding coins
224+
exceedingCoins := sdk.Coins{sdk.NewInt64Coin("stake", 15)}
225+
err = suite.app.LockupKeeper.BeginUnlock(suite.ctx, lock, exceedingCoins)
226+
suite.Require().Error(err)
227+
228+
// test invalid coins
229+
invalidCoins := sdk.Coins{sdk.NewInt64Coin("unknown", 1)}
230+
err = suite.app.LockupKeeper.BeginUnlock(suite.ctx, lock, invalidCoins)
231+
suite.Require().Error(err)
232+
233+
// begin unlock partial amount
234+
partialCoins := sdk.Coins{sdk.NewInt64Coin("stake", 1)}
235+
err = suite.app.LockupKeeper.BeginUnlock(suite.ctx, lock, partialCoins)
236+
suite.Require().NoError(err)
237+
238+
// check unlocking coins
239+
unlockings = suite.app.LockupKeeper.GetAccountUnlockingCoins(suite.ctx, addr1)
240+
suite.Require().Equal(len(unlockings), 1)
241+
suite.Require().Equal(unlockings[0].Amount.Int64(), int64(1))
242+
243+
// check locked coins
244+
locked = suite.app.LockupKeeper.GetAccountLockedCoins(suite.ctx, addr1)
245+
suite.Require().Equal(len(locked), 1)
246+
suite.Require().Equal(locked[0].Amount.Int64(), int64(10))
247+
248+
// check locked coins after the unlocking period
249+
locked = suite.app.LockupKeeper.GetAccountLockedCoins(suite.ctx.WithBlockTime(now.Add(time.Second)), addr1)
250+
suite.Require().Equal(len(locked), 1)
251+
suite.Require().Equal(locked[0].Amount.Int64(), int64(9))
252+
253+
// Finish unlocking partial unlock
254+
partialUnlock := suite.app.LockupKeeper.GetAccountPeriodLocks(suite.ctx, addr1)[1]
255+
err = suite.app.LockupKeeper.Unlock(suite.ctx.WithBlockTime(now.Add(time.Second)), partialUnlock)
256+
suite.Require().NoError(err)
257+
258+
// check unlocking coins
259+
unlockings = suite.app.LockupKeeper.GetAccountUnlockingCoins(suite.ctx, addr1)
260+
suite.Require().Equal(len(unlockings), 0)
261+
262+
// check locked coins
263+
locked = suite.app.LockupKeeper.GetAccountLockedCoins(suite.ctx, addr1)
264+
suite.Require().Equal(len(locked), 1)
265+
suite.Require().Equal(locked[0].Amount.Int64(), int64(9))
266+
267+
}
268+
201269
func (suite *KeeperTestSuite) TestModuleLockedCoins() {
202270
suite.SetupTest()
203271

x/lockup/keeper/msg_server.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,8 @@ func (server msgServer) BeginUnlocking(goCtx context.Context, msg *types.MsgBegi
7878
if err != nil {
7979
return nil, sdkerrors.Wrap(sdkerrors.ErrInvalidRequest, err.Error())
8080
}
81-
err = server.keeper.BeginUnlock(ctx, *lock)
81+
82+
err = server.keeper.BeginUnlock(ctx, *lock, msg.Coins)
8283
if err != nil {
8384
return nil, sdkerrors.Wrap(sdkerrors.ErrInvalidRequest, err.Error())
8485
}

x/lockup/keeper/synthetic_lock.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ func (k Keeper) GetSyntheticLockup(ctx sdk.Context, lockID uint64, suffix string
4646
func (k Keeper) GetAllSyntheticLockupsByLockup(ctx sdk.Context, lockID uint64) []types.SyntheticLock {
4747
store := ctx.KVStore(k.storeKey)
4848
iterator := sdk.KVStorePrefixIterator(store, combineKeys(types.KeyPrefixSyntheticLockup, sdk.Uint64ToBigEndian(lockID)))
49+
defer iterator.Close()
4950

5051
synthLocks := []types.SyntheticLock{}
5152
for ; iterator.Valid(); iterator.Next() {
@@ -59,9 +60,17 @@ func (k Keeper) GetAllSyntheticLockupsByLockup(ctx sdk.Context, lockID uint64) [
5960
return synthLocks
6061
}
6162

63+
func (k Keeper) HasAnySyntheticLockups(ctx sdk.Context, lockID uint64) bool {
64+
store := ctx.KVStore(k.storeKey)
65+
iterator := sdk.KVStorePrefixIterator(store, combineKeys(types.KeyPrefixSyntheticLockup, sdk.Uint64ToBigEndian(lockID)))
66+
defer iterator.Close()
67+
return iterator.Valid()
68+
}
69+
6270
func (k Keeper) GetAllSyntheticLockups(ctx sdk.Context) []types.SyntheticLock {
6371
store := ctx.KVStore(k.storeKey)
6472
iterator := sdk.KVStorePrefixIterator(store, types.KeyPrefixSyntheticLockup)
73+
defer iterator.Close()
6574

6675
synthLocks := []types.SyntheticLock{}
6776
for ; iterator.Valid(); iterator.Next() {

x/lockup/types/lock.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@ func (p SyntheticLock) IsUnlocking() bool {
2929
return !p.EndTime.Equal(time.Time{})
3030
}
3131

32+
// OwnerAddress returns locks owner address
33+
func (p PeriodLock) OwnerAddress() sdk.AccAddress {
34+
addr, err := sdk.AccAddressFromBech32(p.Owner)
35+
if err != nil {
36+
panic(err)
37+
}
38+
return addr
39+
}
40+
3241
func SumLocksByDenom(locks []PeriodLock, denom string) sdk.Int {
3342
sum := sdk.NewInt(0)
3443
// validate the denom once, so we can avoid the expensive validate check in the hot loop.

x/lockup/types/msgs.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,11 @@ func (m MsgBeginUnlockingAll) GetSigners() []sdk.AccAddress {
6666
var _ sdk.Msg = &MsgBeginUnlocking{}
6767

6868
// NewMsgBeginUnlocking creates a message to begin unlocking the tokens of a specific lock
69-
func NewMsgBeginUnlocking(owner sdk.AccAddress, id uint64) *MsgBeginUnlocking {
69+
func NewMsgBeginUnlocking(owner sdk.AccAddress, id uint64, coins sdk.Coins) *MsgBeginUnlocking {
7070
return &MsgBeginUnlocking{
7171
Owner: owner.String(),
7272
ID: id,
73+
Coins: coins,
7374
}
7475
}
7576

0 commit comments

Comments
 (0)