diff --git a/protocol/lib/vault/utils.go b/protocol/lib/vault/utils.go new file mode 100644 index 00000000000..81622443323 --- /dev/null +++ b/protocol/lib/vault/utils.go @@ -0,0 +1,51 @@ +package vault + +import ( + "math/big" + + "github.com/dydxprotocol/v4-chain/protocol/lib" + pricestypes "github.com/dydxprotocol/v4-chain/protocol/x/prices/types" + "github.com/dydxprotocol/v4-chain/protocol/x/vault/types" +) + +// SkewAntiderivativePpm returns the antiderivative of skew given a vault's skew +// factor and leverage. +// skew_antiderivative_ppm = skew_factor * leverage^2 + skew_factor^2 * leverage^3 / 3 +func SkewAntiderivativePpm( + skewFactorPpm uint32, + leveragePpm *big.Int, +) *big.Int { + bigSkewFactorPpm := new(big.Int).SetUint64(uint64(skewFactorPpm)) + bigOneTrillion := lib.BigIntOneTrillion() + + // a = skew_factor * leverage^2. + a := new(big.Int).Mul(leveragePpm, leveragePpm) + a.Mul(a, bigSkewFactorPpm) + + // b = skew_factor^2 * leverage^3 / 3. + b := new(big.Int).Set(a) + b.Mul(b, leveragePpm) + b.Mul(b, bigSkewFactorPpm) + b = lib.BigDivCeil(b, big.NewInt(3)) + + // normalize a and b. + a = lib.BigDivCeil(a, bigOneTrillion) + b = lib.BigDivCeil(b, bigOneTrillion) + b = lib.BigDivCeil(b, bigOneTrillion) + + // return a + b. + return a.Add(a, b) +} + +// SpreadPpm returns the spread that a vault should quote at given its +// quoting params and corresponding market param. +// spread_ppm = max(spread_min_ppm, spread_buffer_ppm + min_price_change_ppm) +func SpreadPpm( + quotingParams *types.QuotingParams, + marketParam *pricestypes.MarketParam, +) uint32 { + return lib.Max( + quotingParams.SpreadMinPpm, + quotingParams.SpreadBufferPpm+marketParam.MinPriceChangePpm, + ) +} diff --git a/protocol/lib/vault/utils_test.go b/protocol/lib/vault/utils_test.go new file mode 100644 index 00000000000..1f8dc02ff3c --- /dev/null +++ b/protocol/lib/vault/utils_test.go @@ -0,0 +1,118 @@ +package vault_test + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/dydxprotocol/v4-chain/protocol/lib/vault" + pricestypes "github.com/dydxprotocol/v4-chain/protocol/x/prices/types" + "github.com/dydxprotocol/v4-chain/protocol/x/vault/types" +) + +func TestSkewAntiderivativePpm(t *testing.T) { + tests := map[string]struct { + skewFactorPpm uint32 + leveragePpm *big.Int + expected *big.Int + }{ + "Zero skew factor and leverage": { + skewFactorPpm: 0, + leveragePpm: big.NewInt(0), + expected: big.NewInt(0), + }, + "Non-zero skew factor, zero leverage": { + skewFactorPpm: 1_000_000, + leveragePpm: big.NewInt(0), + expected: big.NewInt(0), + }, + "Zero skew factor, non-zero leverage": { + skewFactorPpm: 0, + leveragePpm: big.NewInt(1_000_000), + expected: big.NewInt(0), + }, + "Small skew factor and small positive leverage": { + skewFactorPpm: 500_000, // 0.5 + leveragePpm: big.NewInt(800_000), // 0.8 + // 0.5 * 0.8^2 + 0.5^2 * 0.8^3 / 3 ~= 0.362666 + // round up to 0.362667 + expected: big.NewInt(362_667), + }, + "Small skew factor and small negative leverage": { + skewFactorPpm: 500_000, // 0.5 + leveragePpm: big.NewInt(-800_000), // -0.8 + // 0.5 * (-0.8)^2 + 0.5^2 * (-0.8)^3 / 3 ~= 0.277333 + // round up to 0.277334 + expected: big.NewInt(277_334), + }, + "Large skew factor and large positive leverage": { + skewFactorPpm: 5_000_000, // 5 + leveragePpm: big.NewInt(8_700_000), // 8.7 + // 5 * (8.7)^2 + 5^2 * (8.7)^3 / 3 = 5865.975 + expected: big.NewInt(5_865_975_000), + }, + "Large skew factor and large negative leverage": { + skewFactorPpm: 5_000_000, // 5 + leveragePpm: big.NewInt(-8_700_000), // -8.7 + // 5 * (-8.7)^2 + 5^2 * (-8.7)^3 / 3 = -5109.075 + expected: big.NewInt(-5_109_075_000), + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + actual := vault.SkewAntiderivativePpm(tc.skewFactorPpm, tc.leveragePpm) + require.Equal(t, tc.expected, actual) + }) + } +} + +func TestSpreadPpm(t *testing.T) { + tests := map[string]struct { + quotingParams *types.QuotingParams + marketParam *pricestypes.MarketParam + expected uint32 + }{ + "SpreadMinPpm > SpreadBufferPpm + MinPriceChangePpm": { + quotingParams: &types.QuotingParams{ + SpreadMinPpm: 1000, + SpreadBufferPpm: 200, + }, + marketParam: &pricestypes.MarketParam{ + MinPriceChangePpm: 500, + }, + expected: 1000, + }, + "SpreadMinPpm < SpreadBufferPpm + MinPriceChangePpm": { + quotingParams: &types.QuotingParams{ + SpreadMinPpm: 1000, + SpreadBufferPpm: 600, + }, + marketParam: &pricestypes.MarketParam{ + MinPriceChangePpm: 500, + }, + expected: 1100, + }, + "SpreadMinPpm = SpreadBufferPpm + MinPriceChangePpm": { + quotingParams: &types.QuotingParams{ + SpreadMinPpm: 1000, + SpreadBufferPpm: 400, + }, + marketParam: &pricestypes.MarketParam{ + MinPriceChangePpm: 600, + }, + expected: 1000, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + require.Equal( + t, + tc.expected, + vault.SpreadPpm(tc.quotingParams, tc.marketParam), + ) + }) + } +} diff --git a/protocol/mocks/PerpetualsKeeper.go b/protocol/mocks/PerpetualsKeeper.go index c632ae3ad09..fb83519c539 100644 --- a/protocol/mocks/PerpetualsKeeper.go +++ b/protocol/mocks/PerpetualsKeeper.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.43.2. DO NOT EDIT. +// Code generated by mockery v2.44.1. DO NOT EDIT. package mocks @@ -122,6 +122,34 @@ func (_m *PerpetualsKeeper) GetAllPerpetuals(ctx types.Context) []perpetualstype return r0 } +// GetLiquidityTier provides a mock function with given fields: ctx, id +func (_m *PerpetualsKeeper) GetLiquidityTier(ctx types.Context, id uint32) (perpetualstypes.LiquidityTier, error) { + ret := _m.Called(ctx, id) + + if len(ret) == 0 { + panic("no return value specified for GetLiquidityTier") + } + + var r0 perpetualstypes.LiquidityTier + var r1 error + if rf, ok := ret.Get(0).(func(types.Context, uint32) (perpetualstypes.LiquidityTier, error)); ok { + return rf(ctx, id) + } + if rf, ok := ret.Get(0).(func(types.Context, uint32) perpetualstypes.LiquidityTier); ok { + r0 = rf(ctx, id) + } else { + r0 = ret.Get(0).(perpetualstypes.LiquidityTier) + } + + if rf, ok := ret.Get(1).(func(types.Context, uint32) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetNetCollateral provides a mock function with given fields: ctx, id, bigQuantums func (_m *PerpetualsKeeper) GetNetCollateral(ctx types.Context, id uint32, bigQuantums *big.Int) (*big.Int, error) { ret := _m.Called(ctx, id, bigQuantums) diff --git a/protocol/x/vault/keeper/orders.go b/protocol/x/vault/keeper/orders.go index 2ed4c48bff4..cfba4b7deed 100644 --- a/protocol/x/vault/keeper/orders.go +++ b/protocol/x/vault/keeper/orders.go @@ -1,6 +1,7 @@ package keeper import ( + "errors" "fmt" "math" "math/big" @@ -11,6 +12,7 @@ import ( "github.com/dydxprotocol/v4-chain/protocol/lib" "github.com/dydxprotocol/v4-chain/protocol/lib/log" "github.com/dydxprotocol/v4-chain/protocol/lib/metrics" + "github.com/dydxprotocol/v4-chain/protocol/lib/vault" clobtypes "github.com/dydxprotocol/v4-chain/protocol/x/clob/types" "github.com/dydxprotocol/v4-chain/protocol/x/vault/types" ) @@ -154,31 +156,11 @@ func (k Keeper) GetVaultClobOrders( vaultId types.VaultId, ) (orders []*clobtypes.Order, err error) { // Get clob pair, perpetual, market parameter, and market price that correspond to this vault. - clobPair, exists := k.clobKeeper.GetClobPair(ctx, clobtypes.ClobPairId(vaultId.Number)) - if !exists || clobPair.Status == clobtypes.ClobPair_STATUS_FINAL_SETTLEMENT { + clobPair, perpetual, marketParam, marketPrice, err := k.GetVaultClobPerpAndMarket(ctx, vaultId) + if errors.Is(err, types.ErrClobPairNotFound) || clobPair.Status == clobtypes.ClobPair_STATUS_FINAL_SETTLEMENT { return []*clobtypes.Order{}, nil - } - perpId := clobPair.Metadata.(*clobtypes.ClobPair_PerpetualClobMetadata).PerpetualClobMetadata.PerpetualId - perpetual, err := k.perpetualsKeeper.GetPerpetual(ctx, perpId) - if err != nil { - return orders, errorsmod.Wrap( - err, - fmt.Sprintf("VaultId: %v", vaultId), - ) - } - marketParam, exists := k.pricesKeeper.GetMarketParam(ctx, perpetual.Params.MarketId) - if !exists { - return orders, errorsmod.Wrap( - types.ErrMarketParamNotFound, - fmt.Sprintf("VaultId: %v", vaultId), - ) - } - marketPrice, err := k.pricesKeeper.GetMarketPrice(ctx, perpetual.Params.MarketId) - if err != nil { - return orders, errorsmod.Wrap( - err, - fmt.Sprintf("VaultId: %v", vaultId), - ) + } else if err != nil { + return orders, err } else if marketPrice.Price == 0 { // Market price can be zero upon market initialization or due to invalid exchange config. return orders, errorsmod.Wrap( @@ -187,26 +169,11 @@ func (k Keeper) GetVaultClobOrders( ) } - // Calculate leverage = open notional / equity. - equity, err := k.GetVaultEquity(ctx, vaultId) + // Get vault leverage and equity. + leveragePpm, equity, err := k.GetVaultLeverageAndEquity(ctx, vaultId, perpetual, marketPrice) if err != nil { return orders, err } - if equity.Sign() <= 0 { - return orders, errorsmod.Wrap( - types.ErrNonPositiveEquity, - fmt.Sprintf("VaultId: %v", vaultId), - ) - } - inventory := k.GetVaultInventoryInPerpetual(ctx, vaultId, perpId) - openNotional := lib.BaseToQuoteQuantums( - inventory, - perpetual.Params.AtomicResolution, - marketPrice.GetPrice(), - marketPrice.GetExponent(), - ) - leveragePpm := new(big.Int).Mul(openNotional, lib.BigIntOneMillion()) - leveragePpm.Quo(leveragePpm, equity) // Get vault parameters. quotingParams, exists := k.GetVaultQuotingParams(ctx, vaultId) @@ -247,10 +214,7 @@ func (k Keeper) GetVaultClobOrders( } // Calculate spread. - spreadPpm := lib.BigU(lib.Max( - quotingParams.SpreadMinPpm, - quotingParams.SpreadBufferPpm+marketParam.MinPriceChangePpm, - )) + spreadPpm := lib.BigU(vault.SpreadPpm("ingParams, &marketParam)) // Get oracle price in subticks. oracleSubticks := clobtypes.PriceToSubticks( marketPrice, diff --git a/protocol/x/vault/keeper/vault.go b/protocol/x/vault/keeper/vault.go index db92ea56a80..f99c53037c5 100644 --- a/protocol/x/vault/keeper/vault.go +++ b/protocol/x/vault/keeper/vault.go @@ -1,12 +1,17 @@ package keeper import ( + "fmt" "math/big" errorsmod "cosmossdk.io/errors" "cosmossdk.io/store/prefix" sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/dydxprotocol/v4-chain/protocol/lib" "github.com/dydxprotocol/v4-chain/protocol/lib/log" + clobtypes "github.com/dydxprotocol/v4-chain/protocol/x/clob/types" + perptypes "github.com/dydxprotocol/v4-chain/protocol/x/perpetuals/types" + pricestypes "github.com/dydxprotocol/v4-chain/protocol/x/prices/types" satypes "github.com/dydxprotocol/v4-chain/protocol/x/subaccounts/types" "github.com/dydxprotocol/v4-chain/protocol/x/vault/types" ) @@ -56,6 +61,42 @@ func (k Keeper) GetVaultEquity( return k.GetSubaccountEquity(ctx, *vaultId.ToSubaccountId()) } +// GetVaultLeverageAndEquity returns a vault's leverage and equity. +// - leverage = open notional / equity. +func (k Keeper) GetVaultLeverageAndEquity( + ctx sdk.Context, + vaultId types.VaultId, + perpetual perptypes.Perpetual, + marketPrice pricestypes.MarketPrice, +) ( + leveragePpm *big.Int, + equity *big.Int, + err error, +) { + equity, err = k.GetVaultEquity(ctx, vaultId) + if err != nil { + return nil, nil, err + } + if equity.Sign() <= 0 { + return nil, equity, errorsmod.Wrap( + types.ErrNonPositiveEquity, + fmt.Sprintf("VaultId: %v", vaultId), + ) + } + + inventory := k.GetVaultInventoryInPerpetual(ctx, vaultId, perpetual.GetId()) + openNotional := lib.BaseToQuoteQuantums( + inventory, + perpetual.Params.AtomicResolution, + marketPrice.GetPrice(), + marketPrice.GetExponent(), + ) + leveragePpm = new(big.Int).Mul(openNotional, lib.BigIntOneMillion()) + leveragePpm.Quo(leveragePpm, equity) + + return leveragePpm, equity, nil +} + // GetSubaccountEquity returns the equity of a subaccount (in quote quantums). func (k Keeper) GetSubaccountEquity( ctx sdk.Context, @@ -92,6 +133,51 @@ func (k Keeper) GetVaultInventoryInPerpetual( return inventory } +// GetVaultClobPerpAndMarket returns the clob pair, perpetual, market param, and market price +// that correspond to a vault. +func (k Keeper) GetVaultClobPerpAndMarket( + ctx sdk.Context, + vaultId types.VaultId, +) ( + clobPair clobtypes.ClobPair, + perpetual perptypes.Perpetual, + marketParam pricestypes.MarketParam, + marketPrice pricestypes.MarketPrice, + err error, +) { + clobPair, exists := k.clobKeeper.GetClobPair(ctx, clobtypes.ClobPairId(vaultId.Number)) + if !exists { + return clobPair, perpetual, marketParam, marketPrice, errorsmod.Wrap( + types.ErrClobPairNotFound, + fmt.Sprintf("VaultId: %v", vaultId), + ) + } + perpId := clobPair.Metadata.(*clobtypes.ClobPair_PerpetualClobMetadata).PerpetualClobMetadata.PerpetualId + perpetual, err = k.perpetualsKeeper.GetPerpetual(ctx, perpId) + if err != nil { + return clobPair, perpetual, marketParam, marketPrice, errorsmod.Wrap( + err, + fmt.Sprintf("VaultId: %v", vaultId), + ) + } + marketParam, exists = k.pricesKeeper.GetMarketParam(ctx, perpetual.Params.MarketId) + if !exists { + return clobPair, perpetual, marketParam, marketPrice, errorsmod.Wrap( + types.ErrMarketParamNotFound, + fmt.Sprintf("VaultId: %v", vaultId), + ) + } + marketPrice, err = k.pricesKeeper.GetMarketPrice(ctx, perpetual.Params.MarketId) + if err != nil { + return clobPair, perpetual, marketParam, marketPrice, errorsmod.Wrap( + err, + fmt.Sprintf("VaultId: %v", vaultId), + ) + } + + return clobPair, perpetual, marketParam, marketPrice, nil +} + // DecommissionVaults decommissions all deactivated vaults that have non-positive equities. func (k Keeper) DecommissionNonPositiveEquityVaults( ctx sdk.Context, diff --git a/protocol/x/vault/keeper/withdraw.go b/protocol/x/vault/keeper/withdraw.go new file mode 100644 index 00000000000..45916d421de --- /dev/null +++ b/protocol/x/vault/keeper/withdraw.go @@ -0,0 +1,105 @@ +package keeper + +import ( + "math/big" + + errorsmod "cosmossdk.io/errors" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/dydxprotocol/v4-chain/protocol/lib" + "github.com/dydxprotocol/v4-chain/protocol/lib/vault" + "github.com/dydxprotocol/v4-chain/protocol/x/vault/types" +) + +// GetVaultWithdrawalSlippagePpm returns the slippage (in ppm) that should be incurred +// on withdrawing `withdrawalPortionPpm` of a vault's ownership. +// For example, if `withdrawalPortionPpm = 100_000` and `200_000` is returned, +// it means that withdrawing 10% has a 20% slippage for the specified vault. +// +// Slippage is calculated as `min(simple_slippage, estimated_slippage)` where: +// - simple_slippage = leverage * initial_margin +// - estimated_slippage = spread * (1 + average_skew) * leverage +// - average_skew = integral / (posterior_leverage - leverage) +// - integral = skew_antiderivative(skew_factor, posterior_leverage) - +// skew_antiderivative(skew_factor, leverage) +// - posterior_leverage = leverage / (1 - withdrawal_portion) +func (k Keeper) GetVaultWithdrawalSlippagePpm( + ctx sdk.Context, + vaultId types.VaultId, + withdrawalPortionPpm *big.Int, +) (*big.Int, error) { + bigOneMillion := lib.BigIntOneMillion() + if withdrawalPortionPpm.Sign() <= 0 || withdrawalPortionPpm.Cmp(bigOneMillion) > 0 { + return nil, errorsmod.Wrapf( + types.ErrInvalidWithdrawalPortion, + "withdrawalPortionPpm: %s", + withdrawalPortionPpm, + ) + } + + quotingParams, exists := k.GetVaultQuotingParams(ctx, vaultId) + if !exists { + return nil, types.ErrVaultParamsNotFound + } + + _, perpetual, marketParam, marketPrice, err := k.GetVaultClobPerpAndMarket(ctx, vaultId) + if err != nil { + return nil, err + } + + // Get vault leverage. + leveragePpm, _, err := k.GetVaultLeverageAndEquity(ctx, vaultId, perpetual, marketPrice) + if err != nil { + return nil, err + } + if leveragePpm.Sign() == 0 { + return big.NewInt(0), nil + } + + // Calculate simple_slippage = leverage * initial_margin. + lt, err := k.perpetualsKeeper.GetLiquidityTier(ctx, perpetual.Params.LiquidityTier) + if err != nil { + return nil, err + } + simpleSlippagePpm := lib.BigIntMulPpm( + leveragePpm, + lt.InitialMarginPpm, + ) + // Return simple slippage if withdrawing 100%. + if withdrawalPortionPpm.Cmp(bigOneMillion) == 0 { + return simpleSlippagePpm, nil + } + + // Estimate slippage. + // 1. Calculate leverage_after_withdrawal = leverage / (1 - withdrawal_portion) + posteriorLeveragePpm := new(big.Int).Mul(leveragePpm, bigOneMillion) + posteriorLeveragePpm = lib.BigDivCeil( + posteriorLeveragePpm, + new(big.Int).Sub(bigOneMillion, withdrawalPortionPpm), + ) + + // 2. integral = skew_antiderivative(skew_factor, posterior_leverage) - skew_antiderivative(skew_factor, leverage) + estimatedSlippagePpm := vault.SkewAntiderivativePpm(quotingParams.SkewFactorPpm, posteriorLeveragePpm) + estimatedSlippagePpm.Sub(estimatedSlippagePpm, vault.SkewAntiderivativePpm(quotingParams.SkewFactorPpm, leveragePpm)) + + // 3. average_skew = integral / (posterior_leverage - leverage) + estimatedSlippagePpm.Mul(estimatedSlippagePpm, bigOneMillion) + estimatedSlippagePpm = lib.BigDivCeil( + estimatedSlippagePpm, + posteriorLeveragePpm.Sub(posteriorLeveragePpm, leveragePpm), + ) + + // 4. slippage = spread * (1 + average_skew) * leverage + estimatedSlippagePpm.Add(estimatedSlippagePpm, bigOneMillion) + estimatedSlippagePpm.Mul(estimatedSlippagePpm, leveragePpm) + estimatedSlippagePpm = lib.BigIntMulPpm( + estimatedSlippagePpm, + vault.SpreadPpm("ingParams, &marketParam), + ) + estimatedSlippagePpm = lib.BigDivCeil(estimatedSlippagePpm, bigOneMillion) + + // Return min(simple_slippage, estimated_slippage). + return lib.BigMin( + simpleSlippagePpm, + estimatedSlippagePpm, + ), nil +} diff --git a/protocol/x/vault/keeper/withdraw_test.go b/protocol/x/vault/keeper/withdraw_test.go new file mode 100644 index 00000000000..da5d5878938 --- /dev/null +++ b/protocol/x/vault/keeper/withdraw_test.go @@ -0,0 +1,394 @@ +package keeper_test + +import ( + "math/big" + "testing" + + "github.com/cometbft/cometbft/types" + "github.com/dydxprotocol/v4-chain/protocol/dtypes" + testapp "github.com/dydxprotocol/v4-chain/protocol/testutil/app" + "github.com/dydxprotocol/v4-chain/protocol/testutil/constants" + testutil "github.com/dydxprotocol/v4-chain/protocol/testutil/util" + assettypes "github.com/dydxprotocol/v4-chain/protocol/x/assets/types" + clobtypes "github.com/dydxprotocol/v4-chain/protocol/x/clob/types" + perptypes "github.com/dydxprotocol/v4-chain/protocol/x/perpetuals/types" + pricestypes "github.com/dydxprotocol/v4-chain/protocol/x/prices/types" + satypes "github.com/dydxprotocol/v4-chain/protocol/x/subaccounts/types" + vaulttypes "github.com/dydxprotocol/v4-chain/protocol/x/vault/types" + "github.com/stretchr/testify/require" +) + +func TestGetVaultWithdrawalSlippagePpm(t *testing.T) { + testVaultId := constants.Vault_Clob1 + testClobPair := constants.ClobPair_Eth + testPerpetual := constants.EthUsd_20PercentInitial_10PercentMaintenance + testMarketParam := constants.TestMarketParams[1] + testMarketPrice := constants.TestMarketPrices[1] + + tests := map[string]struct { + /* --- Setup --- */ + // skew. + skewFactorPpm uint32 + // spread. + spreadMinPpm uint32 + spreadBufferPpm uint32 + minPriceChangePpm uint32 + // leverage. + assetQuoteQuantums *big.Int + positionBaseQuantums *big.Int + // function input. + vaultId vaulttypes.VaultId + withdrawalPortionPpm *big.Int + /* --- Expectations --- */ + expectedSlippagePpm *big.Int + expectedErr string + }{ + "Success: leverage 0, skew 2, spread 0.003, withdraw 10%": { + skewFactorPpm: 2_000_000, + spreadMinPpm: 2_000, + spreadBufferPpm: 1_500, + minPriceChangePpm: 1_500, + assetQuoteQuantums: big.NewInt(1_000_000_000), // 1,000 USDC + positionBaseQuantums: big.NewInt(0), + vaultId: testVaultId, + withdrawalPortionPpm: big.NewInt(100_000), // 10% + // leverage 0 should yield no fee. + expectedSlippagePpm: big.NewInt(0), + }, + "Success: leverage 1.5, skew 2, spread 0.003, withdraw the smallest portion 0.0001%": { + skewFactorPpm: 2_000_000, + spreadMinPpm: 2_000, + spreadBufferPpm: 1_500, + minPriceChangePpm: 1_500, + assetQuoteQuantums: big.NewInt(-1_000_000_000), // -1,000 USDC + positionBaseQuantums: big.NewInt(1_000_000_000), // 1 ETH + vaultId: testVaultId, + withdrawalPortionPpm: big.NewInt(1), // 0.0001% + // open_notional = 1_000_000_000 * 10^-9 * 3_000 * 10^6 = 3_000_000_000 + // leverage = 3_000_000_000 / (-1_000_000_000 + 3_000_000_000) = 1.5 + // posterior_leverage = 1.5 / (1 - 0.000001) = 1.5000015 ~= 1.500002 + // integral + // = skew_antiderivative(skew_factor, posterior_leverage) - skew_antiderivative(skew_factor, leverage) + // = 2 * 1.500002^2 + 2^2 * 1.500002^3 / 3 - (2 * 1.5^2 + 2^2 * 1.5^3 / 3) + // = 9.000032 - 9 + // = 0.000032 + // average_skew = 0.000032 / (1.500002 - 1.5) = 16 + // fee_bps = spread * (1 + average_skew) * leverage + // = 0.003 * (1 + 16) * 1.5 + // = 0.0765 + // fee_bps = min(0.0765, leverage * imf) + // = min(0.0765, 1.5 * 0.2) = 0.0765 + expectedSlippagePpm: big.NewInt(76_500), + }, + "Success: leverage 1.5, skew 3, spread 0.003, withdraw 10%": { + skewFactorPpm: 3_000_000, + spreadMinPpm: 2_000, + spreadBufferPpm: 1_500, + minPriceChangePpm: 1_500, + assetQuoteQuantums: big.NewInt(-1_000_000_000), // -1,000 USDC + positionBaseQuantums: big.NewInt(1_000_000_000), // 1 ETH + vaultId: testVaultId, + withdrawalPortionPpm: big.NewInt(100_000), // 10% + // open_notional = 1_000_000_000 * 10^-9 * 3_000 * 10^6 = 3_000_000_000 + // leverage = 3_000_000_000 / (-1_000_000_000 + 3_000_000_000) = 1.5 + // posterior_leverage = 1.5 / (1 - 0.1) ~= 1.666667 + // integral + // = skew_antiderivative(skew_factor, posterior_leverage) - skew_antiderivative(skew_factor, leverage) + // = 3 * 1.666667^2 + 3^2 * 1.666667^3 / 3 - (3 * 1.5^2 + 3^2 * 1.5^3 / 3) + // = 22.222234 - 16.875 + // = 5.347234 + // average_skew = 5.347234 / (1.666667 - 1.5) ~= 32.083340 + // fee_bps = spread * (1 + average_skew) * leverage + // = 0.003 * (1 + 32.083340) * 1.5 + // ~= 0.148876 + // fee_bps = min(0.148876, leverage * imf) + // = min(0.148876, 1.5 * 0.2) = 0.148876 + expectedSlippagePpm: big.NewInt(148_876), + }, + "Success: leverage 1.5, skew 3, spread 0.003, withdraw 50%": { + skewFactorPpm: 3_000_000, + spreadMinPpm: 2_000, + spreadBufferPpm: 1_500, + minPriceChangePpm: 1_500, + assetQuoteQuantums: big.NewInt(-1_000_000_000), // -1,000 USDC + positionBaseQuantums: big.NewInt(1_000_000_000), // 1 ETH + vaultId: testVaultId, + withdrawalPortionPpm: big.NewInt(500_000), // 50% + // open_notional = 1_000_000_000 * 10^-9 * 3_000 * 10^6 = 3_000_000_000 + // leverage = 3_000_000_000 / (-1_000_000_000 + 3_000_000_000) = 1.5 + // posterior_leverage = 1.5 / (1 - 0.5) = 3 + // integral + // = skew_antiderivative(skew_factor, posterior_leverage) - skew_antiderivative(skew_factor, leverage) + // = 3 * 3^2 + 3^2 * 3^3 / 3 - (3 * 1.5^2 + 3^2 * 1.5^3 / 3) + // = 108 - 16.875 + // = 91.125 + // average_skew = 91.125 / (3 - 1.5) = 60.75 + // fee_bps = spread * (1 + average_skew) * leverage + // = 0.003 * (1 + 60.75) * 1.5 + // = 0.277875 + // fee_bps = min(0.277875, leverage * imf) + // = min(0.277875, 1.5 * 0.2) = 0.277875 + expectedSlippagePpm: big.NewInt(277_875), + }, + "Success: leverage 1.5, skew 3, spread 0.005, withdraw 50%": { + skewFactorPpm: 3_000_000, + spreadMinPpm: 5_000, + spreadBufferPpm: 1_500, + minPriceChangePpm: 1_500, + assetQuoteQuantums: big.NewInt(-1_000_000_000), // -1,000 USDC + positionBaseQuantums: big.NewInt(1_000_000_000), // 1 ETH + vaultId: testVaultId, + withdrawalPortionPpm: big.NewInt(500_000), // 50% + // open_notional = 1_000_000_000 * 10^-9 * 3_000 * 10^6 = 3_000_000_000 + // leverage = 3_000_000_000 / (-1_000_000_000 + 3_000_000_000) = 1.5 + // posterior_leverage = 1.5 / (1 - 0.5) = 3 + // integral + // = skew_antiderivative(skew_factor, posterior_leverage) - skew_antiderivative(skew_factor, leverage) + // = 3 * 3^2 + 3^2 * 3^3 / 3 - (3 * 1.5^2 + 3^2 * 1.5^3 / 3) + // = 108 - 16.875 + // = 91.125 + // average_skew = 91.125 / (3 - 1.5) = 60.75 + // fee_bps = spread * (1 + average_skew) * leverage + // = 0.005 * (1 + 60.75) * 1.5 + // = 0.463125 + // fee_bps = min(0.463125, leverage * imf) + // = min(0.463125, 1.5 * 0.2) = 0.3 + expectedSlippagePpm: big.NewInt(300_000), + }, + "Success: leverage 1.5, skew 3, spread 0.005, withdraw 100%": { + skewFactorPpm: 3_000_000, + spreadMinPpm: 5_000, + spreadBufferPpm: 1_500, + minPriceChangePpm: 1_500, + assetQuoteQuantums: big.NewInt(-1_000_000_000), // -1,000 USDC + positionBaseQuantums: big.NewInt(1_000_000_000), // 1 ETH + vaultId: testVaultId, + withdrawalPortionPpm: big.NewInt(1_000_000), // 100% + // open_notional = 1_000_000_000 * 10^-9 * 3_000 * 10^6 = 3_000_000_000 + // leverage = 3_000_000_000 / (-1_000_000_000 + 3_000_000_000) = 1.5 + // fee_bps = leverage * imf = 1.5 * 0.2 = 0.3 + expectedSlippagePpm: big.NewInt(300_000), + }, + "Success: leverage 3, skew 2, spread 0.003, withdraw the smallest portion 0.0001%": { + skewFactorPpm: 2_000_000, + spreadMinPpm: 2_000, + spreadBufferPpm: 1_500, + minPriceChangePpm: 1_500, + assetQuoteQuantums: big.NewInt(-2_000_000_000), // -2,000 USDC + positionBaseQuantums: big.NewInt(1_000_000_000), // 1 ETH + vaultId: testVaultId, + withdrawalPortionPpm: big.NewInt(1), // 0.0001% + // open_notional = 1_000_000_000 * 10^-9 * 3_000 * 10^6 = 3_000_000_000 + // leverage = 3_000_000_000 / (-2_000_000_000 + 3_000_000_000) = 3 + // posterior_leverage = 3 / (1 - 0.000001) ~= 3.000004 + // integral + // = skew_antiderivative(skew_factor, posterior_leverage) - skew_antiderivative(skew_factor, leverage) + // = 2 * 3.000004^2 + 2^2 * 3.000004^3 / 3 - (2 * 3^2 + 2^2 * 3^3 / 3) + // ~= 54.000194 - 54 + // = 0.000194 + // average_skew = 0.000194 / (3.000004 - 3) = 48.5 + // fee_bps = spread * (1 + average_skew) * leverage + // = 0.003 * (1 + 48.5) * 3 + // = 0.4455 + // fee_bps = min(0.4455, leverage * imf) + // = min(0.4455, 3 * 0.2) = 0.4455 + expectedSlippagePpm: big.NewInt(445_500), + }, + "Success: leverage 3, skew 2, spread 0.003, withdraw 10%": { + skewFactorPpm: 2_000_000, + spreadMinPpm: 3_000, + spreadBufferPpm: 1_500, + minPriceChangePpm: 1_000, + assetQuoteQuantums: big.NewInt(-2_000_000_000), // -2,000 USDC + positionBaseQuantums: big.NewInt(1_000_000_000), // 1 ETH + vaultId: testVaultId, + withdrawalPortionPpm: big.NewInt(100_000), // 10% + // open_notional = 1_000_000_000 * 10^-9 * 3_000 * 10^6 = 3_000_000_000 + // leverage = 3_000_000_000 / (-2_000_000_000 + 3_000_000_000) = 3 + // posterior_leverage = 3 / (1 - 0.1) = 3.333333 + // integral + // = skew_antiderivative(skew_factor, posterior_leverage) - skew_antiderivative(skew_factor, leverage) + // = 2 * 3.333333^2 + 2^2 * 3.333333^3 / 3 - (2 * 3^2 + 2^2 * 3^3 / 3) + // = 71.604919 - 54 + // = 17.604919 + // average_skew = 17.604919 / (3.333333 - 3) ~= 52.814810 + // fee_bps = spread * (1 + average_skew) * leverage + // = 0.003 * (1 + 52.814810) * 3 + // = 0.48433329 + // round up to 0.484334 + // fee_bps = min(0.484334, leverage * imf) + // = min(0.484334, 3 * 0.2) = 0.484334 + expectedSlippagePpm: big.NewInt(484_334), + }, + "Success: leverage 3, skew 2, spread 0.003, withdraw 50%": { + skewFactorPpm: 2_000_000, + spreadMinPpm: 2_000, + spreadBufferPpm: 1_500, + minPriceChangePpm: 1_500, + assetQuoteQuantums: big.NewInt(-2_000_000_000), // -2,000 USDC + positionBaseQuantums: big.NewInt(1_000_000_000), // 1 ETH + vaultId: testVaultId, + withdrawalPortionPpm: big.NewInt(500_000), // 50% + // open_notional = 1_000_000_000 * 10^-9 * 3_000 * 10^6 = 3_000_000_000 + // leverage = 3_000_000_000 / (-2_000_000_000 + 3_000_000_000) = 3 + // posterior_leverage = 3 / (1 - 0.5) = 6 + // integral + // = skew_antiderivative(skew_factor, posterior_leverage) - skew_antiderivative(skew_factor, leverage) + // = 2 * 6^2 + 2^2 * 6^3 / 3 - (2 * 3^2 + 2^2 * 3^3 / 3) + // = 360 - 54 + // = 306 + // average_skew = 306 / (6 - 3) = 102 + // fee_bps = spread * (1 + average_skew) * leverage + // = 0.003 * (1 + 102) * 3 + // = 0.927 + // fee_bps = min(0.4455, leverage * imf) + // = min(0.927, 3 * 0.2) = 0.6 + expectedSlippagePpm: big.NewInt(600_000), + }, + "Success: leverage 3, skew 2, spread 0.003, withdraw 99.9999%": { + skewFactorPpm: 2_000_000, + spreadMinPpm: 2_000, + spreadBufferPpm: 1_500, + minPriceChangePpm: 1_500, + assetQuoteQuantums: big.NewInt(-2_000_000_000), // -2,000 USDC + positionBaseQuantums: big.NewInt(1_000_000_000), // 1 ETH + vaultId: testVaultId, + withdrawalPortionPpm: big.NewInt(999_999), // 99.9999% + // open_notional = 1_000_000_000 * 10^-9 * 3_000 * 10^6 = 3_000_000_000 + // leverage = 3_000_000_000 / (-2_000_000_000 + 3_000_000_000) = 3 + // posterior_leverage = 3 / (1 - 0.999999) = 3_000_000 + // integral + // = skew_antiderivative(skew_factor, posterior_leverage) - skew_antiderivative(skew_factor, leverage) + // = 2 * 3_000_000^2 + 2^2 * 3_000_000^3 / 3 - (2 * 3^2 + 2^2 * 3^3 / 3) + // ~= 3.6 * 10^19 - 54 + // ~= 3.6e19 + // average_skew = 3.6e19 / (3_000_000 - 3) ~= 1.2e13 + // fee_bps = spread * (1 + average_skew) * leverage + // = 0.003 * (1 + 1.2e13) * 3 + // ~= 108000000000 + // fee_bps = min(108000000000, leverage * imf) + // = min(108000000000, 3 * 0.2) = 0.6 + expectedSlippagePpm: big.NewInt(600_000), + }, + "Error: vault not found": { + skewFactorPpm: 2_000_000, + spreadMinPpm: 2_000, + spreadBufferPpm: 1_500, + minPriceChangePpm: 1_500, + assetQuoteQuantums: big.NewInt(-2_000_000_000), // -2,000 USDC + positionBaseQuantums: big.NewInt(1_000_000_000), // 1 ETH + vaultId: constants.Vault_Clob0, // non-existent vault + withdrawalPortionPpm: big.NewInt(500_000), // 50% + expectedErr: vaulttypes.ErrVaultParamsNotFound.Error(), + }, + "Error: zero withdrawal portion": { + skewFactorPpm: 2_000_000, + spreadMinPpm: 2_000, + spreadBufferPpm: 1_500, + minPriceChangePpm: 1_500, + assetQuoteQuantums: big.NewInt(-2_000_000_000), // -2,000 USDC + positionBaseQuantums: big.NewInt(1_000_000_000), // 1 ETH + vaultId: testVaultId, + withdrawalPortionPpm: big.NewInt(0), // 0% + expectedErr: vaulttypes.ErrInvalidWithdrawalPortion.Error(), + }, + "Error: withdrawal portion greater than 1": { + skewFactorPpm: 2_000_000, + spreadMinPpm: 2_000, + spreadBufferPpm: 1_500, + minPriceChangePpm: 1_500, + assetQuoteQuantums: big.NewInt(-2_000_000_000), // -2,000 USDC + positionBaseQuantums: big.NewInt(1_000_000_000), // 1 ETH + vaultId: testVaultId, + withdrawalPortionPpm: big.NewInt(1_000_001), // 100.0001% + expectedErr: vaulttypes.ErrInvalidWithdrawalPortion.Error(), + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + tApp := testapp.NewTestAppBuilder(t).WithGenesisDocFn(func() (genesis types.GenesisDoc) { + genesis = testapp.DefaultGenesis() + // Set up vault's quoting params. + testapp.UpdateGenesisDocWithAppStateForModule( + &genesis, + func(genesisState *vaulttypes.GenesisState) { + quotingParams := vaulttypes.DefaultQuotingParams() + quotingParams.SkewFactorPpm = tc.skewFactorPpm + quotingParams.SpreadMinPpm = tc.spreadMinPpm + quotingParams.SpreadBufferPpm = tc.spreadBufferPpm + genesisState.Vaults = []vaulttypes.Vault{ + { + VaultId: testVaultId, + VaultParams: vaulttypes.VaultParams{ + Status: vaulttypes.VaultStatus_VAULT_STATUS_QUOTING, + QuotingParams: "ingParams, + }, + }, + } + }, + ) + // Set up markets. + testMarketParam.MinPriceChangePpm = tc.minPriceChangePpm + testapp.UpdateGenesisDocWithAppStateForModule( + &genesis, + func(genesisState *pricestypes.GenesisState) { + genesisState.MarketParams = []pricestypes.MarketParam{testMarketParam} + genesisState.MarketPrices = []pricestypes.MarketPrice{testMarketPrice} + }, + ) + // Set up perpetuals. + testapp.UpdateGenesisDocWithAppStateForModule( + &genesis, + func(genesisState *perptypes.GenesisState) { + genesisState.LiquidityTiers = constants.LiquidityTiers + genesisState.Perpetuals = []perptypes.Perpetual{testPerpetual} + }, + ) + // Set up clob pairs. + testapp.UpdateGenesisDocWithAppStateForModule( + &genesis, + func(genesisState *clobtypes.GenesisState) { + genesisState.ClobPairs = []clobtypes.ClobPair{testClobPair} + }, + ) + // Set up vault asset and perpetual positions. + testapp.UpdateGenesisDocWithAppStateForModule( + &genesis, + func(genesisState *satypes.GenesisState) { + genesisState.Subaccounts = []satypes.Subaccount{ + { + Id: tc.vaultId.ToSubaccountId(), + AssetPositions: []*satypes.AssetPosition{ + { + AssetId: assettypes.AssetUsdc.Id, + Quantums: dtypes.NewIntFromBigInt(tc.assetQuoteQuantums), + }, + }, + PerpetualPositions: []*satypes.PerpetualPosition{ + testutil.CreateSinglePerpetualPosition( + testPerpetual.Params.Id, + tc.positionBaseQuantums, + big.NewInt(0), + big.NewInt(0), + ), + }, + }, + } + }, + ) + return genesis + }).Build() + ctx := tApp.InitChain() + k := tApp.App.VaultKeeper + + slippage, err := k.GetVaultWithdrawalSlippagePpm(ctx, tc.vaultId, tc.withdrawalPortionPpm) + + if tc.expectedErr == "" { + require.NoError(t, err) + require.Equal(t, tc.expectedSlippagePpm, slippage) + } else { + require.ErrorContains(t, err, tc.expectedErr) + } + }) + } +} diff --git a/protocol/x/vault/types/errors.go b/protocol/x/vault/types/errors.go index ec534653b83..b049c4463da 100644 --- a/protocol/x/vault/types/errors.go +++ b/protocol/x/vault/types/errors.go @@ -10,7 +10,6 @@ var ( 1, "Shares are negative", ) - // Deprecated since v6.x ErrClobPairNotFound = errorsmod.Register( ModuleName, 2, @@ -121,4 +120,9 @@ var ( 23, "Locked shares exceeds owner shares", ) + ErrInvalidWithdrawalPortion = errorsmod.Register( + ModuleName, + 24, + "Withdrawal portion must be between 0 (exclusive) and 1 (inclusive)", + ) ) diff --git a/protocol/x/vault/types/expected_keepers.go b/protocol/x/vault/types/expected_keepers.go index 904c9ede3f8..4bc05f7f0f5 100644 --- a/protocol/x/vault/types/expected_keepers.go +++ b/protocol/x/vault/types/expected_keepers.go @@ -46,6 +46,10 @@ type PerpetualsKeeper interface { ctx sdk.Context, id uint32, ) (val perptypes.Perpetual, err error) + GetLiquidityTier( + ctx sdk.Context, + id uint32, + ) (val perptypes.LiquidityTier, err error) } type PricesKeeper interface {