Skip to content

Commit

Permalink
feat(perp): Add user discounts (#1594)
Browse files Browse the repository at this point in the history
* add discounts

* impl genesis

* fix test

* make test more robust

* galaxy brain int encoding fix

* test and optimise

* lint

* CHANGELOG.md

* do merge

* clarify fee variable names in applyDiscountAndRebate func

* refactor dnr_test.go as per PR review

* add genesis tests

---------

Co-authored-by: unknown unknown <unknown@unknown>
Co-authored-by: godismercilex <[email protected]>
Co-authored-by: Jonathan Gimeno <[email protected]>
  • Loading branch information
4 people authored Sep 29, 2023
1 parent 0c31eaf commit 6e534c9
Show file tree
Hide file tree
Showing 11 changed files with 1,099 additions and 77 deletions.
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 ];

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

0 comments on commit 6e534c9

Please sign in to comment.