Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(perp): Add user discounts #1594

Merged
merged 17 commits into from
Sep 29, 2023
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* [#1543](https://github.com/NibiruChain/nibiru/pull/1543) - epic(devgas): devgas module for incentivizing smart contract
* [#1559](https://github.com/NibiruChain/nibiru/pull/1559) - feat: add versions to markets to allow to disable them
* [#1585](https://github.com/NibiruChain/nibiru/pull/1585) - feat: include flag versioned in query markets to allow to query disabled markets

* [#1594](https://github.com/NibiruChain/nibiru/pull/1594) - feat: add user discounts
### Improvements

* [#1466](https://github.com/NibiruChain/nibiru/pull/1466) - refactor(perp): `PositionLiquidatedEvent`
Expand Down
22 changes: 21 additions & 1 deletion proto/nibiru/perp/v2/genesis.proto
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,27 @@ message GenesisState {
];
}

repeated GenesisMarketLastVersion market_last_versions = 8
message Discount {
string fee = 1 [
(gogoproto.customtype) = "github.com/cosmos/cosmos-sdk/types.Dec",
(gogoproto.nullable) = false
];
string volume = 2 [
(gogoproto.customtype) = "github.com/cosmos/cosmos-sdk/types.Int",
(gogoproto.nullable) = false
];
}

repeated Discount global_discount = 8 [ (gogoproto.nullable) = false ];

repeated CustomDiscount custom_discounts = 9 [ (gogoproto.nullable) = false ];
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this proto breakage should be fine since unreleased, so we can group dnr genesis


message CustomDiscount {
string trader = 1;
Discount discount = 2;
}

repeated GenesisMarketLastVersion market_last_versions = 10
[ (gogoproto.nullable) = false ];
}

Expand Down
117 changes: 116 additions & 1 deletion x/perp/v2/integration/action/dnr.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import (
sdk "github.com/cosmos/cosmos-sdk/types"

"github.com/NibiruChain/nibiru/app"
"github.com/NibiruChain/nibiru/x/common/asset"
"github.com/NibiruChain/nibiru/x/common/testutil/action"
"github.com/NibiruChain/nibiru/x/perp/v2/types"
)

func DnREpochIs(epoch uint64) action.Action {
Expand Down Expand Up @@ -66,7 +68,12 @@ type expectPreviousVolumeAction struct {
}

func (e expectPreviousVolumeAction) Do(app *app.NibiruApp, ctx sdk.Context) (outCtx sdk.Context, err error, isMandatory bool) {
v := app.PerpKeeperV2.GetUserVolumeLastEpoch(ctx, e.User)
currentEpoch, err := app.PerpKeeperV2.DnREpoch.Get(ctx)
if err != nil {
return ctx, err, true
}

v := app.PerpKeeperV2.GetTraderVolumeLastEpoch(ctx, currentEpoch, e.User)
if !v.Equal(e.Volume) {
return ctx, fmt.Errorf("unexpected user dnr volume, wanted %s, got %s", e.Volume, v), true
}
Expand All @@ -92,3 +99,111 @@ func (e expectVolumeNotExistAction) Do(app *app.NibiruApp, ctx sdk.Context) (out
}
return ctx, nil, true
}

type marketOrderFeeIs struct {
fee sdk.Dec
*openPositionAction
}

func MarketOrderFeeIs(
fee sdk.Dec,
trader sdk.AccAddress,
pair asset.Pair,
dir types.Direction,
margin math.Int,
leverage sdk.Dec,
baseAssetLimit sdk.Dec,
responseCheckers ...MarketOrderResponseChecker,
) action.Action {
o := openPositionAction{
trader: trader,
pair: pair,
dir: dir,
margin: margin,
leverage: leverage,
baseAssetLimit: baseAssetLimit,
responseCheckers: responseCheckers,
}
return &marketOrderFeeIs{
fee: fee,
openPositionAction: &o,
}
}

func (o *marketOrderFeeIs) Do(app *app.NibiruApp, ctx sdk.Context) (sdk.Context, error, bool) {
balanceBefore := app.BankKeeper.GetBalance(ctx, o.trader, o.pair.QuoteDenom()).Amount
resp, err := app.PerpKeeperV2.MarketOrder(
ctx, o.pair, o.dir, o.trader,
o.margin, o.leverage, o.baseAssetLimit,
)
if err != nil {
return ctx, err, true
}

balanceBefore = balanceBefore.Sub(resp.MarginToVault.TruncateInt())

expectedFee := math.LegacyNewDecFromInt(o.margin).Mul(o.fee.Add(sdk.MustNewDecFromStr("0.001"))) // we add the ecosystem fund fee
balanceAfter := app.BankKeeper.GetBalance(ctx, o.trader, o.pair.QuoteDenom()).Amount
paidFees := balanceBefore.Sub(balanceAfter)
if !paidFees.Equal(expectedFee.TruncateInt()) {
return ctx, fmt.Errorf("unexpected fee, wanted %s, got %s", expectedFee, paidFees), true
}
return ctx, nil, true
}

func SetPreviousEpochUserVolume(user sdk.AccAddress, volume math.Int) action.Action {
return &setPreviousEpochUserVolumeAction{
user: user,
volume: volume,
}
}

type setPreviousEpochUserVolumeAction struct {
user sdk.AccAddress
volume math.Int
}

func (s setPreviousEpochUserVolumeAction) Do(app *app.NibiruApp, ctx sdk.Context) (outCtx sdk.Context, err error, isMandatory bool) {
currentEpoch, err := app.PerpKeeperV2.DnREpoch.Get(ctx)
if err != nil {
return ctx, err, true
}
app.PerpKeeperV2.TraderVolumes.Insert(ctx, collections.Join(s.user, currentEpoch-1), s.volume)
return ctx, nil, true
}

func SetGlobalDiscount(fee sdk.Dec, volume math.Int) action.Action {
return &setGlobalDiscountAction{
fee: fee,
volume: volume,
}
}

type setGlobalDiscountAction struct {
fee sdk.Dec
volume math.Int
}

func (s setGlobalDiscountAction) Do(app *app.NibiruApp, ctx sdk.Context) (outCtx sdk.Context, err error, isMandatory bool) {
app.PerpKeeperV2.GlobalDiscounts.Insert(ctx, s.volume, s.fee)
return ctx, nil, true
}

func SetCustomDiscount(user sdk.AccAddress, fee sdk.Dec, volume math.Int) action.Action {
return &setCustomDiscountAction{
fee: fee,
volume: volume,
user: user,
}
}

type setCustomDiscountAction struct {
fee sdk.Dec
volume math.Int
user sdk.AccAddress
}

func (s *setCustomDiscountAction) Do(app *app.NibiruApp, ctx sdk.Context) (outCtx sdk.Context, err error, isMandatory bool) {
app.PerpKeeperV2.TraderDiscounts.Insert(ctx, collections.Join(s.user, s.volume), s.fee)
return ctx, nil, true
}
11 changes: 4 additions & 7 deletions x/perp/v2/keeper/clearing_house.go
Original file line number Diff line number Diff line change
Expand Up @@ -554,13 +554,6 @@ func (k Keeper) afterPositionUpdate(
}
}

// update user volume
dnrEpoch, err := k.DnREpoch.Get(ctx)
if err != nil {
return err
}
k.IncreaseTraderVolume(ctx, dnrEpoch, traderAddr, positionResp.ExchangedNotionalValue.Abs().TruncateInt())

transferredFee, err := k.transferFee(ctx, market.Pair, traderAddr, positionResp.ExchangedNotionalValue,
market.ExchangeFeeRatio, market.EcosystemFundFeeRatio,
)
Expand Down Expand Up @@ -644,6 +637,10 @@ func (k Keeper) transferFee(
exchangeFeeRatio sdk.Dec,
ecosystemFundFeeRatio sdk.Dec,
) (fees sdkmath.Int, err error) {
exchangeFeeRatio, err = k.applyDiscountAndRebate(ctx, pair, trader, positionNotional, exchangeFeeRatio)
if err != nil {
return sdkmath.Int{}, err
}
feeToExchangeFeePool := exchangeFeeRatio.Mul(positionNotional).RoundInt()
if feeToExchangeFeePool.IsPositive() {
if err = k.BankKeeper.SendCoinsFromAccountToModule(
Expand Down
137 changes: 113 additions & 24 deletions x/perp/v2/keeper/dnr.go
Original file line number Diff line number Diff line change
@@ -1,45 +1,74 @@
package keeper

import (
"math/big"

"cosmossdk.io/math"
"github.com/NibiruChain/collections"
sdk "github.com/cosmos/cosmos-sdk/types"

"github.com/NibiruChain/nibiru/x/common/asset"
)

// DnRGCFrequency is the frequency at which the DnR garbage collector runs.
const DnRGCFrequency = 1000

// IntValueEncoder instructs collections on how to encode a math.Int.
// IntValueEncoder instructs collections on how to encode a math.Int as a value.
// TODO: move to collections.
var IntValueEncoder collections.ValueEncoder[math.Int] = intValueEncoder{}

// IntKeyEncoder instructs collections on how to encode a math.Int as a key.
// NOTE: unsafe to use as the first part of a composite key.
var IntKeyEncoder collections.KeyEncoder[math.Int] = intKeyEncoder{}

type intValueEncoder struct{}

func (i intValueEncoder) Encode(value math.Int) []byte {
v, err := value.Marshal()
if err != nil {
panic(err)
}
return v
func (intValueEncoder) Encode(value math.Int) []byte {
return IntKeyEncoder.Encode(value)
}

func (i intValueEncoder) Decode(b []byte) math.Int {
var v math.Int
err := v.Unmarshal(b)
if err != nil {
panic(err)
}
return v
func (intValueEncoder) Decode(b []byte) math.Int {
_, got := IntKeyEncoder.Decode(b)
return got
}

func (i intValueEncoder) Stringify(value math.Int) string {
return value.String()
func (intValueEncoder) Stringify(value math.Int) string {
return IntKeyEncoder.Stringify(value)
}

func (i intValueEncoder) Name() string {
func (intValueEncoder) Name() string {
return "math.Int"
}

type intKeyEncoder struct{}

const maxIntKeyLen = math.MaxBitLen / 8

func (intKeyEncoder) Encode(key math.Int) []byte {
if key.IsNil() {
panic("cannot encode invalid math.Int")
}
if key.IsNegative() {
panic("cannot encode negative math.Int")
}
i := key.BigInt()

be := i.Bytes()
padded := make([]byte, maxIntKeyLen)
copy(padded[maxIntKeyLen-len(be):], be)
return padded
}

func (intKeyEncoder) Decode(b []byte) (int, math.Int) {
if len(b) != maxIntKeyLen {
panic("invalid key length")
}
i := new(big.Int).SetBytes(b)
return maxIntKeyLen, math.NewIntFromBigInt(i)
}

func (intKeyEncoder) Stringify(key math.Int) string { return key.String() }

// IncreaseTraderVolume adds the volume to the user's volume for the current epoch.
func (k Keeper) IncreaseTraderVolume(ctx sdk.Context, currentEpoch uint64, user sdk.AccAddress, volume math.Int) {
currentVolume := k.TraderVolumes.GetOr(ctx, collections.Join(user, currentEpoch), math.ZeroInt())
Expand Down Expand Up @@ -68,18 +97,78 @@ func (k Keeper) gcUserVolume(ctx sdk.Context, user sdk.AccAddress, currentEpoch
}
}

// GetUserVolumeLastEpoch returns the user's volume for the last epoch.
// GetTraderVolumeLastEpoch returns the user's volume for the last epoch.
// Returns zero if the user has no volume for the last epoch.
func (k Keeper) GetUserVolumeLastEpoch(ctx sdk.Context, user sdk.AccAddress) math.Int {
currentEpoch, err := k.DnREpoch.Get(ctx)
if err != nil {
// a DnR epoch should always exist, otherwise it means the chain was not initialized properly.
panic(err)
}
func (k Keeper) GetTraderVolumeLastEpoch(ctx sdk.Context, currentEpoch uint64, user sdk.AccAddress) math.Int {
// if it's the first epoch, we do not have any user volume.
if currentEpoch == 0 {
return math.ZeroInt()
}
// return the user's volume for the last epoch, or zero.
return k.TraderVolumes.GetOr(ctx, collections.Join(user, currentEpoch-1), math.ZeroInt())
}

// GetTraderDiscount will check if the trader has a custom discount for the given volume.
// If it does not have a custom discount, it will return the global discount for the given volume.
// The discount is the nearest left entry of the trader volume.
func (k Keeper) GetTraderDiscount(ctx sdk.Context, trader sdk.AccAddress, volume math.Int) (math.LegacyDec, bool) {
// we try to see if the trader has a custom discount.
customDiscountRng := collections.PairRange[sdk.AccAddress, math.Int]{}.
Prefix(trader).
EndInclusive(volume).
Descending()

customDiscount := k.TraderDiscounts.Iterate(ctx, customDiscountRng)
defer customDiscount.Close()

if customDiscount.Valid() {
return customDiscount.Value(), true
}

// if it does not have a custom discount we try with global ones
globalDiscountRng := collections.Range[math.Int]{}.
EndInclusive(volume).
Descending()

globalDiscounts := k.GlobalDiscounts.Iterate(ctx, globalDiscountRng)
defer globalDiscounts.Close()

if globalDiscounts.Valid() {
return globalDiscounts.Value(), true
}
return math.LegacyZeroDec(), false
}

// applyDiscountAndRebate applies the discount and rebate to the given exchange fee ratio.
// It updates the current epoch trader volume.
// It returns the new exchange fee ratio.
func (k Keeper) applyDiscountAndRebate(
ctx sdk.Context,
_ asset.Pair,
trader sdk.AccAddress,
positionNotional math.LegacyDec,
feeRatio sdk.Dec,
) (sdk.Dec, error) {
// update user volume
dnrEpoch, err := k.DnREpoch.Get(ctx)
if err != nil {
return feeRatio, err
}
k.IncreaseTraderVolume(ctx, dnrEpoch, trader, positionNotional.Abs().TruncateInt())

// get past epoch volume
pastVolume := k.GetTraderVolumeLastEpoch(ctx, dnrEpoch, trader)
// if the trader has no volume for the last epoch, we return the provided fee ratios.
if pastVolume.IsZero() {
return feeRatio, nil
}

// try to apply discount
discountedFeeRatio, hasDiscount := k.GetTraderDiscount(ctx, trader, pastVolume)
// if the trader does not have any discount, we return the provided fee ratios.
if !hasDiscount {
return feeRatio, nil
}
// return discounted fee ratios
return discountedFeeRatio, nil
}
Loading