diff --git a/CHANGELOG.md b/CHANGELOG.md index 8025db793..9b58746b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ Ref: https://keepachangelog.com/en/1.0.0/ ## v0.9.0 - Support all asset list v1 tokens +- Use spot price in pricing ## v0.8.4 diff --git a/router/usecase/pools/routable_cw_pool.go b/router/usecase/pools/routable_cw_pool.go index 30ff347a8..6f1dec4bb 100644 --- a/router/usecase/pools/routable_cw_pool.go +++ b/router/usecase/pools/routable_cw_pool.go @@ -111,7 +111,6 @@ func (r *routableCosmWasmPoolImpl) SetTokenOutDenom(tokenOutDenom string) { // CalcSpotPrice implements sqsdomain.RoutablePool. func (r *routableCosmWasmPoolImpl) CalcSpotPrice(ctx context.Context, baseDenom string, quoteDenom string) (osmomath.BigDec, error) { - var request msg.SpotPriceQueryMsg // HACK: // AStroport PCL pool has the quote and base asset denoms reversed diff --git a/router/usecase/precompute.go b/router/usecase/precompute.go new file mode 100644 index 000000000..ce9ff7ff4 --- /dev/null +++ b/router/usecase/precompute.go @@ -0,0 +1,53 @@ +package usecase + +import "github.com/osmosis-labs/osmosis/osmomath" + +var ( + TenE9 = osmomath.NewInt(1_000_000_000) + TenE8 = osmomath.NewInt(100_000_000) + TenE7 = osmomath.NewInt(10_000_000) + TenE6 = osmomath.NewInt(1_000_000) + TenE5 = osmomath.NewInt(100_000) + TenE4 = osmomath.NewInt(10_000) + TenE3 = osmomath.NewInt(1_000) + TenE2 = osmomath.NewInt(100) + TenE1 = osmomath.NewInt(10) +) + +// GetPrecomputeOrderOfMagnitude returns the order of magnitude of the given amount. +// Uses look up table for precomputed order of magnitudes. +func GetPrecomputeOrderOfMagnitude(amount osmomath.Int) int { + if amount.GT(TenE9) { + a := amount.Quo(TenE9) + return 9 + GetPrecomputeOrderOfMagnitude(a) + } + if amount.GTE(TenE9) { + return 9 + } + if amount.GTE(TenE8) { + return 8 + } + if amount.GTE(TenE7) { + return 7 + } + if amount.GTE(TenE6) { + return 6 + } + if amount.GTE(TenE5) { + return 5 + } + if amount.GTE(TenE4) { + return 4 + } + if amount.GTE(TenE3) { + return 3 + } + if amount.GTE(TenE2) { + return 2 + } + if amount.GTE(TenE1) { + return 1 + } + + return 0 +} diff --git a/router/usecase/precompute_test.go b/router/usecase/precompute_test.go new file mode 100644 index 000000000..9c23589a0 --- /dev/null +++ b/router/usecase/precompute_test.go @@ -0,0 +1,70 @@ +package usecase_test + +import ( + "testing" + + "github.com/osmosis-labs/osmosis/osmomath" + "github.com/osmosis-labs/sqs/router/usecase" +) + +var ( + testAmount = osmomath.NewInt(1234567890323344555) +) + +func (s *RouterTestSuite) TestGetPrecomputeOrderOfMagnitude() { + + tests := map[string]struct { + amount osmomath.Int + }{ + "0 = 0": { + amount: osmomath.ZeroInt(), + }, + "1 = 0": { + amount: osmomath.OneInt(), + }, + "9.99 = 0": { + amount: osmomath.NewInt(9), + }, + "10^9 - 1": { + amount: usecase.TenE9.Sub(osmomath.OneInt()), + }, + "10^9": { + amount: usecase.TenE9, + }, + "10^9 +1": { + amount: usecase.TenE9.Add(osmomath.OneInt()), + }, + "10^18 +1": { + amount: usecase.TenE9.Mul(usecase.TenE9).Add(osmomath.OneInt()), + }, + "10^15 +5": { + amount: usecase.TenE9.Mul(usecase.TenE6).Add(osmomath.OneInt()), + }, + } + + for name, tc := range tests { + s.Run(name, func() { + + actual := usecase.GetPrecomputeOrderOfMagnitude(tc.amount) + + expected := osmomath.OrderOfMagnitude(tc.amount.ToLegacyDec()) + + s.Require().Equal(expected, actual) + }) + } + +} + +// go test -benchmem -run=^$ -bench ^BenchmarkGetPrecomputeOrderOfMagnitude$ github.com/osmosis-labs/sqs/router/usecase -count=6 +func BenchmarkGetPrecomputeOrderOfMagnitude(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = usecase.GetPrecomputeOrderOfMagnitude(testAmount) + } +} + +// go test -benchmem -run=^$ -bench ^BenchmarkOrderOfMagnitude$ github.com/osmosis-labs/sqs/router/usecase -count=6 > old +func BenchmarkOrderOfMagnitude(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = osmomath.OrderOfMagnitude(testAmount.ToLegacyDec()) + } +} diff --git a/router/usecase/router_usecase.go b/router/usecase/router_usecase.go index 2f6afaf2d..32e77192e 100644 --- a/router/usecase/router_usecase.go +++ b/router/usecase/router_usecase.go @@ -136,71 +136,29 @@ func (r *routerUseCaseImpl) GetOptimalQuote(ctx context.Context, tokenIn sdk.Coi func (r *routerUseCaseImpl) GetOptimalQuoteFromConfig(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom string, config domain.RouterConfig) (domain.Quote, error) { // Get an order of magnitude for the token in amount // This is used for caching ranked routes as these might differ depending on the amount swapped in. - tokenInOrderOfMagnitude := osmomath.OrderOfMagnitude(tokenIn.Amount.ToLegacyDec()) + tokenInOrderOfMagnitude := GetPrecomputeOrderOfMagnitude(tokenIn.Amount) candidateRankedRoutes, err := r.GetCachedRankedRoutes(ctx, tokenIn.Denom, tokenOutDenom, tokenInOrderOfMagnitude) if err != nil { return nil, err } + router := r.initializeRouter(config) + var ( topSingleRouteQuote domain.Quote rankedRoutes []route.RouteImpl ) - router := r.initializeRouter(config) - - // Preferred route in this context is either an overwrite or a cached ranked route. - // If an overwrite exists, it is always used over the ranked route. + // If cached ranked routes are not present, compute and rank routes by direct quote if len(candidateRankedRoutes.Routes) == 0 { - // If top routes are not present in cache, retrieve unranked candidate routes - candidateRoutes, err := r.handleCandidateRoutes(ctx, router, tokenIn.Denom, tokenOutDenom) - if err != nil { - r.logger.Error("error handling routes", zap.Error(err)) - return nil, err - } - - // Get request path for metrics - requestURLPath, err := domain.GetURLPathFromContext(ctx) - if err != nil { - return nil, err - } - - if len(candidateRoutes.Routes) > 0 { - cacheWrite.WithLabelValues(requestURLPath, candidateRouteCacheLabel, tokenIn.Denom, tokenOutDenom, noOrderOfMagnitude).Inc() - - r.candidateRouteCache.Set(formatCandidateRouteCacheKey(tokenIn.Denom, tokenOutDenom), candidateRoutes, time.Duration(config.CandidateRouteCacheExpirySeconds)*time.Second) - } - - // Rank candidate routes by estimating direct quotes - topSingleRouteQuote, rankedRoutes, err = r.rankRoutesByDirectQuote(ctx, router, candidateRoutes, tokenIn, tokenOutDenom) - if err != nil { - r.logger.Error("error getting top routes", zap.Error(err)) - return nil, err - } - - if len(rankedRoutes) == 0 { - return nil, fmt.Errorf("no ranked routes found") - } - - // Update ranked routes with filtered ranked routes - rankedRoutes = filterDuplicatePoolIDRoutes(rankedRoutes) - - // Convert ranked routes back to candidate for caching - candidateRoutes = convertRankedToCandidateRoutes(rankedRoutes) - - if len(rankedRoutes) > 0 { - cacheWrite.WithLabelValues(requestURLPath, rankedRouteCacheLabel, tokenIn.Denom, tokenOutDenom, strconv.FormatInt(int64(tokenInOrderOfMagnitude), 10)).Inc() - - r.rankedRouteCache.Set(formatRankedRouteCacheKey(tokenIn.Denom, tokenOutDenom, tokenInOrderOfMagnitude), candidateRoutes, time.Duration(config.RankedRouteCacheExpirySeconds)*time.Second) - } + topSingleRouteQuote, rankedRoutes, err = r.computeAndRankRoutesByDirectQuote(ctx, router, tokenIn, tokenOutDenom) } else { - // Rank candidate routes by estimating direct quotes + // Otherwise, simply compute quotes over cached ranked routes topSingleRouteQuote, rankedRoutes, err = r.rankRoutesByDirectQuote(ctx, router, candidateRankedRoutes, tokenIn, tokenOutDenom) - if err != nil { - r.logger.Error("error getting top routes", zap.Error(err)) - return nil, err - } + } + if err != nil { + return nil, err } if len(rankedRoutes) == 1 || router.config.MaxSplitRoutes == domain.DisableSplitRoutes { @@ -221,8 +179,6 @@ func (r *routerUseCaseImpl) GetOptimalQuoteFromConfig(ctx context.Context, token return nil, err } - // TODO: Cache split route proportions - finalQuote := topSingleRouteQuote // If the split route quote is better than the single route quote, return the split route quote @@ -317,6 +273,55 @@ func (r *routerUseCaseImpl) rankRoutesByDirectQuote(ctx context.Context, router return topQuote, routes, nil } +// computeAndRankRoutesByDirectQuote computes candidate routes and ranks them by token out after estimating direct quotes. +func (r *routerUseCaseImpl) computeAndRankRoutesByDirectQuote(ctx context.Context, router *Router, tokenIn sdk.Coin, tokenOutDenom string) (domain.Quote, []route.RouteImpl, error) { + tokenInOrderOfMagnitude := GetPrecomputeOrderOfMagnitude(tokenIn.Amount) + + // If top routes are not present in cache, retrieve unranked candidate routes + candidateRoutes, err := r.handleCandidateRoutes(ctx, router, tokenIn.Denom, tokenOutDenom) + if err != nil { + r.logger.Error("error handling routes", zap.Error(err)) + return nil, nil, err + } + + // Get request path for metrics + requestURLPath, err := domain.GetURLPathFromContext(ctx) + if err != nil { + return nil, nil, err + } + + if len(candidateRoutes.Routes) > 0 { + cacheWrite.WithLabelValues(requestURLPath, candidateRouteCacheLabel, tokenIn.Denom, tokenOutDenom, noOrderOfMagnitude).Inc() + + r.candidateRouteCache.Set(formatCandidateRouteCacheKey(tokenIn.Denom, tokenOutDenom), candidateRoutes, time.Duration(router.config.CandidateRouteCacheExpirySeconds)*time.Second) + } + + // Rank candidate routes by estimating direct quotes + topSingleRouteQuote, rankedRoutes, err := r.rankRoutesByDirectQuote(ctx, router, candidateRoutes, tokenIn, tokenOutDenom) + if err != nil { + r.logger.Error("error getting ranked routes", zap.Error(err)) + return nil, nil, err + } + + if len(rankedRoutes) == 0 { + return nil, nil, fmt.Errorf("no ranked routes found") + } + + // Update ranked routes with filtered ranked routes + rankedRoutes = filterDuplicatePoolIDRoutes(rankedRoutes) + + // Convert ranked routes back to candidate for caching + candidateRoutes = convertRankedToCandidateRoutes(rankedRoutes) + + if len(rankedRoutes) > 0 { + cacheWrite.WithLabelValues(requestURLPath, rankedRouteCacheLabel, tokenIn.Denom, tokenOutDenom, strconv.FormatInt(int64(tokenInOrderOfMagnitude), 10)).Inc() + + r.rankedRouteCache.Set(formatRankedRouteCacheKey(tokenIn.Denom, tokenOutDenom, tokenInOrderOfMagnitude), candidateRoutes, time.Duration(router.config.RankedRouteCacheExpirySeconds)*time.Second) + } + + return topSingleRouteQuote, rankedRoutes, nil +} + // estimateDirectQuote estimates and returns the direct quote for the given routes, token in and token out denom. // Also, returns the routes ranked by amount out in decreasing order. // Returns error if: diff --git a/router/usecase/routertesting/suite.go b/router/usecase/routertesting/suite.go index e2ccda5b9..fde54dc1d 100644 --- a/router/usecase/routertesting/suite.go +++ b/router/usecase/routertesting/suite.go @@ -157,6 +157,24 @@ var ( TransmuterCodeIDs: []uint64{148, 254}, GeneralCosmWasmCodeIDs: []uint64{}, } + + DefaultPricingRouterConfig = domain.RouterConfig{ + PreferredPoolIDs: []uint64{}, + MaxRoutes: 5, + MaxPoolsPerRoute: 3, + MaxSplitRoutes: 3, + MinOSMOLiquidity: 50, + RouteCacheEnabled: true, + } + + DefaultPricingConfig = domain.PricingConfig{ + DefaultSource: domain.ChainPricingSource, + CacheExpiryMs: 2000, + DefaultQuoteHumanDenom: "usdc", + MaxPoolsPerRoute: 4, + MaxRoutes: 5, + MinOSMOLiquidity: 50, + } ) func init() { @@ -306,3 +324,10 @@ func (s *RouterTestHelper) SetupRouterAndPoolsUsecase(router *routerusecase.Rout Tokens: tokensUsecase, } } + +// helper to convert any to BigDec +func (s *RouterTestHelper) ConvertAnyToBigDec(any any) osmomath.BigDec { + bigDec, ok := any.(osmomath.BigDec) + s.Require().True(ok) + return bigDec +} diff --git a/tokens/usecase/pricing/chain/pricing_chain.go b/tokens/usecase/pricing/chain/pricing_chain.go index e9dddd786..618310be2 100644 --- a/tokens/usecase/pricing/chain/pricing_chain.go +++ b/tokens/usecase/pricing/chain/pricing_chain.go @@ -29,6 +29,12 @@ type chainPricing struct { var _ domain.PricingStrategy = &chainPricing{} +const ( + // We use multiplier so that stablecoin quotes avoid selecting low liquidity routes. + // USDC/USDT value of 10 should be sufficient to avoid low liquidity routes. + tokenInMultiplier = 10 +) + var ( cacheHitsCounter = prometheus.NewCounterVec( prometheus.CounterOpts{ @@ -52,6 +58,14 @@ var ( }, []string{"base", "quote"}, ) + + pricesSpotPriceError = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "sqs_pricing_spot_price_error_total", + Help: "Total number of spot price errors in pricing", + }, + []string{"base", "quote"}, + ) ) func init() { @@ -110,7 +124,8 @@ func (c *chainPricing) GetPrice(ctx context.Context, baseDenom string, quoteDeno } // Create a quote denom coin. - oneQuoteCoin := sdk.NewCoin(quoteDenom, quoteDenomScalingFactor.TruncateInt()) + // We use multiplier so that stablecoin quotes avoid selecting low liquidity routes. + tenQuoteCoin := sdk.NewCoin(quoteDenom, osmomath.NewInt(tokenInMultiplier).Mul(quoteDenomScalingFactor.TruncateInt())) // Overwrite default config with custom values // necessary for pricing. @@ -122,20 +137,61 @@ func (c *chainPricing) GetPrice(ctx context.Context, baseDenom string, quoteDeno routerConfig.MaxSplitIterations = domain.DisableSplitRoutes // Compute a quote for one quote coin. - quote, err := c.RUsecase.GetOptimalQuoteFromConfig(ctx, oneQuoteCoin, baseDenom, routerConfig) + quote, err := c.RUsecase.GetOptimalQuoteFromConfig(ctx, tenQuoteCoin, baseDenom, routerConfig) if err != nil { return osmomath.BigDec{}, err } - // Compute on-chain price for 1 unit of base denom and quote denom. - chainPrice := osmomath.NewBigDecFromBigInt(oneQuoteCoin.Amount.BigIntMut()).QuoMut(osmomath.NewBigDecFromBigInt(quote.GetAmountOut().BigIntMut())) + routes := quote.GetRoute() + if len(routes) == 0 { + return osmomath.BigDec{}, fmt.Errorf("no route found when computing pricing for %s (base) -> %s (quote)", baseDenom, quoteDenom) + } + + route := routes[0] + + chainPrice := osmomath.OneBigDec() + + pools := route.GetPools() + + var ( + tempQuoteDenom = quoteDenom + tempBaseDenom string + useAlternativeMethod = false + ) + + for _, pool := range pools { + tempBaseDenom = pool.GetTokenOutDenom() + + // Get spot price for the pool. + poolSpotPrice, err := c.RUsecase.GetPoolSpotPrice(ctx, pool.GetId(), tempQuoteDenom, tempBaseDenom) + if err != nil || poolSpotPrice.IsNil() || poolSpotPrice.IsZero() { + // Increase price truncation counter + pricesSpotPriceError.WithLabelValues(baseDenom, quoteDenom).Inc() + + useAlternativeMethod = true + break + } + + // Multiply spot price by the previous spot price. + chainPrice = chainPrice.MulMut(poolSpotPrice) + + tempQuoteDenom = tempBaseDenom + } + + if useAlternativeMethod { + // Compute on-chain price for 1 unit of base denom and quote denom. + chainPrice = osmomath.NewBigDecFromBigInt(tenQuoteCoin.Amount.BigIntMut()).QuoMut(osmomath.NewBigDecFromBigInt(quote.GetAmountOut().BigIntMut())) + } else { + chainPrice = osmomath.OneBigDec().QuoMut(chainPrice) + } + if chainPrice.IsZero() { // Increase price truncation counter pricesTruncationCounter.WithLabelValues(baseDenom, quoteDenom).Inc() } // Compute precision scaling factor. - precisionScalingFactor := osmomath.BigDecFromDec(baseDenomScalingFactor.Quo(oneQuoteCoin.Amount.ToLegacyDec())) + precisionScalingFactor := osmomath.BigDecFromDec(osmomath.NewDec(tokenInMultiplier).MulMut(baseDenomScalingFactor.Quo(tenQuoteCoin.Amount.ToLegacyDec()))) // Apply scaling facors to descale the amounts to real amounts. currentPrice := chainPrice.MulMut(precisionScalingFactor) diff --git a/tokens/usecase/pricing/chain/pricing_chain_test.go b/tokens/usecase/pricing/chain/pricing_chain_test.go new file mode 100644 index 000000000..812d115f4 --- /dev/null +++ b/tokens/usecase/pricing/chain/pricing_chain_test.go @@ -0,0 +1,73 @@ +package chainpricing_test + +import ( + "context" + "fmt" + "testing" + + "github.com/osmosis-labs/osmosis/osmomath" + "github.com/osmosis-labs/sqs/domain/cache" + "github.com/osmosis-labs/sqs/router/usecase/routertesting" + "github.com/osmosis-labs/sqs/tokens/usecase/pricing" + "github.com/stretchr/testify/suite" +) + +func TestPricingTestSuite(t *testing.T) { + suite.Run(t, new(PricingTestSuite)) +} + +type PricingTestSuite struct { + routertesting.RouterTestHelper +} + +var ( + UOSMO = routertesting.UOSMO + ATOM = routertesting.ATOM + stOSMO = routertesting.STOSMO + stATOM = routertesting.STATOM + USDC = routertesting.USDC + USDCaxl = routertesting.USDCaxl + USDT = routertesting.USDT + WBTC = routertesting.WBTC + ETH = routertesting.ETH + AKT = routertesting.AKT + UMEE = routertesting.UMEE + UION = routertesting.UION + CRE = routertesting.CRE + + defaultPricingRouterConfig = routertesting.DefaultPricingRouterConfig + defaultPricingConfig = routertesting.DefaultPricingConfig +) + +func (s *PricingTestSuite) TestGetPrices_Chain() { + + // Set up mainnet mock state. + router, mainnetState := s.SetupMainnetRouter(defaultPricingRouterConfig) + mainnetUsecase := s.SetupRouterAndPoolsUsecase(router, mainnetState, cache.New(), cache.New()) + + // Set up on-chain pricing strategy + pricingStrategy, err := pricing.NewPricingStrategy(defaultPricingConfig, mainnetUsecase.Tokens, mainnetUsecase.Router) + s.Require().NoError(err) + + s.Require().NotZero(len(routertesting.MainnetDenoms)) + for _, mainnetDenom := range routertesting.MainnetDenoms { + mainnetDenom := mainnetDenom + s.Run(mainnetDenom, func() { + + // System under test. + usdcPrice, err := pricingStrategy.GetPrice(context.Background(), mainnetDenom, USDC) + s.Require().NoError(err) + + usdtPrice, err := pricingStrategy.GetPrice(context.Background(), mainnetDenom, USDT) + s.Require().NoError(err) + + errTolerance := osmomath.ErrTolerance{ + // 1% tolerance + MultiplicativeTolerance: osmomath.MustNewDecFromStr("0.07"), + } + + result := errTolerance.CompareBigDec(usdcPrice, usdtPrice) + s.Require().Zero(result, fmt.Sprintf("denom: %s, usdcPrice: %s, usdtPrice: %s", mainnetDenom, usdcPrice, usdtPrice)) + }) + } +} diff --git a/tokens/usecase/tokens_usecase_test.go b/tokens/usecase/tokens_usecase_test.go index 4478a8604..9ea353391 100644 --- a/tokens/usecase/tokens_usecase_test.go +++ b/tokens/usecase/tokens_usecase_test.go @@ -160,14 +160,14 @@ func (s *TokensUseCaseTestSuite) TestGetPrices_Chain() { usdcQuoteAny, ok := baseAssetPrices[USDC] s.Require().True(ok) - usdcQuote := s.convertAnyToBigDec(usdcQuoteAny) + usdcQuote := s.ConvertAnyToBigDec(usdcQuoteAny) usdtQuoteAny, ok := baseAssetPrices[USDT] s.Require().True(ok) - usdtQuote := s.convertAnyToBigDec(usdtQuoteAny) + usdtQuote := s.ConvertAnyToBigDec(usdtQuoteAny) result := errTolerance.CompareBigDec(usdcQuote, usdtQuote) - s.Require().Zero(result) + s.Require().Zero(result, fmt.Sprintf("usdcQuote: %s, usdtQuote: %s", usdcQuote, usdtQuote)) } // WBTC is around 50K at the time of creation of this test @@ -183,7 +183,7 @@ func (s *TokensUseCaseTestSuite) TestGetPrices_Chain() { actualwBTCUSDCPriceAny, ok := prices[WBTC][USDC] s.Require().True(ok) - actualwBTCUSDCPrice := s.convertAnyToBigDec(actualwBTCUSDCPriceAny) + actualwBTCUSDCPrice := s.ConvertAnyToBigDec(actualwBTCUSDCPriceAny) result := wbtcErrorTolerance.CompareBigDec(expectedwBTCPrice, actualwBTCUSDCPrice) s.Require().Zero(result) @@ -236,10 +236,3 @@ func (s *TokensUseCaseTestSuite) TestGetPrices_Chain_Specific() { fmt.Println(price) } - -// helper to convert any to BigDec -func (s *TokensUseCaseTestSuite) convertAnyToBigDec(any any) osmomath.BigDec { - bigDec, ok := any.(osmomath.BigDec) - s.Require().True(ok) - return bigDec -}