diff --git a/protocol/testing/e2e/funding/funding_e2e_test.go b/protocol/testing/e2e/funding/funding_e2e_test.go new file mode 100644 index 0000000000..90ffdfe6a0 --- /dev/null +++ b/protocol/testing/e2e/funding/funding_e2e_test.go @@ -0,0 +1,253 @@ +package funding_test + +import ( + "testing" + "time" + + "github.com/cometbft/cometbft/types" + testapp "github.com/dydxprotocol/v4-chain/protocol/testutil/app" + "github.com/dydxprotocol/v4-chain/protocol/testutil/constants" + pricefeed_testutil "github.com/dydxprotocol/v4-chain/protocol/testutil/pricefeed" + pricestest "github.com/dydxprotocol/v4-chain/protocol/testutil/prices" + clobtypes "github.com/dydxprotocol/v4-chain/protocol/x/clob/types" + epochstypes "github.com/dydxprotocol/v4-chain/protocol/x/epochs/types" + perptypes "github.com/dydxprotocol/v4-chain/protocol/x/perpetuals/types" + "github.com/stretchr/testify/require" +) + +type TestHumanOrder struct { + Order clobtypes.Order + HumanPrice string + HumanSize string +} + +const ( + BlockTimeDuration = 2 * time.Second + NumBlocksPerMinute = int64(time.Minute / BlockTimeDuration) // 30 + BlockHeightAtFirstFundingTick = 1000 +) + +var ( + OrderTemplate_Alice_Num0_Id0_Clob0_Buy_LongTerm = clobtypes.Order{ + OrderId: clobtypes.OrderId{ + SubaccountId: constants.Alice_Num0, + ClientId: 0, + OrderFlags: clobtypes.OrderIdFlags_LongTerm, + ClobPairId: 0, + }, + Side: clobtypes.Order_SIDE_BUY, + GoodTilOneof: &clobtypes.Order_GoodTilBlockTime{ + GoodTilBlockTime: uint32(GenesisTime.Add(24 * time.Hour).Unix()), + }, + } + OrderTemplate_Alice_Num0_Id1_Clob0_Buy_LongTerm = clobtypes.Order{ + OrderId: clobtypes.OrderId{ + SubaccountId: constants.Alice_Num0, + ClientId: 1, + OrderFlags: clobtypes.OrderIdFlags_LongTerm, + ClobPairId: 0, + }, + Side: clobtypes.Order_SIDE_BUY, + GoodTilOneof: &clobtypes.Order_GoodTilBlockTime{ + GoodTilBlockTime: uint32(GenesisTime.Add(24 * time.Hour).Unix()), + }, + } + OrderTemplate_Bob_Num0_Id0_Clob0_Sell_LongTerm = clobtypes.Order{ + OrderId: clobtypes.OrderId{ + SubaccountId: constants.Bob_Num0, + ClientId: 0, + OrderFlags: clobtypes.OrderIdFlags_LongTerm, + ClobPairId: 0, + }, + Side: clobtypes.Order_SIDE_SELL, + GoodTilOneof: &clobtypes.Order_GoodTilBlockTime{ + GoodTilBlockTime: uint32(GenesisTime.Add(24 * time.Hour).Unix()), + }, + } + OrderTemplate_Bob_Num0_Id1_Clob0_Sell_LongTerm = clobtypes.Order{ + OrderId: clobtypes.OrderId{ + SubaccountId: constants.Bob_Num0, + ClientId: 1, + OrderFlags: clobtypes.OrderIdFlags_LongTerm, + ClobPairId: 0, + }, + Side: clobtypes.Order_SIDE_SELL, + GoodTilOneof: &clobtypes.Order_GoodTilBlockTime{ + GoodTilBlockTime: uint32(GenesisTime.Add(24 * time.Hour).Unix()), + }, + } + // Genesis time of the chain + GenesisTime = time.Unix(1690000000, 0) + FirstFundingSampleTick = time.Unix(1690000050, 0) + FirstFundingTick = time.Unix(1690002000, 0) + LastFundingSampleOfSecondFundingTickEpoch = time.Unix(1690005570, 0) + SecondFundingTick = time.Unix( + 1690002000+int64(epochstypes.FundingTickEpochDuration), + 0, + ) +) + +func TestFunding(t *testing.T) { + tests := map[string]struct { + testHumanOrders []TestHumanOrder + initialIndexPrice map[uint32]string + // index price to be used in premium calculation + indexPriceForPremium map[uint32]string + // oracle price for funding index calculation + oracelPriceForFundingIndex map[uint32]string + expectedFundingPremium int32 + expectedFundingIndex int64 + }{ + "Test funding": { + testHumanOrders: []TestHumanOrder{ + // Unmatched orders to generate funding premiums. + { + Order: OrderTemplate_Bob_Num0_Id0_Clob0_Sell_LongTerm, + HumanPrice: "28005", + HumanSize: "2", + }, + { + Order: OrderTemplate_Alice_Num0_Id0_Clob0_Buy_LongTerm, + HumanPrice: "28000", + HumanSize: "2", + }, + // Matched orders to set up Alice and Bob's positions. + { + Order: OrderTemplate_Bob_Num0_Id1_Clob0_Sell_LongTerm, + HumanPrice: "28003", + HumanSize: "1", + }, + { + Order: OrderTemplate_Alice_Num0_Id1_Clob0_Buy_LongTerm, + HumanPrice: "28003", + HumanSize: "1", + }, + }, + initialIndexPrice: map[uint32]string{ + 0: "28002", + }, + indexPriceForPremium: map[uint32]string{ + 0: "27960", + }, + oracelPriceForFundingIndex: map[uint32]string{ + 0: "27000", + }, + expectedFundingPremium: 1430, // 28_000 / 27_960 - 1 ~= 0.001430 + // 1430 / 8 * 27000 * 10^(btc_atomic_resolution - quote_atomic_resolution) ~= 483 + expectedFundingIndex: 483, + }, + // TODO(CORE-712): Add more test cases + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + tApp := testapp.NewTestAppBuilder(t).WithGenesisDocFn(func() (genesis types.GenesisDoc) { + genesis = testapp.DefaultGenesis() + genesis.GenesisTime = GenesisTime + return genesis + }).Build() + ctx := tApp.InitChain() + + // Place orders on the book. + for _, testHumanOrder := range tc.testHumanOrders { + order := testapp.MustMakeOrderFromHumanInput( + ctx, + tApp.App, + testHumanOrder.Order, + testHumanOrder.HumanPrice, + testHumanOrder.HumanSize, + ) + + checkTx := testapp.MustMakeCheckTxsWithClobMsg(ctx, tApp.App, *clobtypes.NewMsgPlaceOrder(order)) + resp := tApp.CheckTx(checkTx[0]) + require.Conditionf(t, resp.IsOK, "Expected CheckTx to succeed. Response: %+v", resp) + } + + // Update initial index price. This price is meant to be within the impact price range, + // leading to zero sampled premiums. + pricefeed_testutil.UpdateIndexPrice( + t, + ctx, + tApp.App, + pricestest.MustHumanPriceToMarketPrice(tc.initialIndexPrice[0], -5), + // Only index price past a certain threshold is used for premium calculation. + // Use additional buffer here to ensure `test-race` passes. + time.Now().Add(1*time.Hour), + ) + + ctx = tApp.AdvanceToBlock(BlockHeightAtFirstFundingTick, testapp.AdvanceToBlockOptions{ + BlockTime: FirstFundingTick, + LinearBlockTimeInterpolation: true, + }) + + premiumSamples := tApp.App.PerpetualsKeeper.GetPremiumSamples(ctx) + // No non-zero premium samples yet. + require.Len(t, premiumSamples.AllMarketPremiums, 0) + // Zero premium samples since we just entered a new `funding-tick` epoch. + require.Equal(t, uint32(0), premiumSamples.NumPremiums) + + // Update index price for each validator so they use this price for premium calculation. + pricefeed_testutil.UpdateIndexPrice( + t, + ctx, + tApp.App, + pricestest.MustHumanPriceToMarketPrice(tc.indexPriceForPremium[0], -5), + // Only index price past a certain threshold is used for premium calculation. + // Use additional buffer here to ensure `test-race` passes. + time.Now().Add(1*time.Hour), + ) + + // We just entered a new `funding-tick` epoch, there should be 0 funding premium samples. + require.Equal(t, tApp.App.PerpetualsKeeper.GetPremiumSamples(ctx).NumPremiums, uint32(0)) + + // Advance to the end of the last funding-sample epoch during the second funding-tick epoch. + // At this point, 60 funding-sample epochs have passed, so we should expect 60 premium samples. + ctx = tApp.AdvanceToBlock( + testapp.EstimatedHeightForBlockTime(GenesisTime, LastFundingSampleOfSecondFundingTickEpoch, BlockTimeDuration), + testapp.AdvanceToBlockOptions{ + BlockTime: LastFundingSampleOfSecondFundingTickEpoch, + LinearBlockTimeInterpolation: true, + }) + + premiumSamples = tApp.App.PerpetualsKeeper.GetPremiumSamples(ctx) + require.Equal(t, 60, int(premiumSamples.NumPremiums)) + expectedAllMarketPremiums := []perptypes.MarketPremiums{ + { + PerpetualId: 0, + Premiums: constants.GenerateConstantFundingPremiums(tc.expectedFundingPremium, 60), + }, + } + require.Equal(t, expectedAllMarketPremiums, premiumSamples.AllMarketPremiums) + + // Update index price for each validator so they propose this price as the new oracle price. + // This price will be used for calculating the funding index at the end of `funding-tick`. + pricefeed_testutil.UpdateIndexPrice( + t, + ctx, + tApp.App, + pricestest.MustHumanPriceToMarketPrice(tc.oracelPriceForFundingIndex[0], -5), + // Only index price past a certain threshold is used for premium calculation. + // Use additional buffer here to ensure `test-race` passes. + time.Now().Add(1*time.Hour), + ) + // Advance another 30 seconds to the end of the second funding-tick epoch. This will trigger processing + // of `funding-tick`, which calculates the final funding rate and updates the funding index. + ctx = tApp.AdvanceToBlock(uint32(ctx.BlockHeight()+NumBlocksPerMinute-1), testapp.AdvanceToBlockOptions{ + BlockTime: SecondFundingTick.Add(-BlockTimeDuration), + }) + ctx = tApp.AdvanceToBlock(uint32(ctx.BlockHeight())+1, testapp.AdvanceToBlockOptions{ + BlockTime: SecondFundingTick, + }) + + premiumSamples = tApp.App.PerpetualsKeeper.GetPremiumSamples(ctx) + require.Equal(t, uint32(0), premiumSamples.NumPremiums) + require.Len(t, premiumSamples.AllMarketPremiums, 0) + + // Check that the funding index is correctly updated. + btcPerp, err := tApp.App.PerpetualsKeeper.GetPerpetual(ctx, 0) + require.NoError(t, err) + require.Equal(t, tc.expectedFundingIndex, btcPerp.FundingIndex.BigInt().Int64()) + // TODO(CORE-703): Settle Alice and Bob's positions, so we can measure that the funding payment as processed. + }) + } +} diff --git a/protocol/testutil/app/app.go b/protocol/testutil/app/app.go index db30882c1b..0ababd7611 100644 --- a/protocol/testutil/app/app.go +++ b/protocol/testutil/app/app.go @@ -83,6 +83,11 @@ type AdvanceToBlockOptions struct { // The time associated with the block. If left at the default value then block time will be left unchanged. BlockTime time.Time + // Whether to increment the block time using linear interpolation among the blocks. + // TODO(DEC-2156): Instead of an option, pass in a `BlockTimeFunc` to map each block to a + // time giving user greater flexibility. + LinearBlockTimeInterpolation bool + // RequestPrepareProposalTxsOverride allows overriding the txs that gets passed into the // PrepareProposalHandler. This is useful for testing scenarios where unintended msg txs // end up in the mempool (i.e. CheckTx failed to filter bad msg txs out). @@ -405,9 +410,13 @@ func (tApp *TestApp) AdvanceToBlock( for tApp.App.LastBlockHeight() < int64(block) { tApp.panicIfChainIsHalted() tApp.header.Height = tApp.App.LastBlockHeight() + 1 - // By default, only update block time at the requested block. if tApp.header.Height == int64(block) { + // By default, only update block time at the requested block. tApp.header.Time = options.BlockTime + } else if options.LinearBlockTimeInterpolation { + remainingDuration := options.BlockTime.Sub(tApp.header.Time) + nextBlockDuration := remainingDuration / time.Duration(int64(block)-tApp.App.LastBlockHeight()) + tApp.header.Time = tApp.header.Time.Add(nextBlockDuration) } tApp.header.LastCommitHash = tApp.App.LastCommitID().Hash tApp.header.NextValidatorsHash = tApp.App.LastCommitID().Hash diff --git a/protocol/testutil/app/block_advancement.go b/protocol/testutil/app/block_advancement.go index 954f59558c..0fa2679822 100644 --- a/protocol/testutil/app/block_advancement.go +++ b/protocol/testutil/app/block_advancement.go @@ -2,6 +2,7 @@ package app import ( "testing" + "time" abcitypes "github.com/cometbft/cometbft/abci/types" sdktypes "github.com/cosmos/cosmos-sdk/types" @@ -133,3 +134,13 @@ func (b BlockAdvancement) getProposedOperationsTxBytes(ctx sdktypes.Context, app return testtx.MustGetTxBytes(msgProposedOperations) } + +// Given genesis time, target block time and block time duration, return the estimated height +// at which the target block time is reached. +func EstimatedHeightForBlockTime( + genesisTime time.Time, + targetBlockTime time.Time, + blockTimeDuration time.Duration, +) uint32 { + return uint32(targetBlockTime.Sub(genesisTime) / blockTimeDuration) +} diff --git a/protocol/testutil/app/order.go b/protocol/testutil/app/order.go new file mode 100644 index 0000000000..9df9a66dde --- /dev/null +++ b/protocol/testutil/app/order.go @@ -0,0 +1,54 @@ +package app + +// This file includes clob helpers used in the end-to-end test suites. Functions here cannot live in +// protocol/testutil/clob because they depend on the TestApp struct, and would create an import cycle. + +import ( + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/dydxprotocol/v4-chain/protocol/app" + "github.com/dydxprotocol/v4-chain/protocol/lib" + clobtest "github.com/dydxprotocol/v4-chain/protocol/testutil/clob" + perptest "github.com/dydxprotocol/v4-chain/protocol/testutil/perpetuals" + pricestest "github.com/dydxprotocol/v4-chain/protocol/testutil/prices" + clobtypes "github.com/dydxprotocol/v4-chain/protocol/x/clob/types" + pricestypes "github.com/dydxprotocol/v4-chain/protocol/x/prices/types" +) + +// Subsitute quantums and subticks with value converted from human readable price and amount. +func MustMakeOrderFromHumanInput( + ctx sdk.Context, + app *app.App, + order clobtypes.Order, + humanPrice string, + humanSize string, +) clobtypes.Order { + clobPair, exists := app.ClobKeeper.GetClobPair(ctx, clobtypes.ClobPairId(order.OrderId.ClobPairId)) + if !exists { + panic(fmt.Sprintf("clobPair does not exist: %v", order.OrderId.ClobPairId)) + } + perp, err := app.PerpetualsKeeper.GetPerpetual(ctx, clobtest.MustPerpetualId(clobPair)) + if err != nil { + panic(err) + } + baseQuantums := perptest.MustHumanSizeToBaseQuantums(humanSize, perp.Params.AtomicResolution) + order.Quantums = baseQuantums + + marketParams, exists := app.PricesKeeper.GetMarketParam(ctx, perp.Params.MarketId) + if !exists { + panic(fmt.Sprintf("marketParam does not exist: %v", perp.Params.MarketId)) + } + marketPrice := pricestest.MustHumanPriceToMarketPrice(humanPrice, marketParams.Exponent) + subticks := clobtypes.PriceToSubticks( + pricestypes.MarketPrice{ + Price: marketPrice, + Exponent: marketParams.Exponent, + }, + clobPair, + perp.Params.AtomicResolution, + lib.QuoteCurrencyAtomicResolution, + ) + order.Subticks = subticks.Num().Uint64() + return order +} diff --git a/protocol/testutil/perpetuals/perpetuals.go b/protocol/testutil/perpetuals/perpetuals.go index 5d9fdf5b65..6dec4027f7 100644 --- a/protocol/testutil/perpetuals/perpetuals.go +++ b/protocol/testutil/perpetuals/perpetuals.go @@ -1,6 +1,8 @@ package perpetuals import ( + "math/big" + "github.com/dydxprotocol/v4-chain/protocol/dtypes" perptypes "github.com/dydxprotocol/v4-chain/protocol/x/perpetuals/types" ) @@ -66,3 +68,41 @@ func GeneratePerpetual(optionalModifications ...PerpetualModifierOption) *perpty return perpetual } + +func MustHumanSizeToBaseQuantums( + humanSize string, + atomicResolution int32, +) (baseQuantums uint64) { + // Parse the humanSize string to a big rational + ratValue, ok := new(big.Rat).SetString(humanSize) + if !ok { + panic("Failed to parse humanSize to big.Rat") + } + + // Convert atomicResolution to int64 for calculations + resolution := int64(atomicResolution) + + // Create a multiplier which is 10 raised to the power of the absolute atomicResolution + multiplier := new(big.Int).Exp(big.NewInt(10), big.NewInt(abs(resolution)), nil) + + // Depending on the sign of atomicResolution, multiply or divide + if atomicResolution > 0 { + ratValue.Mul(ratValue, new(big.Rat).SetInt(multiplier)) + } else if atomicResolution < 0 { + divisor := new(big.Rat).SetInt(multiplier) + ratValue.Mul(ratValue, divisor) + } + + // Convert the result to an unsigned 64-bit integer + resultInt := ratValue.Num() // Get the numerator which now represents the whole value + + return resultInt.Uint64() +} + +// Helper function to get the absolute value of an int64 +func abs(n int64) int64 { + if n < 0 { + return -n + } + return n +} diff --git a/protocol/testutil/perpetuals/perpetuals_test.go b/protocol/testutil/perpetuals/perpetuals_test.go new file mode 100644 index 0000000000..630797c19a --- /dev/null +++ b/protocol/testutil/perpetuals/perpetuals_test.go @@ -0,0 +1,44 @@ +package perpetuals_test + +import ( + "testing" + + "github.com/dydxprotocol/v4-chain/protocol/testutil/perpetuals" + "github.com/stretchr/testify/require" +) + +func TestMustHumanSizeToBaseQuantums(t *testing.T) { + tests := []struct { + humanSize string + atomicResolution int32 + expected uint64 + expectPanic bool + }{ + {"1.123", -8, 112_300_000, false}, + {"0.55", -9, 550_000_000, false}, + {"0.00000001", -8, 1, false}, + {"235", 1, 2350, false}, + {"1", -10, 10_000_000_000, false}, + {"0.0000000001", -10, 1, false}, + {"abc", -8, 0, true}, // Invalid humanSize + } + + for _, test := range tests { + if test.expectPanic { + require.Panics(t, func() { + perpetuals.MustHumanSizeToBaseQuantums(test.humanSize, test.atomicResolution) + }, "For humanSize %v and atomicResolution %v, expected a panic", test.humanSize, test.atomicResolution) + } else { + result := perpetuals.MustHumanSizeToBaseQuantums(test.humanSize, test.atomicResolution) + require.Equal(t, + test.expected, + result, + "expected = %v, result =%v, for humanSize %v and atomicResolution %v", + test.expected, + result, + test.humanSize, + test.atomicResolution, + ) + } + } +} diff --git a/protocol/testutil/pricefeed/index_price.go b/protocol/testutil/pricefeed/index_price.go index 49331669c6..c4e637d2ce 100644 --- a/protocol/testutil/pricefeed/index_price.go +++ b/protocol/testutil/pricefeed/index_price.go @@ -1,8 +1,14 @@ package pricefeed import ( + "testing" + "time" + + sdk "github.com/cosmos/cosmos-sdk/types" "github.com/dydxprotocol/v4-chain/protocol/testutil/constants" + "github.com/stretchr/testify/require" + "github.com/dydxprotocol/v4-chain/protocol/app" pricefeedapi "github.com/dydxprotocol/v4-chain/protocol/daemons/pricefeed/api" ) @@ -21,3 +27,40 @@ func GetTestMarketPriceUpdates(n int) (indexPrices []*pricefeedapi.MarketPriceUp } return indexPrices } + +func UpdateIndexPrice( + t *testing.T, + ctx sdk.Context, + tApp *app.App, + price uint64, + lastUpdatedTime time.Time, +) { + _, err := tApp.Server.UpdateMarketPrices( + ctx, + &pricefeedapi.UpdateMarketPricesRequest{ + MarketPriceUpdates: []*pricefeedapi.MarketPriceUpdate{ + { + MarketId: 0, + ExchangePrices: []*pricefeedapi.ExchangePrice{ + { + ExchangeId: "exchange-a", + Price: price, + LastUpdateTime: &lastUpdatedTime, + }, + { + ExchangeId: "exchange-b", + Price: price, + LastUpdateTime: &lastUpdatedTime, + }, + { + ExchangeId: "exchange-c", + Price: price, + LastUpdateTime: &lastUpdatedTime, + }, + }, + }, + }, + }, + ) + require.NoError(t, err) +} diff --git a/protocol/testutil/prices/market_param_price.go b/protocol/testutil/prices/market_param_price.go index df1542dbff..9cce662da7 100644 --- a/protocol/testutil/prices/market_param_price.go +++ b/protocol/testutil/prices/market_param_price.go @@ -1,6 +1,8 @@ package prices import ( + "math/big" + pricestypes "github.com/dydxprotocol/v4-chain/protocol/x/prices/types" ) @@ -77,3 +79,33 @@ func GenerateMarketParamPrice(optionalModifications ...MarketParamPriceModifierO return marketParamPrice } + +func MustHumanPriceToMarketPrice( + humanPrice string, + exponent int32, +) (marketPrice uint64) { + // Ensure the exponent is negative + if exponent >= 0 { + panic("Only negative exponents are supported") + } + + // Parse the humanPrice string to a big rational + ratValue, ok := new(big.Rat).SetString(humanPrice) + if !ok { + panic("Failed to parse humanPrice to big.Rat") + } + + // Convert exponent to its absolute value for calculations + absResolution := int64(-exponent) + + // Create a multiplier which is 10 raised to the power of the absolute exponent + multiplier := new(big.Int).Exp(big.NewInt(10), big.NewInt(absResolution), nil) + + // Multiply the parsed humanPrice with the multiplier + ratValue.Mul(ratValue, new(big.Rat).SetInt(multiplier)) + + // Convert the result to an unsigned 64-bit integer + resultInt := ratValue.Num() // Get the numerator which now represents the whole value + + return resultInt.Uint64() +} diff --git a/protocol/testutil/prices/market_param_price_test.go b/protocol/testutil/prices/market_param_price_test.go new file mode 100644 index 0000000000..ebe565c3b0 --- /dev/null +++ b/protocol/testutil/prices/market_param_price_test.go @@ -0,0 +1,37 @@ +package prices_test + +import ( + "testing" + + "github.com/dydxprotocol/v4-chain/protocol/testutil/prices" + "github.com/stretchr/testify/require" +) + +func TestMustHumanPriceToMarketPrice(t *testing.T) { + tests := []struct { + humanPrice string + exponent int32 + expected uint64 + expectPanic bool + }{ + {"20000", -5, 2000_000_000, false}, + {"12345.67", -3, 12_345_670, false}, + {"1.123", -8, 112_300_000, false}, + {"0.00000001", -8, 1, false}, + {"1", -10, 10_000_000_000, false}, + {"0.0000000001", -10, 1, false}, + {"abc", -8, 0, true}, // Invalid humanPrice + } + + for _, test := range tests { + if test.expectPanic { + require.Panics(t, func() { + prices.MustHumanPriceToMarketPrice(test.humanPrice, test.exponent) + }, "For humanPrice %s and exponent %d, expected a panic", test.humanPrice, test.exponent) + } else { + result := prices.MustHumanPriceToMarketPrice(test.humanPrice, test.exponent) + require.Equal(t, test.expected, result, "expected = %v, result = %v for humanPrice %s and exponent %d", + test.expected, result, test.humanPrice, test.exponent) + } + } +}