diff --git a/x/perp/keeper/clearing_house.go b/x/perp/keeper/clearing_house.go index 70e92391f..4a67ed2d0 100644 --- a/x/perp/keeper/clearing_house.go +++ b/x/perp/keeper/clearing_house.go @@ -10,7 +10,7 @@ import ( "github.com/NibiruChain/nibiru/x/common" "github.com/NibiruChain/nibiru/x/perp/events" "github.com/NibiruChain/nibiru/x/perp/types" - pooltypes "github.com/NibiruChain/nibiru/x/vpool/types" + vpooltypes "github.com/NibiruChain/nibiru/x/vpool/types" ) // TODO test: OpenPosition | https://github.com/NibiruChain/nibiru/issues/299 @@ -279,13 +279,13 @@ func (k Keeper) getPositionNotionalAndUnrealizedPnL( return sdk.ZeroDec(), sdk.ZeroDec(), nil } - var baseAssetDirection pooltypes.Direction + var baseAssetDirection vpooltypes.Direction if position.Size_.IsPositive() { // LONG - baseAssetDirection = pooltypes.Direction_ADD_TO_POOL + baseAssetDirection = vpooltypes.Direction_ADD_TO_POOL } else { // SHORT - baseAssetDirection = pooltypes.Direction_REMOVE_FROM_POOL + baseAssetDirection = vpooltypes.Direction_REMOVE_FROM_POOL } switch pnlCalcOption { @@ -638,11 +638,11 @@ func (k Keeper) closePositionEntirely( positionResp.FundingPayment = remaining.FundingPayment positionResp.MarginToVault = remaining.Margin.Neg() - var baseAssetDirection pooltypes.Direction + var baseAssetDirection vpooltypes.Direction if currentPosition.Size_.IsPositive() { - baseAssetDirection = pooltypes.Direction_ADD_TO_POOL + baseAssetDirection = vpooltypes.Direction_ADD_TO_POOL } else { - baseAssetDirection = pooltypes.Direction_REMOVE_FROM_POOL + baseAssetDirection = vpooltypes.Direction_REMOVE_FROM_POOL } exchangedQuoteAssetAmount, err := k.VpoolKeeper.SwapBaseForQuote( @@ -795,12 +795,12 @@ func (k Keeper) swapQuoteForBase( baseAssetLimit sdk.Dec, canOverFluctuationLimit bool, ) (baseAmount sdk.Dec, err error) { - var quoteAssetDirection pooltypes.Direction + var quoteAssetDirection vpooltypes.Direction if side == types.Side_BUY { - quoteAssetDirection = pooltypes.Direction_ADD_TO_POOL + quoteAssetDirection = vpooltypes.Direction_ADD_TO_POOL } else { // side == types.Side_SELL - quoteAssetDirection = pooltypes.Direction_REMOVE_FROM_POOL + quoteAssetDirection = vpooltypes.Direction_REMOVE_FROM_POOL } baseAmount, err = k.VpoolKeeper.SwapQuoteForBase( diff --git a/x/perp/keeper/state.go b/x/perp/keeper/state.go index 4f4e44bd8..9a999598e 100644 --- a/x/perp/keeper/state.go +++ b/x/perp/keeper/state.go @@ -39,6 +39,10 @@ func (k Keeper) Whitelist() Whitelist { return (Whitelist)(k) } +func (k Keeper) PrepaidBadDebtState() PrepaidBadDebtState { + return (PrepaidBadDebtState)(k) +} + var paramsNamespace = []byte{0x0} var paramsKey = []byte{0x0} @@ -169,3 +173,48 @@ func (w Whitelist) IsWhitelisted(ctx sdk.Context, address string) bool { return kv.Has([]byte(address)) } + +var prepaidBadDebtNamespace = []byte{0x4} + +type PrepaidBadDebtState Keeper + +func (pbd PrepaidBadDebtState) getKVStore(ctx sdk.Context) sdk.KVStore { + return prefix.NewStore(ctx.KVStore(pbd.storeKey), prepaidBadDebtNamespace) +} + +/* +Fetches the amount of bad debt prepaid by denom. Returns zero if the denom is not found. +*/ +func (pbd PrepaidBadDebtState) Get(ctx sdk.Context, denom string) (amount sdk.Int) { + kv := pbd.getKVStore(ctx) + + v := kv.Get([]byte(denom)) + if v == nil { + return sdk.ZeroInt() + } + + return sdk.NewIntFromUint64(sdk.BigEndianToUint64(v)) +} + +/* +Sets the amount of bad debt prepaid by denom. +*/ +func (pbd PrepaidBadDebtState) Set(ctx sdk.Context, denom string, amount sdk.Int) { + kv := pbd.getKVStore(ctx) + kv.Set([]byte(denom), sdk.Uint64ToBigEndian(amount.Uint64())) +} + +/* +Increments the amount of bad debt prepaid by denom. +Calling this method on a denom that doesn't exist is effectively the same as setting the amount (0 + increment). +*/ +func (pbd PrepaidBadDebtState) Increment(ctx sdk.Context, denom string, increment sdk.Int) ( + amount sdk.Int, +) { + kv := pbd.getKVStore(ctx) + amount = pbd.Get(ctx, denom).Add(increment) + + kv.Set([]byte(denom), sdk.Uint64ToBigEndian(amount.Uint64())) + + return amount +} diff --git a/x/perp/keeper/state_test.go b/x/perp/keeper/state_test.go new file mode 100644 index 000000000..786953ff1 --- /dev/null +++ b/x/perp/keeper/state_test.go @@ -0,0 +1,29 @@ +package keeper + +import ( + "testing" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/stretchr/testify/assert" +) + +func TestPrepaidBadDebtState(t *testing.T) { + perpKeeper, _, ctx := getKeeper(t) + + t.Log("not found results in zero") + amount := perpKeeper.PrepaidBadDebtState().Get(ctx, "foo") + assert.EqualValues(t, sdk.ZeroInt(), amount) + + t.Log("set and get") + perpKeeper.PrepaidBadDebtState().Set(ctx, "NUSD", sdk.NewInt(100)) + + amount = perpKeeper.PrepaidBadDebtState().Get(ctx, "NUSD") + assert.EqualValues(t, sdk.NewInt(100), amount) + + t.Log("increment and check") + amount = perpKeeper.PrepaidBadDebtState().Increment(ctx, "NUSD", sdk.NewInt(50)) + assert.EqualValues(t, sdk.NewInt(150), amount) + + amount = perpKeeper.PrepaidBadDebtState().Get(ctx, "NUSD") + assert.EqualValues(t, sdk.NewInt(150), amount) +} diff --git a/x/perp/keeper/withdraw.go b/x/perp/keeper/withdraw.go new file mode 100644 index 000000000..b7f3b04ea --- /dev/null +++ b/x/perp/keeper/withdraw.go @@ -0,0 +1,68 @@ +package keeper + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" + + "github.com/NibiruChain/nibiru/x/perp/types" +) + +/* +Withdraws coins from the vault to the receiver. +If the total amount of coins to withdraw is greater than the vault's amount, then +withdraw the shortage from the PerpEF and mark it as prepaid bad debt. + +Prepaid bad debt will count towards realized bad debt from negative PnL positions +when those are closed/liquidated. + +An example of this happening is when a long position has really high PnL and +closes their position, realizing their profits. +There is a counter party short position with really negative PnL, but +their position hasn't been closed/liquidated yet. +We must pay the long trader first, which results in funds being taken from the EF. +When the short position is closed, it also realizes some bad debt but because +we have already withdrawn from the EF, we don't need to withdraw more from the EF. +*/ +func (k Keeper) Withdraw( + ctx sdk.Context, + denom string, + receiver sdk.AccAddress, + amountToWithdraw sdk.Int, +) (err error) { + if !amountToWithdraw.IsPositive() { + return nil + } + + vaultQuoteBalance := k.BankKeeper.GetBalance( + ctx, + k.AccountKeeper.GetModuleAddress(types.VaultModuleAccount), + denom, + ) + if vaultQuoteBalance.Amount.LT(amountToWithdraw) { + // if withdraw amount is larger than entire balance of vault + // means this trader's profit comes from other under collateral position's future loss + // and the balance of entire vault is not enough + // need money from PerpEF to pay first, and record this prepaidBadDebt + shortage := amountToWithdraw.Sub(vaultQuoteBalance.Amount) + k.PrepaidBadDebtState().Increment(ctx, denom, shortage) + if err := k.BankKeeper.SendCoinsFromModuleToModule( + ctx, + types.PerpEFModuleAccount, + types.VaultModuleAccount, + sdk.NewCoins( + sdk.NewCoin(denom, shortage), + ), + ); err != nil { + return err + } + } + + // Transfer from Vault to receiver + return k.BankKeeper.SendCoinsFromModuleToAccount( + ctx, + /* from */ types.VaultModuleAccount, + /* to */ receiver, + sdk.NewCoins( + sdk.NewCoin(denom, amountToWithdraw), + ), + ) +} diff --git a/x/perp/keeper/withdraw_test.go b/x/perp/keeper/withdraw_test.go new file mode 100644 index 000000000..33e082f51 --- /dev/null +++ b/x/perp/keeper/withdraw_test.go @@ -0,0 +1,97 @@ +package keeper + +import ( + "testing" + + sdk "github.com/cosmos/cosmos-sdk/types" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NibiruChain/nibiru/x/perp/types" + "github.com/NibiruChain/nibiru/x/testutil/sample" +) + +func TestWithdraw(t *testing.T) { + tests := []struct { + name string + initialVaultBalance int64 + initialPrepaidBadDebt int64 + amountToWithdraw int64 + + expectedPerpEFWithdrawal int64 + expectedFinalPrepaidBadDebt int64 + }{ + { + name: "no bad debt", + initialVaultBalance: 100, + initialPrepaidBadDebt: 0, + + amountToWithdraw: 10, + + expectedPerpEFWithdrawal: 0, + expectedFinalPrepaidBadDebt: 0, + }, + { + name: "creates prepaid bad debt", + initialVaultBalance: 9, + initialPrepaidBadDebt: 0, + + amountToWithdraw: 10, + + expectedPerpEFWithdrawal: 1, + expectedFinalPrepaidBadDebt: 1, + }, + { + name: "increases existing prepaid bad debt", + initialVaultBalance: 9, + initialPrepaidBadDebt: 1, + + amountToWithdraw: 10, + + expectedPerpEFWithdrawal: 1, + expectedFinalPrepaidBadDebt: 2, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Log("initialize variables") + perpKeeper, mocks, ctx := getKeeper(t) + receiver := sample.AccAddress() + denom := "NUSD" + + t.Log("mock account keeper") + vaultAddr := authtypes.NewModuleAddress(types.VaultModuleAccount) + mocks.mockAccountKeeper.EXPECT().GetModuleAddress( + types.VaultModuleAccount). + Return(vaultAddr) + + t.Log("mock bank keeper") + mocks.mockBankKeeper.EXPECT().GetBalance(ctx, vaultAddr, denom). + Return(sdk.NewInt64Coin(denom, tc.initialVaultBalance)) + mocks.mockBankKeeper.EXPECT().SendCoinsFromModuleToAccount( + ctx, types.VaultModuleAccount, receiver, + sdk.NewCoins(sdk.NewInt64Coin(denom, tc.amountToWithdraw)), + ).Return(nil) + if tc.expectedPerpEFWithdrawal > 0 { + mocks.mockBankKeeper.EXPECT().SendCoinsFromModuleToModule( + ctx, types.PerpEFModuleAccount, types.VaultModuleAccount, + sdk.NewCoins(sdk.NewInt64Coin(denom, tc.expectedPerpEFWithdrawal)), + ).Return(nil) + } + + t.Log("initial prepaid bad debt") + perpKeeper.PrepaidBadDebtState().Set(ctx, denom, sdk.NewInt(tc.initialPrepaidBadDebt)) + + t.Log("execute withdrawal") + err := perpKeeper.Withdraw(ctx, denom, receiver, sdk.NewInt(tc.amountToWithdraw)) + require.NoError(t, err) + + t.Log("assert new prepaid bad debt") + prepaidBadDebt := perpKeeper.PrepaidBadDebtState().Get(ctx, denom) + assert.EqualValues(t, tc.expectedFinalPrepaidBadDebt, prepaidBadDebt.Int64()) + }) + } +}