From ae2b8ebf22cf49835822ea31cc1df9a1a4ca4cf9 Mon Sep 17 00:00:00 2001 From: Deividas Petraitis Date: Sat, 17 Aug 2024 10:09:35 +0300 Subject: [PATCH] Feat: in given out for /custom-direct-quote (#420) * Feat: in given out for /custom-direct-quote * Feat: in given out for /custom-direct-quote Add humanDenoms support * Feat: in given out for /custom-direct-quote Update swagger * Feat: in given out for /custom-direct-quote Update docs * Feat: in given out for /custom-direct-quote Add docs for supported route types * Feat: in given out for /custom-direct-quote (#423) * Feat: in given out for /custom-direct-quote e2e tests for exact amount out init * fix merge error * WIP * WIP * Feat: in given out for /custom-direct-quote e2e tests for /custom-direct-quote * Feat: in given out for /custom-direct-quote Break run_quote_test into smaller individual parts for reusability * Feat: in given out for /custom-direct-quote Introduce calculate_expected_base_out_quote_spot_price * Feat: in given out for /custom-direct-quote Add synthetic tests for /quote and /custom-direct-quote * Update tests/route.py --------- Co-authored-by: Deividas Petraitis * code rabbit --------- Co-authored-by: Roman --- docs/docs.go | 43 ++- docs/swagger.json | 43 ++- docs/swagger.yaml | 46 ++- domain/mocks/router_usecase_mock.go | 8 + domain/mvc/router.go | 5 +- router/delivery/http/export_test.go | 6 - router/delivery/http/router_handler.go | 151 ++++----- router/delivery/http/router_handler_test.go | 242 +++++++++++---- router/types/errors.go | 3 + .../types/get_direct_custom_quote_request.go | 130 ++++++++ .../get_direct_custom_quote_request_test.go | 288 ++++++++++++++++++ .../{request.go => get_quote_request.go} | 0 ...uest_test.go => get_quote_request_test.go} | 4 + router/usecase/router_usecase.go | 26 +- router/usecase/router_usecase_test.go | 153 +++++++++- tests/quote.py | 225 +++++++++++--- tests/quote_response.py | 28 ++ tests/route.py | 14 + tests/sqs_service.py | 30 +- ...router_direct_custom_quote_in_given_out.py | 65 ++++ ...router_direct_custom_quote_out_given_in.py | 64 ++++ tests/test_router_quote_in_given_out.py | 54 +--- tests/test_router_quote_out_given_in.py | 175 +++-------- tests/test_synthetic_geo.py | 40 ++- 24 files changed, 1430 insertions(+), 413 deletions(-) create mode 100644 router/types/get_direct_custom_quote_request.go create mode 100644 router/types/get_direct_custom_quote_request_test.go rename router/types/{request.go => get_quote_request.go} (100%) rename router/types/{request_test.go => get_quote_request_test.go} (98%) create mode 100644 tests/test_router_direct_custom_quote_in_given_out.py create mode 100644 tests/test_router_direct_custom_quote_out_given_in.py diff --git a/docs/docs.go b/docs/docs.go index b4ee6bdc6..ac1c931de 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -141,7 +141,7 @@ const docTemplate = `{ }, "/router/custom-direct-quote": { "get": { - "description": "Call does not search for the route rather directly computes the quote for the given poolID.", + "description": "Call does not search for the route rather directly computes the quote for the given poolID.\nNOTE: Endpoint only supports multi-hop routes, split routes are not supported.\n\nFor exact amount in swap method, the ` + "`" + `tokenIn` + "`" + ` and ` + "`" + `tokenOutDenom` + "`" + ` are required.\nFor exact amount out swap method, the ` + "`" + `tokenOut` + "`" + ` and ` + "`" + `tokenInDenom` + "`" + ` are required.\nMixing swap method parameters in other way than specified will result in an error.\n", "produces": [ "application/json" ], @@ -150,28 +150,47 @@ const docTemplate = `{ "parameters": [ { "type": "string", - "example": "5OSMO", - "description": "String representation of the sdk.Coin for the token in.", + "example": "1000000uosmo", + "description": "String representation of the sdk.Coin denoting the input token for the exact amount in swap method.", "name": "tokenIn", - "in": "query", - "required": true + "in": "query" }, { "type": "string", - "example": "ATOM,USDC", - "description": "String representing the list of the token denom out separated by comma.", + "example": "uion", + "description": "String representing the list of the output token denominations separated by comma for the exact amount in swap method.", "name": "tokenOutDenom", - "in": "query", - "required": true + "in": "query" + }, + { + "type": "string", + "example": "2353uion", + "description": "String representation of the sdk.Coin denoting the output token for the exact amount out swap method.", + "name": "tokenOut", + "in": "query" }, { "type": "string", - "example": "1,2,3", + "example": "uosmo", + "description": "String representing the list of the input token denominations separated by comma for the exact amount out swap method.", + "name": "tokenInDenom", + "in": "query" + }, + { + "type": "string", + "example": "1100", "description": "String representing list of the pool ID.", "name": "poolID", "in": "query", "required": true }, + { + "type": "boolean", + "description": "Boolean flag indicating whether the given denoms are human readable or not. Human denoms get converted to chain internally", + "name": "humanDenoms", + "in": "query", + "required": true + }, { "type": "boolean", "description": "Boolean flag indicating whether to apply exponents to the spot price. False by default.", @@ -198,24 +217,28 @@ const docTemplate = `{ "parameters": [ { "type": "string", + "example": "1000000uosmo", "description": "String representation of the sdk.Coin denoting the input token for the exact amount in swap method.", "name": "tokenIn", "in": "query" }, { "type": "string", + "example": "uion", "description": "String representing the denomination of the output token for the exact amount in swap method.", "name": "tokenOutDenom", "in": "query" }, { "type": "string", + "example": "2353uion", "description": "String representation of the sdk.Coin denoting the output token for the exact amount out swap method.", "name": "tokenOut", "in": "query" }, { "type": "string", + "example": "uosmo", "description": "String representing the denomination of the input token for the exact amount out swap method.", "name": "tokenInDenom", "in": "query" diff --git a/docs/swagger.json b/docs/swagger.json index 70d924852..4b3f2bd03 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -132,7 +132,7 @@ }, "/router/custom-direct-quote": { "get": { - "description": "Call does not search for the route rather directly computes the quote for the given poolID.", + "description": "Call does not search for the route rather directly computes the quote for the given poolID.\nNOTE: Endpoint only supports multi-hop routes, split routes are not supported.\n\nFor exact amount in swap method, the `tokenIn` and `tokenOutDenom` are required.\nFor exact amount out swap method, the `tokenOut` and `tokenInDenom` are required.\nMixing swap method parameters in other way than specified will result in an error.\n", "produces": [ "application/json" ], @@ -141,28 +141,47 @@ "parameters": [ { "type": "string", - "example": "5OSMO", - "description": "String representation of the sdk.Coin for the token in.", + "example": "1000000uosmo", + "description": "String representation of the sdk.Coin denoting the input token for the exact amount in swap method.", "name": "tokenIn", - "in": "query", - "required": true + "in": "query" }, { "type": "string", - "example": "ATOM,USDC", - "description": "String representing the list of the token denom out separated by comma.", + "example": "uion", + "description": "String representing the list of the output token denominations separated by comma for the exact amount in swap method.", "name": "tokenOutDenom", - "in": "query", - "required": true + "in": "query" + }, + { + "type": "string", + "example": "2353uion", + "description": "String representation of the sdk.Coin denoting the output token for the exact amount out swap method.", + "name": "tokenOut", + "in": "query" }, { "type": "string", - "example": "1,2,3", + "example": "uosmo", + "description": "String representing the list of the input token denominations separated by comma for the exact amount out swap method.", + "name": "tokenInDenom", + "in": "query" + }, + { + "type": "string", + "example": "1100", "description": "String representing list of the pool ID.", "name": "poolID", "in": "query", "required": true }, + { + "type": "boolean", + "description": "Boolean flag indicating whether the given denoms are human readable or not. Human denoms get converted to chain internally", + "name": "humanDenoms", + "in": "query", + "required": true + }, { "type": "boolean", "description": "Boolean flag indicating whether to apply exponents to the spot price. False by default.", @@ -189,24 +208,28 @@ "parameters": [ { "type": "string", + "example": "1000000uosmo", "description": "String representation of the sdk.Coin denoting the input token for the exact amount in swap method.", "name": "tokenIn", "in": "query" }, { "type": "string", + "example": "uion", "description": "String representing the denomination of the output token for the exact amount in swap method.", "name": "tokenOutDenom", "in": "query" }, { "type": "string", + "example": "2353uion", "description": "String representation of the sdk.Coin denoting the output token for the exact amount out swap method.", "name": "tokenOut", "in": "query" }, { "type": "string", + "example": "uosmo", "description": "String representing the denomination of the input token for the exact amount out swap method.", "name": "tokenInDenom", "in": "query" diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 669f62611..b34d58f1f 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -157,29 +157,51 @@ paths: summary: Get entries for all supported orderbook base and quote denoms. /router/custom-direct-quote: get: - description: Call does not search for the route rather directly computes the - quote for the given poolID. + description: | + Call does not search for the route rather directly computes the quote for the given poolID. + NOTE: Endpoint only supports multi-hop routes, split routes are not supported. + + For exact amount in swap method, the `tokenIn` and `tokenOutDenom` are required. + For exact amount out swap method, the `tokenOut` and `tokenInDenom` are required. + Mixing swap method parameters in other way than specified will result in an error. operationId: get-direct-quote parameters: - - description: String representation of the sdk.Coin for the token in. - example: 5OSMO + - description: String representation of the sdk.Coin denoting the input token + for the exact amount in swap method. + example: 1000000uosmo in: query name: tokenIn - required: true type: string - - description: String representing the list of the token denom out separated - by comma. - example: ATOM,USDC + - description: String representing the list of the output token denominations + separated by comma for the exact amount in swap method. + example: uion in: query name: tokenOutDenom - required: true + type: string + - description: String representation of the sdk.Coin denoting the output token + for the exact amount out swap method. + example: 2353uion + in: query + name: tokenOut + type: string + - description: String representing the list of the input token denominations + separated by comma for the exact amount out swap method. + example: uosmo + in: query + name: tokenInDenom type: string - description: String representing list of the pool ID. - example: 1,2,3 + example: "1100" in: query name: poolID required: true type: string + - description: Boolean flag indicating whether the given denoms are human readable + or not. Human denoms get converted to chain internally + in: query + name: humanDenoms + required: true + type: boolean - description: Boolean flag indicating whether to apply exponents to the spot price. False by default. in: query @@ -206,21 +228,25 @@ paths: parameters: - description: String representation of the sdk.Coin denoting the input token for the exact amount in swap method. + example: 1000000uosmo in: query name: tokenIn type: string - description: String representing the denomination of the output token for the exact amount in swap method. + example: uion in: query name: tokenOutDenom type: string - description: String representation of the sdk.Coin denoting the output token for the exact amount out swap method. + example: 2353uion in: query name: tokenOut type: string - description: String representing the denomination of the input token for the exact amount out swap method. + example: uosmo in: query name: tokenInDenom type: string diff --git a/domain/mocks/router_usecase_mock.go b/domain/mocks/router_usecase_mock.go index 6caa9e067..6a5bff635 100644 --- a/domain/mocks/router_usecase_mock.go +++ b/domain/mocks/router_usecase_mock.go @@ -23,6 +23,7 @@ type RouterUsecaseMock struct { GetBestSingleRouteQuoteFunc func(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom string) (domain.Quote, error) GetCustomDirectQuoteFunc func(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom string, poolID uint64) (domain.Quote, error) GetCustomDirectQuoteMultiPoolFunc func(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom []string, poolIDs []uint64) (domain.Quote, error) + GetCustomDirectQuoteMultiPoolInGivenOutFunc func(ctx context.Context, tokenOut sdk.Coin, tokenInDenom []string, poolIDs []uint64) (domain.Quote, error) GetCandidateRoutesFunc func(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom string) (sqsdomain.CandidateRoutes, error) GetTakerFeeFunc func(poolID uint64) ([]sqsdomain.TakerFeeForPair, error) SetTakerFeesFunc func(takerFees sqsdomain.TakerFeeMap) @@ -93,6 +94,13 @@ func (m *RouterUsecaseMock) GetCustomDirectQuoteMultiPool(ctx context.Context, t panic("unimplemented") } +func (m *RouterUsecaseMock) GetCustomDirectQuoteMultiPoolInGivenOut(ctx context.Context, tokenOut sdk.Coin, tokenInDenom []string, poolIDs []uint64) (domain.Quote, error) { + if m.GetCustomDirectQuoteMultiPoolInGivenOutFunc != nil { + return m.GetCustomDirectQuoteMultiPoolInGivenOutFunc(ctx, tokenOut, tokenInDenom, poolIDs) + } + panic("unimplemented") +} + func (m *RouterUsecaseMock) GetCandidateRoutes(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom string) (sqsdomain.CandidateRoutes, error) { if m.GetCandidateRoutesFunc != nil { return m.GetCandidateRoutesFunc(ctx, tokenIn, tokenOutDenom) diff --git a/domain/mvc/router.go b/domain/mvc/router.go index f64274685..98adf158a 100644 --- a/domain/mvc/router.go +++ b/domain/mvc/router.go @@ -67,8 +67,11 @@ type RouterUsecase interface { // This allows to bypass a min liquidity requirement in the router when attempting to swap over a specific pool. GetCustomDirectQuote(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom string, poolID uint64) (domain.Quote, error) // GetCustomDirectQuoteMultiPool calculates direct custom quote for given tokenIn and tokenOutDenom over given poolID route. - // Otherwise it implements same rules as GetCustomDirectQuote. + // Underlying implementation uses GetCustomDirectQuote. GetCustomDirectQuoteMultiPool(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom []string, poolIDs []uint64) (domain.Quote, error) + // GetCustomDirectQuoteMultiPool calculates direct custom quote for given tokenOut and tokenInDenom over given poolID route. + // Underlying implementation uses GetCustomDirectQuote. + GetCustomDirectQuoteMultiPoolInGivenOut(ctx context.Context, tokenOut sdk.Coin, tokenInDenom []string, poolIDs []uint64) (domain.Quote, error) // GetCandidateRoutes returns the candidate routes for the given tokenIn and tokenOutDenom. GetCandidateRoutes(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom string) (sqsdomain.CandidateRoutes, error) // GetTakerFee returns the taker fee for all token pairs in a pool. diff --git a/router/delivery/http/export_test.go b/router/delivery/http/export_test.go index 0c83b37b4..d02cfda64 100644 --- a/router/delivery/http/export_test.go +++ b/router/delivery/http/export_test.go @@ -1,7 +1 @@ package http - -import "github.com/labstack/echo/v4" - -func GetPoolsValidTokenInTokensOut(c echo.Context) (poolIDs []uint64, tokenOut []string, tokenIn string, err error) { - return getPoolsValidTokenInTokensOut(c) -} diff --git a/router/delivery/http/router_handler.go b/router/delivery/http/router_handler.go index 58207f0eb..83137c7c6 100644 --- a/router/delivery/http/router_handler.go +++ b/router/delivery/http/router_handler.go @@ -1,10 +1,8 @@ package http import ( - "errors" "net/http" "strconv" - "strings" "github.com/labstack/echo/v4" "go.opentelemetry.io/otel/attribute" @@ -63,10 +61,10 @@ func NewRouterHandler(e *echo.Echo, us mvc.RouterUsecase, tu mvc.TokensUsecase, // @Description When `singleRoute` parameter is set to true, it gives the best single quote while excluding splits. // @ID get-route-quote // @Produce json -// @Param tokenIn query string false "String representation of the sdk.Coin denoting the input token for the exact amount in swap method." -// @Param tokenOutDenom query string false "String representing the denomination of the output token for the exact amount in swap method." -// @Param tokenOut query string false "String representation of the sdk.Coin denoting the output token for the exact amount out swap method." -// @Param tokenInDenom query string false "String representing the denomination of the input token for the exact amount out swap method." +// @Param tokenIn query string false "String representation of the sdk.Coin denoting the input token for the exact amount in swap method." example(1000000uosmo) +// @Param tokenOutDenom query string false "String representing the denomination of the output token for the exact amount in swap method." example(uion) +// @Param tokenOut query string false "String representation of the sdk.Coin denoting the output token for the exact amount out swap method." example(2353uion) +// @Param tokenInDenom query string false "String representing the denomination of the input token for the exact amount out swap method." example(uosmo) // @Param singleRoute query bool false "Boolean flag indicating whether to return single routes (no splits). False (splits enabled) by default." // @Param humanDenoms query bool true "Boolean flag indicating whether the given denoms are human readable or not. Human denoms get converted to chain internally" // @Param applyExponents query bool false "Boolean flag indicating whether to apply exponents to the spot price. False by default." @@ -150,39 +148,80 @@ func (a *RouterHandler) GetOptimalQuote(c echo.Context) (err error) { // @Summary Compute the quote for the given poolID // @Description Call does not search for the route rather directly computes the quote for the given poolID. +// @Description NOTE: Endpoint only supports multi-hop routes, split routes are not supported. +// @Description +// @Description For exact amount in swap method, the `tokenIn` and `tokenOutDenom` are required. +// @Description For exact amount out swap method, the `tokenOut` and `tokenInDenom` are required. +// @Description Mixing swap method parameters in other way than specified will result in an error. +// @Description // @ID get-direct-quote // @Produce json -// @Param tokenIn query string true "String representation of the sdk.Coin for the token in." example(5OSMO) -// @Param tokenOutDenom query string true "String representing the list of the token denom out separated by comma." example(ATOM,USDC) -// @Param poolID query string true "String representing list of the pool ID." example(1,2,3) +// @Param tokenIn query string false "String representation of the sdk.Coin denoting the input token for the exact amount in swap method." example(1000000uosmo) +// @Param tokenOutDenom query string false "String representing the list of the output token denominations separated by comma for the exact amount in swap method." example(uion) +// @Param tokenOut query string false "String representation of the sdk.Coin denoting the output token for the exact amount out swap method." example(2353uion) +// @Param tokenInDenom query string false "String representing the list of the input token denominations separated by comma for the exact amount out swap method." example(uosmo) +// @Param poolID query string true "String representing list of the pool ID." example(1100) +// @Param humanDenoms query bool true "Boolean flag indicating whether the given denoms are human readable or not. Human denoms get converted to chain internally" // @Param applyExponents query bool false "Boolean flag indicating whether to apply exponents to the spot price. False by default." // @Success 200 {object} domain.Quote "The computed best route quote" // @Router /router/custom-direct-quote [get] -func (a *RouterHandler) GetDirectCustomQuote(c echo.Context) error { +func (a *RouterHandler) GetDirectCustomQuote(c echo.Context) (err error) { ctx := c.Request().Context() - poolIDs, tokenOutDenom, tokenIn, err := getDirectCustomQuoteParameters(c) - if err != nil { - return c.JSON(http.StatusBadRequest, domain.ResponseError{Message: err.Error()}) - } - - shouldApplyExponentsStr := c.QueryParam("applyExponents") - shouldApplyExponents := false - if shouldApplyExponentsStr != "" { - shouldApplyExponents, err = strconv.ParseBool(shouldApplyExponentsStr) + defer func() { if err != nil { - return c.JSON(domain.GetStatusCode(err), domain.ResponseError{Message: err.Error()}) + // nolint:errcheck // ignore error + c.JSON(domain.GetStatusCode(err), domain.ResponseError{Message: err.Error()}) } + + // Note: we do not end the span here as it is ended in the middleware. + }() + + var req types.GetDirectCustomQuoteRequest + if err := UnmarshalRequest(c, &req); err != nil { + return err + } + + // Validate the request + if err := req.Validate(); err != nil { + return err } - // Quote - quote, err := a.RUsecase.GetCustomDirectQuoteMultiPool(ctx, tokenIn, tokenOutDenom, poolIDs) + var ( + tokenIn *sdk.Coin + tokenOutDenom []string + ) + + // Determine the tokenIn and tokenOutDenom based on the swap method. + if req.SwapMethod() == domain.TokenSwapMethodExactIn { + tokenIn, tokenOutDenom = req.TokenIn, req.TokenOutDenom + } else { + tokenIn, tokenOutDenom = req.TokenOut, req.TokenInDenom + } + + // Apply human denoms conversion if required. + chainDenoms, err := mvc.ValidateChainDenomsQueryParam(c, a.TUsecase, append([]string{tokenIn.Denom}, tokenOutDenom...)) + if err != nil { + return err + } + + // Update coins token in denom it case it was translated from human to chain. + tokenIn.Denom = chainDenoms[0] + tokenOutDenom = chainDenoms[1:] + + // Get the quote based on the swap method. + var quote domain.Quote + if req.SwapMethod() == domain.TokenSwapMethodExactIn { + quote, err = a.RUsecase.GetCustomDirectQuoteMultiPool(ctx, *tokenIn, tokenOutDenom, req.PoolID) + } else { + quote, err = a.RUsecase.GetCustomDirectQuoteMultiPoolInGivenOut(ctx, *tokenIn, tokenOutDenom, req.PoolID) + } if err != nil { return c.JSON(domain.GetStatusCode(err), domain.ResponseError{Message: err.Error()}) } scalingFactor := oneDec - if shouldApplyExponents { + if req.ApplyExponents { scalingFactor = a.getSpotPriceScalingFactor(tokenIn.Denom, tokenOutDenom[len(tokenOutDenom)-1]) } @@ -309,21 +348,6 @@ func (a *RouterHandler) GetSpotPriceForPool(c echo.Context) error { return c.JSON(http.StatusOK, spotPrice) } -// getDirectCustomQuoteParameters returns the pool IDs, tokenIn and tokenOutDenom from server context if they are valid. -func getDirectCustomQuoteParameters(c echo.Context) ([]uint64, []string, sdk.Coin, error) { - poolID, tokenOut, tokenInStr, err := getPoolsValidTokenInTokensOut(c) - if err != nil { - return nil, nil, sdk.Coin{}, err - } - - tokenIn, err := sdk.ParseCoinNormalized(tokenInStr) - if err != nil { - return nil, nil, sdk.Coin{}, types.ErrTokenInNotValid - } - - return poolID, tokenOut, tokenIn, nil -} - // getSpotPriceScalingFactor returns the spot price scaling factor for a given tokenIn and tokenOutDenom. func (a *RouterHandler) getSpotPriceScalingFactor(tokenInDenom, tokenOutDenom string) osmomath.Dec { scalingFactor, err := a.TUsecase.GetSpotPriceScalingFactorByDenom(tokenOutDenom, tokenInDenom) @@ -365,54 +389,3 @@ func getValidTokenInTokenOutStr(c echo.Context) (tokenOutStr, tokenInStr string, return tokenOutStr, tokenInStr, nil } - -func getValidPoolID(c echo.Context) ([]uint64, error) { - // We accept two poolIDs and poolID parameters, and require at least one of them to be filled - poolIDParam := c.QueryParam("poolID") - poolIDStr := strings.Split(poolIDParam, ",") - if len(poolIDStr) == 0 { - return nil, errors.New("poolID is required") - } - - var poolIDs []uint64 - for _, v := range poolIDStr { - i, err := strconv.ParseUint(v, 10, 64) - if err != nil { - return nil, err - } - poolIDs = append(poolIDs, i) - } - - return poolIDs, nil -} - -func getPoolsValidTokenInTokensOut(c echo.Context) (poolIDs []uint64, tokenOut []string, tokenIn string, err error) { - poolIDs, err = getValidPoolID(c) - if err != nil { - return nil, nil, "", err - } - - tokenIn, err = getValidTokenInStr(c) - if err != nil { - return nil, nil, "", err - } - - tokenOut = strings.Split(c.QueryParam("tokenOutDenom"), ",") - if len(tokenOut) == 0 { - return nil, nil, "", errors.New("tokenOutDenom is required") - } - - // one output per each pool - if len(tokenOut) != len(poolIDs) { - return nil, nil, "", types.ErrNumOfTokenOutDenomPoolsMismatch - } - - // Validate denoms - for _, v := range tokenOut { - if err := domain.ValidateInputDenoms(tokenIn, v); err != nil { - return nil, nil, "", err - } - } - - return poolIDs, tokenOut, tokenIn, nil -} diff --git a/router/delivery/http/router_handler_test.go b/router/delivery/http/router_handler_test.go index 88468cdca..74151d92f 100644 --- a/router/delivery/http/router_handler_test.go +++ b/router/delivery/http/router_handler_test.go @@ -2,10 +2,9 @@ package http_test import ( "context" - "errors" + "fmt" "net/http" "net/http/httptest" - "slices" "strings" "testing" @@ -166,76 +165,209 @@ func (s *RouterHandlerSuite) TestGetOptimalQuote() { } } -// TestGetPoolsValidTokenInTokensOut tests parsing pools, token in and token out parameters -// from the request. -func TestGetPoolsValidTokenInTokensOut(t *testing.T) { - testCases := []struct { - name string - - // input - uri string - - // expected output - tokenIn string - poolIDs []uint64 - tokenOut []string +func (s *RouterHandlerSuite) TestGetDirectCustomQuote() { + // Prepare 3 pools, we create once and reuse them in the test cases + // It's done to avoid creating them multiple times and increasing pool IDs counter. + _, poolOne := s.PoolOne() + _, poolTwo := s.PoolTwo() + _, poolThree := s.PoolThree() - err error + testcases := []struct { + name string + queryParams map[string]string + handler *routerdelivery.RouterHandler + expectedStatusCode int + expectedResponse string + expectedError error }{ { - name: "happy case - token through single pool", - uri: "http://localhost?tokenIn=10OSMO&poolID=1&tokenOutDenom=USDC", - tokenIn: "10OSMO", - poolIDs: []uint64{1}, - tokenOut: []string{"USDC"}, + name: "valid exact in request", + queryParams: map[string]string{ + "tokenIn": "1000ibc/EA1D43981D5C9A1C4AAEA9C23BB1D4FA126BA9BC7020A25E0AE4AA841EA25DC5", + "tokenOutDenom": "ibc/498A0751C798A0D9A389AA3691123DADA57DAA4FE165D5C75894505B876BA6E4", + "poolID": "10", + "applyExponents": "true", + }, + handler: &routerdelivery.RouterHandler{ + TUsecase: &mocks.TokensUsecaseMock{ + IsValidChainDenomFunc: func(chainDenom string) bool { + return true + }, + }, + RUsecase: &mocks.RouterUsecaseMock{ + GetCustomDirectQuoteMultiPoolFunc: func(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom []string, poolIDs []uint64) (domain.Quote, error) { + return s.NewExactAmountInQuote(poolOne, poolTwo, poolThree), nil + }, + }, + }, + expectedStatusCode: http.StatusOK, + expectedResponse: s.MustReadFile("../../usecase/routertesting/parsing/quote_amount_in_response.json"), + expectedError: nil, + }, + { + name: "valid exact out request", + queryParams: map[string]string{ + "tokenOut": "1000ibc/498A0751C798A0D9A389AA3691123DADA57DAA4FE165D5C75894505B876BA6E4", + "tokenInDenom": "ibc/EA1D43981D5C9A1C4AAEA9C23BB1D4FA126BA9BC7020A25E0AE4AA841EA25DC5", + "poolID": "10", + "applyExponents": "true", + }, + handler: &routerdelivery.RouterHandler{ + TUsecase: &mocks.TokensUsecaseMock{ + IsValidChainDenomFunc: func(chainDenom string) bool { + return true + }, + }, + RUsecase: &mocks.RouterUsecaseMock{ + GetCustomDirectQuoteMultiPoolInGivenOutFunc: func(ctx context.Context, tokenOut sdk.Coin, tokenInDenom []string, poolIDs []uint64) (domain.Quote, error) { + return s.NewExactAmountOutQuote(poolOne, poolTwo, poolThree), nil + }, + }, + }, + expectedStatusCode: http.StatusOK, + expectedResponse: s.MustReadFile("../../usecase/routertesting/parsing/quote_amount_out_response.json"), + expectedError: nil, + }, + { + name: "valid exact out request: apply human denom", + queryParams: map[string]string{ + "tokenOut": "1000usdc", + "tokenInDenom": "eth", + "poolID": "10", + "applyExponents": "true", + "humanDenoms": "true", + }, + handler: &routerdelivery.RouterHandler{ + TUsecase: &mocks.TokensUsecaseMock{ + IsValidChainDenomFunc: func(chainDenom string) bool { + // because we are applying human denoms + // test will fail with humanDenoms set to false + return false + }, + }, + RUsecase: &mocks.RouterUsecaseMock{ + GetCustomDirectQuoteMultiPoolInGivenOutFunc: func(ctx context.Context, tokenOut sdk.Coin, tokenInDenom []string, poolIDs []uint64) (domain.Quote, error) { + return s.NewExactAmountOutQuote(poolOne, poolTwo, poolThree), nil + }, + }, + }, + expectedStatusCode: http.StatusOK, + expectedResponse: s.MustReadFile("../../usecase/routertesting/parsing/quote_amount_out_response.json"), + expectedError: nil, + }, + { + name: "not valid exact out request: apply human denom", + queryParams: map[string]string{ + "tokenOut": "1000usdc", + "tokenInDenom": "eth", + "poolID": "10", + "applyExponents": "true", + "humanDenoms": "false", + }, + handler: &routerdelivery.RouterHandler{ + TUsecase: &mocks.TokensUsecaseMock{ + IsValidChainDenomFunc: func(chainDenom string) bool { + // because we are applying human denoms + // test will fail with humanDenoms set to false + return false + }, + }, + RUsecase: &mocks.RouterUsecaseMock{ + GetCustomDirectQuoteMultiPoolInGivenOutFunc: func(ctx context.Context, tokenOut sdk.Coin, tokenInDenom []string, poolIDs []uint64) (domain.Quote, error) { + return s.NewExactAmountOutQuote(poolOne, poolTwo, poolThree), nil + }, + }, + }, + expectedStatusCode: http.StatusInternalServerError, + expectedResponse: `{"message":"denom is not a valid chain denom (usdc)"}`, + expectedError: fmt.Errorf("denom is not a valid chain denom (%s)", "usdc"), + }, + { + name: "invalid swap method request", + queryParams: map[string]string{ + "tokenIn": "1000ust", + "tokenOutDenom": "usdc", + "tokenOut": "1000usdc", + "tokenInDenom": "atom", + "poolID": "10", + "applyExponents": "true", + }, + expectedStatusCode: http.StatusInternalServerError, + expectedResponse: `{"message":"swap method is invalid - must be either swap exact amount in or swap exact amount out"}`, + expectedError: routertypes.ErrSwapMethodNotValid, }, { - name: "fail case - token through single pool", - uri: "http://localhost?tokenIn=&poolID=1&tokenOutDenom=USDC", - err: routertypes.ErrTokenInNotSpecified, + name: "invalid pools request", + queryParams: map[string]string{ + "tokenIn": "1000ust", + "tokenOutDenom": "usdc", + "tokenOut": "1000usdc", + "tokenInDenom": "atom", + "poolID": "string,5", + "applyExponents": "true", + }, + expectedStatusCode: http.StatusInternalServerError, + expectedResponse: `{"message":"pool ID must be integer"}`, + expectedError: routertypes.ErrPoolIDNotValid, }, { - name: "happy case - token through multi pool", - uri: "http://localhost?tokenIn=56OSMO&poolID=1,5,7&tokenOutDenom=ATOM,AKT,USDC", - tokenIn: "56OSMO", - poolIDs: []uint64{1, 5, 7}, - tokenOut: []string{"ATOM", "AKT", "USDC"}, + name: "invalid tokenIn format", + queryParams: map[string]string{ + "tokenIn": "invalid_denom", + "tokenOutDenom": "usdc", + "singleRoute": "true", + "applyExponents": "true", + }, + expectedStatusCode: http.StatusInternalServerError, + expectedResponse: `{"message":"tokenIn is invalid - must be in the format amountDenom"}`, + expectedError: routertypes.ErrTokenInNotValid, }, { - name: "fail case - token through multi pool", - uri: "http://localhost?tokenIn=56OSMO&poolID=1,5&tokenOutDenom=ATOM,AKT,USDC", - err: routertypes.ErrNumOfTokenOutDenomPoolsMismatch, + name: "invalid tokenOut format", + queryParams: map[string]string{ + "tokenInDenom": "ust", + "tokenOut": "invalid_denom", + "singleRoute": "true", + "applyExponents": "true", + }, + expectedStatusCode: http.StatusInternalServerError, + expectedResponse: `{"message":"tokenOut is invalid - must be in the format amountDenom"}`, + expectedError: routertypes.ErrTokenOutNotValid, }, } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - ctx := echo.New().NewContext( - httptest.NewRequest(http.MethodGet, tc.uri, nil), - nil, - ) - - poolIDs, tokenOut, tokenIn, err := routerdelivery.GetPoolsValidTokenInTokensOut(ctx) - if !errors.Is(err, tc.err) { - t.Fatalf("got %v, want %v", err, tc.err) - } - - // on error output of the function is undefined - if err != nil { - t.SkipNow() + for _, tc := range testcases { + s.Run(tc.name, func() { + e := echo.New() + req := httptest.NewRequest(echo.POST, "/", nil) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + q := req.URL.Query() + for k, v := range tc.queryParams { + q.Add(k, v) } + req.URL.RawQuery = q.Encode() + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) - if slices.Compare(poolIDs, tc.poolIDs) != 0 { - t.Fatalf("got %v, want %v", poolIDs, tc.poolIDs) - } + err := tc.handler.GetDirectCustomQuote(c) - if slices.Compare(tokenOut, tc.tokenOut) != 0 { - t.Fatalf("got %v, want %v", tokenOut, tc.tokenOut) + if tc.expectedError != nil { + s.Assert().Error(err) + s.Assert().Equal(tc.expectedError, err) + s.Assert().Equal(tc.expectedStatusCode, rec.Code) + s.Assert().Equal( + strings.TrimSpace(tc.expectedResponse), + strings.TrimSpace(rec.Body.String()), + ) + return } - if tokenIn != tc.tokenIn { - t.Fatalf("got %v, want %v", tokenIn, tc.tokenIn) - } + s.Assert().NoError(err) + s.Assert().Equal(tc.expectedStatusCode, rec.Code) + s.Assert().JSONEq( + strings.TrimSpace(tc.expectedResponse), + strings.TrimSpace(rec.Body.String()), + ) }) } } diff --git a/router/types/errors.go b/router/types/errors.go index c66876ebc..fbe8ad296 100644 --- a/router/types/errors.go +++ b/router/types/errors.go @@ -4,6 +4,7 @@ import "errors" // Handler Errors var ( + ErrValidationFailed = errors.New("validation failed") ErrTokenInNotValid = errors.New("tokenIn is invalid - must be in the format amountDenom") ErrTokenOutNotValid = errors.New("tokenOut is invalid - must be in the format amountDenom") ErrTokenInDenomNotSpecified = errors.New("tokenInDenom is required") @@ -11,6 +12,8 @@ var ( ErrTokenOutNotSpecified = errors.New("tokenOut is required") ErrTokenInNotSpecified = errors.New("tokenIn is required") ErrSwapMethodNotValid = errors.New("swap method is invalid - must be either swap exact amount in or swap exact amount out") + ErrPoolIDNotValid = errors.New("pool ID must be integer") ErrNumOfTokenOutDenomPoolsMismatch = errors.New("number of tokenOutDenom must be equal to number of pool IDs") + ErrNumOfTokenInDenomPoolsMismatch = errors.New("number of tokenInDenom must be equal to number of pool IDs") ErrInvalidRouteType = errors.New("invalid route type") ) diff --git a/router/types/get_direct_custom_quote_request.go b/router/types/get_direct_custom_quote_request.go new file mode 100644 index 000000000..67462bca0 --- /dev/null +++ b/router/types/get_direct_custom_quote_request.go @@ -0,0 +1,130 @@ +package types + +import ( + "errors" + "strconv" + "strings" + + "github.com/osmosis-labs/sqs/domain" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/labstack/echo/v4" +) + +// GetDirectCustomQuoteRequest represents +type GetDirectCustomQuoteRequest struct { + TokenIn *sdk.Coin + TokenOutDenom []string + TokenOut *sdk.Coin + TokenInDenom []string + PoolID []uint64 // list of the pool ID + ApplyExponents bool // Boolean flag indicating whether to apply exponents to the spot price. False by default. +} + +// UnmarshalHTTPRequest unmarshals the HTTP request to GetDirectCustomQuoteRequest. +// It returns an error if the request is invalid. +// NOTE: Currently method for some cases returns an error, while for others +// it returns a response error. This is not consistent and should be fixed. +func (r *GetDirectCustomQuoteRequest) UnmarshalHTTPRequest(c echo.Context) error { + var err error + r.ApplyExponents, err = domain.ParseBooleanQueryParam(c, "applyExponents") + if err != nil { + return c.JSON(domain.GetStatusCode(err), domain.ResponseError{Message: err.Error()}) + } + + if tokenIn := c.QueryParam("tokenIn"); tokenIn != "" { + tokenInCoin, err := sdk.ParseCoinNormalized(tokenIn) + if err != nil { + return ErrTokenInNotValid + } + r.TokenIn = &tokenInCoin + } + + if tokenOut := c.QueryParam("tokenOut"); tokenOut != "" { + tokenOutCoin, err := sdk.ParseCoinNormalized(tokenOut) + if err != nil { + return ErrTokenOutNotValid + } + r.TokenOut = &tokenOutCoin + } + + r.TokenInDenom = strings.Split(c.QueryParam("tokenInDenom"), ",") + r.TokenOutDenom = strings.Split(c.QueryParam("tokenOutDenom"), ",") + + // We accept two poolIDs and poolID parameters, and require at least one of them to be filled + poolIDStr := strings.Split(c.QueryParam("poolID"), ",") + if len(poolIDStr) == 0 { + return errors.New("poolID is required") + } + + for _, v := range poolIDStr { + i, err := strconv.ParseUint(v, 10, 64) + if err != nil { + return ErrPoolIDNotValid + } + r.PoolID = append(r.PoolID, i) + } + + return nil +} + +// SwapMethod returns the swap method of the request. +// Request may contain data for both swap methods, only one of them should be specified, otherwise it's invalid. +func (r *GetDirectCustomQuoteRequest) SwapMethod() domain.TokenSwapMethod { + exactIn := r.TokenIn != nil && len(r.TokenOutDenom) > 0 + exactOut := r.TokenOut != nil && len(r.TokenInDenom) > 0 + + if exactIn && exactOut { + return domain.TokenSwapMethodInvalid + } + + if exactIn { + return domain.TokenSwapMethodExactIn + } + + if exactOut { + return domain.TokenSwapMethodExactOut + } + + return domain.TokenSwapMethodInvalid +} + +// Validate validates the GetQuoteRequest. +func (r *GetDirectCustomQuoteRequest) Validate() error { + method := r.SwapMethod() + if method == domain.TokenSwapMethodInvalid { + return ErrSwapMethodNotValid + } + + // Validate swap method exact amount in + if method == domain.TokenSwapMethodExactIn { + // one output per each pool + if len(r.TokenOutDenom) != len(r.PoolID) { + return ErrNumOfTokenOutDenomPoolsMismatch + } + + // no duplicate denoms allowed + for _, v := range r.TokenOutDenom { + if err := domain.ValidateInputDenoms(r.TokenIn.Denom, v); err != nil { + return err + } + } + } + + // Validate swap method exact amount out + if method == domain.TokenSwapMethodExactOut { + // one output per each pool + if len(r.TokenInDenom) != len(r.PoolID) { + return ErrNumOfTokenInDenomPoolsMismatch + } + + // no duplicate denoms allowed + for _, v := range r.TokenInDenom { + if err := domain.ValidateInputDenoms(r.TokenOut.Denom, v); err != nil { + return err + } + } + } + + return nil +} diff --git a/router/types/get_direct_custom_quote_request_test.go b/router/types/get_direct_custom_quote_request_test.go new file mode 100644 index 000000000..699f79ba7 --- /dev/null +++ b/router/types/get_direct_custom_quote_request_test.go @@ -0,0 +1,288 @@ +package types_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/labstack/echo/v4" + "github.com/osmosis-labs/sqs/domain" + "github.com/osmosis-labs/sqs/router/types" + + sdk "github.com/cosmos/cosmos-sdk/types" + + "github.com/stretchr/testify/assert" +) + +// TestGetDirectCustomQuoteRequestUnmarshal tests the UnmarshalHTTPRequest method of GetDirectCustomQuoteRequest. +func TestGetDirectCustomQuoteRequestUnmarshal(t *testing.T) { + testcases := []struct { + name string + queryParams map[string]string + expectedResult *types.GetDirectCustomQuoteRequest + expectedError error + expectedStatus int + expectedBody string + }{ + { + name: "valid request with tokenIn and tokenOut", + queryParams: map[string]string{ + "tokenIn": "1000ust", + "tokenOutDenom": "usdc,ion", + "tokenOut": "1000usdc", + "tokenInDenom": "atom,uosmo", + "poolID": "1,23", + "applyExponents": "true", + }, + expectedResult: &types.GetDirectCustomQuoteRequest{ + TokenIn: &sdk.Coin{Denom: "ust", Amount: sdk.NewInt(1000)}, + TokenOutDenom: []string{"usdc", "ion"}, + TokenOut: &sdk.Coin{Denom: "usdc", Amount: sdk.NewInt(1000)}, + TokenInDenom: []string{"atom", "uosmo"}, + PoolID: []uint64{1, 23}, + ApplyExponents: true, + }, + expectedError: nil, + expectedStatus: http.StatusOK, + expectedBody: "", + }, + { + name: "invalid poolID param", + queryParams: map[string]string{ + "tokenIn": "1000ust", + "tokenOut": "1000usdc", + "singleRoute": "true", + "poolID": "invalid,10", + }, + expectedResult: nil, + expectedError: types.ErrPoolIDNotValid, + expectedStatus: http.StatusInternalServerError, + }, + { + name: "invalid applyExponents param", + queryParams: map[string]string{ + "tokenIn": "1000ust", + "tokenOut": "1000usdc", + "singleRoute": "true", + "applyExponents": "invalid", + }, + expectedResult: nil, + expectedError: nil, + expectedStatus: http.StatusInternalServerError, + expectedBody: `{"message":"strconv.ParseBool: parsing \"invalid\": invalid syntax"}`, + }, + { + name: "invalid tokenIn param", + queryParams: map[string]string{ + "tokenIn": "invalid_token", + "tokenOut": "1000usdc", + "singleRoute": "true", + "applyExponents": "true", + }, + expectedResult: nil, + expectedError: types.ErrTokenInNotValid, + }, + { + name: "invalid tokenOut param", + queryParams: map[string]string{ + "tokenIn": "1000ust", + "tokenOut": "invalid_token", + "singleRoute": "true", + "applyExponents": "true", + }, + expectedResult: nil, + expectedError: types.ErrTokenOutNotValid, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + e := echo.New() + req := httptest.NewRequest(echo.GET, "/", nil) + q := req.URL.Query() + for k, v := range tc.queryParams { + q.Add(k, v) + } + req.URL.RawQuery = q.Encode() + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + var result types.GetDirectCustomQuoteRequest + err := (&result).UnmarshalHTTPRequest(c) + + if tc.expectedError != nil { + assert.Error(t, err) + assert.Equal(t, tc.expectedError, err) + return + } + + assert.NoError(t, err) + assert.Equal(t, tc.expectedStatus, rec.Code) + assert.Equal(t, tc.expectedBody, strings.TrimSpace(rec.Body.String())) // JSONEq fails + + // GetDirectCustomQuoteRequest must contain the expected result if the status is OK + if tc.expectedStatus == http.StatusOK { + assert.Equal(t, tc.expectedResult, &result) + } + }) + } +} + +// TestGetDirectCustomQuoteRequestSwapMethod tests the SwapMethod method of GetDirectCustomQuoteRequest. +func TestGetDirectCustomQuoteRequestSwapMethod(t *testing.T) { + testcases := []struct { + name string + request *types.GetDirectCustomQuoteRequest + expectedMethod domain.TokenSwapMethod + }{ + { + name: "valid exact in swap method", + request: &types.GetDirectCustomQuoteRequest{ + TokenIn: &sdk.Coin{Denom: "ust", Amount: sdk.NewInt(1000)}, + TokenOutDenom: []string{"usdc"}, + PoolID: []uint64{1}, + }, + expectedMethod: domain.TokenSwapMethodExactIn, + }, + { + name: "valid exact out swap method", + request: &types.GetDirectCustomQuoteRequest{ + TokenOut: &sdk.Coin{Denom: "usdc", Amount: sdk.NewInt(1000)}, + TokenInDenom: []string{"ust"}, + PoolID: []uint64{1}, + }, + expectedMethod: domain.TokenSwapMethodExactOut, + }, + { + name: "invalid swap method with both tokenIn and tokenOut", + request: &types.GetDirectCustomQuoteRequest{ + TokenIn: &sdk.Coin{Denom: "ust", Amount: sdk.NewInt(1000)}, + TokenOut: &sdk.Coin{Denom: "usdc", Amount: sdk.NewInt(1000)}, + TokenInDenom: []string{"ust"}, + TokenOutDenom: []string{"usdc"}, + PoolID: []uint64{1}, + }, + expectedMethod: domain.TokenSwapMethodInvalid, + }, + { + name: "invalid swap method with only tokenIn", + request: &types.GetDirectCustomQuoteRequest{ + TokenIn: &sdk.Coin{Denom: "ust", Amount: sdk.NewInt(1000)}, + }, + expectedMethod: domain.TokenSwapMethodInvalid, + }, + { + name: "invalid swap method with only tokenOut", + request: &types.GetDirectCustomQuoteRequest{ + TokenOut: &sdk.Coin{Denom: "usdc", Amount: sdk.NewInt(1000)}, + }, + expectedMethod: domain.TokenSwapMethodInvalid, + }, + { + name: "invalid swap method with neither tokenIn nor tokenOut", + request: &types.GetDirectCustomQuoteRequest{}, + expectedMethod: domain.TokenSwapMethodInvalid, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + method := tc.request.SwapMethod() + assert.Equal(t, tc.expectedMethod, method) + }) + } +} + +// TestGetDirectCustomQuoteRequestValidate tests the Validate method of GetDirectCustomQuoteRequest. +func TestGetDirectCustomQuoteRequestValidate(t *testing.T) { + testcases := []struct { + name string + request *types.GetDirectCustomQuoteRequest + expectedError error + }{ + { + name: "valid exact in request", + request: &types.GetDirectCustomQuoteRequest{ + TokenIn: &sdk.Coin{Denom: "ust", Amount: sdk.NewInt(1000)}, + TokenOutDenom: []string{"usdc"}, + PoolID: []uint64{1}, + }, + expectedError: nil, + }, + { + name: "exact in request pool id and token out denom mismatch", + request: &types.GetDirectCustomQuoteRequest{ + TokenIn: &sdk.Coin{Denom: "ust", Amount: sdk.NewInt(1000)}, + TokenOutDenom: []string{"usdc", "usdt", "uusd"}, + PoolID: []uint64{1, 2}, + }, + expectedError: types.ErrNumOfTokenOutDenomPoolsMismatch, + }, + { + name: "valid exact out request", + request: &types.GetDirectCustomQuoteRequest{ + TokenOut: &sdk.Coin{Denom: "usdc", Amount: sdk.NewInt(1000)}, + TokenInDenom: []string{"ust"}, + PoolID: []uint64{1}, + }, + expectedError: nil, + }, + { + name: "exact out request pool id and token in denom mismatch", + request: &types.GetDirectCustomQuoteRequest{ + TokenOut: &sdk.Coin{Denom: "usdc", Amount: sdk.NewInt(1000)}, + TokenInDenom: []string{"usdc", "usdt", "uusd"}, + PoolID: []uint64{1}, + }, + expectedError: types.ErrNumOfTokenInDenomPoolsMismatch, + }, + { + name: "invalid request: contains both tokenIn and tokenOut", + request: &types.GetDirectCustomQuoteRequest{ + TokenIn: &sdk.Coin{Denom: "ust", Amount: sdk.NewInt(1000)}, + TokenOut: &sdk.Coin{Denom: "usdc", Amount: sdk.NewInt(1000)}, + TokenInDenom: []string{"ust"}, + TokenOutDenom: []string{"usdc"}, + }, + expectedError: types.ErrSwapMethodNotValid, + }, + { + name: "invalid exact in request with invalid denoms", + request: &types.GetDirectCustomQuoteRequest{ + TokenIn: &sdk.Coin{Denom: "usdc", Amount: sdk.NewInt(1000)}, + TokenOutDenom: []string{"usdc"}, + PoolID: []uint64{1}, + }, + expectedError: domain.SameDenomError{ + DenomA: "usdc", + DenomB: "usdc", + }, + }, + { + name: "invalid exact out request with invalid denoms", + request: &types.GetDirectCustomQuoteRequest{ + TokenOut: &sdk.Coin{Denom: "usdt", Amount: sdk.NewInt(1000)}, + TokenInDenom: []string{"usdt"}, + PoolID: []uint64{1}, + }, + expectedError: domain.SameDenomError{ + DenomA: "usdt", + DenomB: "usdt", + }, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + err := tc.request.Validate() + if tc.expectedError != nil { + assert.Error(t, err) + assert.Equal(t, tc.expectedError, err) + return + } + + assert.NoError(t, err) + }) + } +} diff --git a/router/types/request.go b/router/types/get_quote_request.go similarity index 100% rename from router/types/request.go rename to router/types/get_quote_request.go diff --git a/router/types/request_test.go b/router/types/get_quote_request_test.go similarity index 98% rename from router/types/request_test.go rename to router/types/get_quote_request_test.go index fa9ebcf7c..d18b144c9 100644 --- a/router/types/request_test.go +++ b/router/types/get_quote_request_test.go @@ -29,13 +29,17 @@ func TestGetQuoteRequestUnmarshal(t *testing.T) { name: "valid request with tokenIn and tokenOut", queryParams: map[string]string{ "tokenIn": "1000ust", + "tokenOutDenom": "usdc", "tokenOut": "1000usdc", + "tokenInDenom": "atom", "singleRoute": "true", "applyExponents": "true", }, expectedResult: &types.GetQuoteRequest{ TokenIn: &sdk.Coin{Denom: "ust", Amount: sdk.NewInt(1000)}, + TokenOutDenom: "usdc", TokenOut: &sdk.Coin{Denom: "usdc", Amount: sdk.NewInt(1000)}, + TokenInDenom: "atom", SingleRoute: true, ApplyExponents: true, }, diff --git a/router/usecase/router_usecase.go b/router/usecase/router_usecase.go index 0c16a7bb9..1ffa16f59 100644 --- a/router/usecase/router_usecase.go +++ b/router/usecase/router_usecase.go @@ -19,6 +19,7 @@ import ( "github.com/osmosis-labs/sqs/domain/cache" "github.com/osmosis-labs/sqs/domain/mvc" "github.com/osmosis-labs/sqs/log" + "github.com/osmosis-labs/sqs/router/types" "github.com/osmosis-labs/sqs/router/usecase/route" "github.com/osmosis-labs/sqs/router/usecase/routertesting/parsing" "github.com/osmosis-labs/sqs/sqsdomain" @@ -484,21 +485,19 @@ func (r *routerUseCaseImpl) GetCustomDirectQuote(ctx context.Context, tokenIn sd return bestSingleRouteQuote, nil } -var ErrValidationFailed = fmt.Errorf("validation failed") - // GetCustomDirectQuoteMultiPool implements mvc.RouterUsecase. func (r *routerUseCaseImpl) GetCustomDirectQuoteMultiPool(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom []string, poolIDs []uint64) (domain.Quote, error) { if len(poolIDs) == 0 { - return nil, fmt.Errorf("%w: at least one pool ID should be specified", ErrValidationFailed) + return nil, fmt.Errorf("%w: at least one pool ID should be specified", types.ErrValidationFailed) } if len(tokenOutDenom) == 0 { - return nil, fmt.Errorf("%w: at least one token out denom should be specified", ErrValidationFailed) + return nil, fmt.Errorf("%w: at least one token out denom should be specified", types.ErrValidationFailed) } // for each given pool we expect to have provided token out denom if len(poolIDs) != len(tokenOutDenom) { - return nil, fmt.Errorf("%w: number of pool ID should match number of out denom", ErrValidationFailed) + return nil, fmt.Errorf("%w: number of pool ID should match number of out denom", types.ErrValidationFailed) } // AmountIn is the first token of the asset pair. @@ -547,6 +546,23 @@ func (r *routerUseCaseImpl) GetCustomDirectQuoteMultiPool(ctx context.Context, t return &result, nil } +// GetCustomDirectQuoteMultiPool implements mvc.RouterUsecase. +func (r *routerUseCaseImpl) GetCustomDirectQuoteMultiPoolInGivenOut(ctx context.Context, tokenOut sdk.Coin, tokenInDenom []string, poolIDs []uint64) (domain.Quote, error) { + quote, err := r.GetCustomDirectQuoteMultiPool(ctx, tokenOut, tokenInDenom, poolIDs) + if err != nil { + return nil, err + } + + q, ok := quote.(*quoteExactAmountIn) + if !ok { + return nil, errors.New("quote is not a quoteExactAmountIn") + } + + return "eExactAmountOut{ + quoteExactAmountIn: q, + }, nil +} + // GetCandidateRoutes implements domain.RouterUsecase. func (r *routerUseCaseImpl) GetCandidateRoutes(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom string) (sqsdomain.CandidateRoutes, error) { candidateRouteSearchOptions := domain.CandidateRouteSearchOptions{ diff --git a/router/usecase/router_usecase_test.go b/router/usecase/router_usecase_test.go index 27318c7df..fb8db0539 100644 --- a/router/usecase/router_usecase_test.go +++ b/router/usecase/router_usecase_test.go @@ -17,6 +17,7 @@ import ( "github.com/osmosis-labs/sqs/log" poolsusecase "github.com/osmosis-labs/sqs/pools/usecase" routerrepo "github.com/osmosis-labs/sqs/router/repository" + "github.com/osmosis-labs/sqs/router/types" "github.com/osmosis-labs/sqs/router/usecase" "github.com/osmosis-labs/sqs/router/usecase/route" "github.com/osmosis-labs/sqs/router/usecase/routertesting" @@ -1065,21 +1066,21 @@ func (s *RouterTestSuite) TestGetCustomQuote_GetCustomDirectQuotes_Mainnet_UOSMO poolID: []uint64{ 1, // OSMO - ATOM }, - err: usecase.ErrValidationFailed, + err: types.ErrValidationFailed, }, { name: "Fail: empty poolID", tokenIn: sdk.NewCoin(UOSMO, amountIn), tokenOutDenom: []string{ATOM}, poolID: []uint64{}, - err: usecase.ErrValidationFailed, + err: types.ErrValidationFailed, }, { name: "Fail: mismatch poolID and tokenOutDenom", tokenIn: sdk.NewCoin(UOSMO, amountIn), tokenOutDenom: []string{ATOM}, poolID: []uint64{1, 2}, - err: usecase.ErrValidationFailed, + err: types.ErrValidationFailed, }, { name: "Single pool: OSMO-ATOM - happy case", @@ -1154,6 +1155,152 @@ func (s *RouterTestSuite) TestGetCustomQuote_GetCustomDirectQuotes_Mainnet_UOSMO } } +// This test runs tests against GetCustomDirectQuotes to ensure that the method correctly calculates +// quote across multi pool route. +func (s *RouterTestSuite) TestGetCustomQuote_GetCustomDirectQuotesInGivenOut_Mainnet_UOSMOUSDC() { + config := routertesting.DefaultRouterConfig + config.MaxPoolsPerRoute = 5 + config.MaxRoutes = 10 + + var ( + amountOut = osmomath.NewInt(5000000) + ) + + mainnetState := s.SetupMainnetState() + + // Setup router repository mock + routerRepositoryMock := routerrepo.New(&log.NoOpLogger{}) + routerRepositoryMock.SetTakerFees(mainnetState.TakerFeeMap) + + // Setup pools usecase mock. + poolsUsecase, err := poolsusecase.NewPoolsUsecase(&domain.PoolsConfig{}, "node-uri-placeholder", routerRepositoryMock, domain.UnsetScalingFactorGetterCb, &log.NoOpLogger{}) + s.Require().NoError(err) + poolsUsecase.StorePools(mainnetState.Pools) + + tokenMetaDataHolder := mocks.TokenMetadataHolderMock{} + candidateRouteFinderMock := mocks.CandidateRouteFinderMock{} + + routerUsecase := usecase.NewRouterUsecase(routerRepositoryMock, poolsUsecase, candidateRouteFinderMock, &tokenMetaDataHolder, config, emptyCosmWasmPoolsRouterConfig, &log.NoOpLogger{}, cache.New(), cache.New()) + + // Test cases + testCases := []struct { + // test name + name string + + // token being swapped + tokenOut sdk.Coin + + // token to be received + tokenInDenom []string + + // pools route path for swap + poolID []uint64 + + // usually it's the number of pools given, + // unless any of those pools does not have given asset pair. + expectedNumOfRoutes int + + // for single-hop it matches poolID slice + expectedPoolID []uint64 + + err error + }{ + { + name: "Fail: empty tokenOutDenom", + tokenOut: sdk.NewCoin(UOSMO, amountOut), + tokenInDenom: []string{}, + poolID: []uint64{ + 1, // OSMO - ATOM + }, + err: types.ErrValidationFailed, + }, + { + name: "Fail: empty poolID", + tokenOut: sdk.NewCoin(UOSMO, amountOut), + tokenInDenom: []string{ATOM}, + poolID: []uint64{}, + err: types.ErrValidationFailed, + }, + { + name: "Fail: mismatch poolID and tokenOutDenom", + tokenOut: sdk.NewCoin(UOSMO, amountOut), + tokenInDenom: []string{ATOM}, + poolID: []uint64{1, 2}, + err: types.ErrValidationFailed, + }, + { + name: "Single pool: OSMO-ATOM - happy case", + tokenOut: sdk.NewCoin(UOSMO, amountOut), + tokenInDenom: []string{ATOM}, + poolID: []uint64{ + 1, // OSMO - ATOM + }, + expectedNumOfRoutes: 1, + expectedPoolID: []uint64{1}, + }, + { + name: "Single pool: OSMO-ATOM - fail case: out denom not found", + tokenOut: sdk.NewCoin(UOSMO, amountOut), + tokenInDenom: []string{ATOM}, + poolID: []uint64{ + 1093, // OSMO - AKT + }, + err: usecase.ErrTokenOutDenomPoolNotFound, + }, + { + name: "Single pool: ATOM-OSMO - fail case: in denom not found", + tokenOut: sdk.NewCoin(ATOM, amountOut), + tokenInDenom: []string{UOSMO}, + poolID: []uint64{ + 1480, // AKT - USDC + }, + err: usecase.ErrTokenInDenomPoolNotFound, + }, + { + name: "Multi pool: OSMO-USDC - happy case", + tokenOut: sdk.NewCoin(UOSMO, amountOut), + tokenInDenom: []string{AKT, USDC}, + poolID: []uint64{ + 1093, // OSMO - AKT + 1301, // AKT - USDC + }, + expectedNumOfRoutes: 1, + expectedPoolID: []uint64{1093, 1301}, + }, + { + name: "Multi pool: OSMO-USDC - fail case", + tokenOut: sdk.NewCoin(UOSMO, amountOut), + tokenInDenom: []string{ATOM, USDT}, + poolID: []uint64{ + 1, // OSMO - ATOM + 1301, // AKT - USDC + }, + expectedNumOfRoutes: 2, + expectedPoolID: []uint64{1093, 1301}, + err: usecase.ErrTokenInDenomPoolNotFound, + }, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + quotes, err := routerUsecase.GetCustomDirectQuoteMultiPoolInGivenOut(context.Background(), tc.tokenOut, tc.tokenInDenom, tc.poolID) + s.Require().ErrorIs(err, tc.err) + if err != nil { + return // nothing else to do + } + + // token in must match + s.Require().Equal(tc.expectedNumOfRoutes, len(quotes.GetRoute())) + + // Custom direct quote should have only one route + routes := quotes.GetRoute() + s.Require().Len(routes, 1) + + s.validateExpectedPoolIDsMultiHopRoute(routes[0].GetPools(), tc.expectedPoolID) + }) + } +} + func (s *RouterTestSuite) TestGetCustomQuote_GetCustomDirectQuotes_Mainnet_Orderbook() { config := routertesting.DefaultRouterConfig config.MaxPoolsPerRoute = 5 diff --git a/tests/quote.py b/tests/quote.py index 7457f636e..a4bfee507 100644 --- a/tests/quote.py +++ b/tests/quote.py @@ -1,3 +1,5 @@ +import time +from typing import Callable, Any import conftest from sqs_service import * from quote_response import * @@ -9,6 +11,34 @@ from route import * class Quote: + @staticmethod + def run_quote_test(service_call: Callable[[], Any], expected_latency_upper_bound_ms, expected_status_code=200) -> QuoteExactAmountOutResponse: + """ + Runs exact amount out test for the /router/quote endpoint with the given input parameters. + + Does basic validation around response status code and latency. + + Returns quote for additional validation if needed by client. + + Validates: + - Response status code is as given or default 200. + - Latency is under the given bound. + """ + + start_time = time.time() + response = service_call() + elapsed_time_ms = (time.time() - start_time) * 1000 + + assert response.status_code == expected_status_code, f"Error: {response.text}" + assert expected_latency_upper_bound_ms > elapsed_time_ms, f"Error: latency {elapsed_time_ms} exceeded {expected_latency_upper_bound_ms} ms, denom in and token out" + + response_json = response.json() + + print(response.text) + + # Return the response for further processing + return response.json() + @staticmethod def choose_error_tolerance(amount: int): # This is the max error tolerance of 7% that we allow. @@ -70,6 +100,126 @@ def validate_pool_denoms_in_route(token_in_denom, token_out_denom, denoms, pool_ assert token_in_denom, f"Error: token in {token_in_denom} not found in pool denoms {denoms}, pool ID {pool_id}, route in {route_denom_in}, route out {route_denom_out}" class ExactAmountInQuote: + @staticmethod + def calculate_expected_base_out_quote_spot_price(denom_out, coin): + """ + Compute expected base out quote spot price + + First, get the USD price of each denom, and then divide to get the expected spot price + """ + + # Compute expected base out quote spot price + # First, get the USD price of each denom, and then divide to get the expected spot price + in_base_usd_quote_price = conftest.get_usd_price_scaled(denom_out) + out_base_usd_quote_price = conftest.get_usd_price_scaled(coin.denom) + expected_in_base_out_quote_price = out_base_usd_quote_price / in_base_usd_quote_price + + # Compute expected token out + expected_token_in = int(coin.amount) * expected_in_base_out_quote_price + + token_in_amount_usdc_value = in_base_usd_quote_price * coin.amount + + return expected_in_base_out_quote_price, expected_token_in, token_in_amount_usdc_value + + def run_quote_test(environment_url, token_in, token_out, human_denoms, single_route, expected_latency_upper_bound_ms, expected_status_code=200) -> QuoteExactAmountInResponse: + """ + Runs a test for the /router/quote endpoint with the given input parameters. + + Does basic validation around response status code and latency + + Returns quote for additional validation if needed by client + + Validates: + - Response status code is as given or default 200 + - Latency is under the given bound + """ + + service_call = lambda: conftest.SERVICE_MAP[environment_url].get_exact_amount_in_quote(token_in, token_out, human_denoms, single_route) + + response = Quote.run_quote_test(service_call, expected_latency_upper_bound_ms, expected_status_code) + + # Return route for more detailed validation + return QuoteExactAmountInResponse(**response) + + @staticmethod + def validate_quote_test(quote, expected_amount_in_str, expected_denom_in, spot_price_scaling_factor, expected_in_base_out_quote_price, expected_token_out, denom_out, error_tolerance, direct_quote=False): + """ + Runs the following validations: + - Basic presence of fields + - Transmuter has no price impact. Otherwise, it is negative. + - Token out amount is within error tolerance from expected. + - Returned spot price is within error tolerance from expected. + """ + + # Validate routes are generally present + assert len(quote.route) > 0 + + # Check if the route is a single pool single transmuter route + # For such routes, the price impact is 0. + is_transmuter_route = Quote.is_transmuter_in_single_route(quote.route) + + # Validate price impact + # If it is a single pool single transmuter route, we expect the price impact to be 0 + # Price impact is returned as a negative number for any other route. + assert quote.price_impact is not None + assert (not is_transmuter_route) and (quote.price_impact < 0) or (is_transmuter_route) and (quote.price_impact == 0), f"Error: price impact {quote.price_impact} is zero for non-transmuter route" + price_impact_positive = quote.price_impact * -1 + + # Validate amount in and denom are as input + assert quote.amount_in.amount == int(expected_amount_in_str) + assert quote.amount_in.denom == expected_denom_in + + # Validate that the fee is charged + ExactAmountInQuote.validate_fee(quote) + + # Validate that the route is valid + ExactAmountInQuote.validate_route(quote, expected_denom_in, denom_out, direct_quote) + + # Validate that the spot price is present + assert quote.in_base_out_quote_spot_price is not None + + # Validate that the spot price is within the error tolerance + assert relative_error(quote.in_base_out_quote_spot_price * spot_price_scaling_factor, expected_in_base_out_quote_price) < error_tolerance, f"Error: in base out quote spot price {quote.in_base_out_quote_spot_price} is not within {error_tolerance} of expected {expected_in_base_out_quote_price}" + + # If there is a price impact greater than the provided error tolerance, we dynamically set the error tolerance to be + # the price impact * (1 + error_tolerance) to account for the price impact + if price_impact_positive > error_tolerance: + error_tolerance = price_impact_positive * Decimal(1 + error_tolerance) + + # Validate that the amount out is within the error tolerance + amount_out_scaled = quote.amount_out * spot_price_scaling_factor + assert relative_error(amount_out_scaled, expected_token_out) < error_tolerance, f"Error: amount out scaled {amount_out_scaled} is not within {error_tolerance} of expected {expected_token_out}" + + @staticmethod + def validate_route(quote, denom_in, denom_out, direct_quote=False): + """ + Validates that the route is valid by checking the following: + - The input token is present in each pool denoms + - The last token out is equal to denom out + """ + for route in quote.route: + cur_token_in_denom = denom_in + for p in route.pools: + pool_id = p.id + pool = conftest.shared_test_state.pool_by_id_map.get(str(pool_id)) + + assert pool, f"Error: pool ID {pool_id} not found in test data" + + denoms = conftest.get_denoms_from_pool_tokens(pool.get("pool_tokens")) + + # Validate route denoms are present in pool + Quote.validate_pool_denoms_in_route(cur_token_in_denom, p.token_out_denom, denoms, pool_id, denom_in, denom_out) + + cur_token_in_denom = p.token_out_denom + + if not direct_quote: + # Last route token out must be equal to denom out + assert denom_out == get_last_route_token_out(route), f"Error: denom out {denom_out} not equal to last token out {get_last_route_token_out(route)}" + + if direct_quote: + # For direct custom quotes response always is multi route + assert denom_out == get_last_quote_route_token_out(quote), f"Error: denom out {denom_out} not equal to last token out {get_last_quote_route_token_out(quote)}" + @staticmethod def validate_fee(quote): """ @@ -97,51 +247,47 @@ def validate_fee(quote): class ExactAmountOutQuote: @staticmethod - def calculate_amount_transmuter(token_out: Coin, denom_in): - # This is the max error tolerance of 5% that we allow. - # Arbitrarily hand-picked to avoid flakiness. - error_tolerance = 0.05 + def calculate_expected_base_out_quote_spot_price(denom_in, coin): + """ + Compute expected base out quote spot price - # Get denom in precision. - denom_out_precision = conftest.get_denom_exponent(token_out.denom) + First, get the USD price of each denom, and then divide to get the expected spot price + """ - # Get denom out data to retrieve precision and price - denom_in_data = conftest.shared_test_state.chain_denom_to_data_map.get(denom_in) - denom_in_precision = denom_in_data.get("exponent") + in_base_usd_quote_price = conftest.get_usd_price_scaled(denom_in) + out_base_usd_quote_price = conftest.get_usd_price_scaled(coin.denom) + expected_in_base_out_quote_price = out_base_usd_quote_price / in_base_usd_quote_price - # Compute spot price scaling factor. - spot_price_scaling_factor = Decimal(10)**denom_out_precision / Decimal(10)**denom_in_precision + # Compute expected token out + expected_token_in = int(coin.amount) * expected_in_base_out_quote_price - # Compute expected spot prices - out_base_in_quote_price = Decimal(denom_in_data.get("price")) - expected_in_base_out_quote_price = 1 / out_base_in_quote_price + token_out_amount_usdc_value = in_base_usd_quote_price * coin.amount - # Compute expected token in - expected_token_in = int(token_out.amount) * expected_in_base_out_quote_price + return expected_in_base_out_quote_price, expected_token_in, token_out_amount_usdc_value - return spot_price_scaling_factor, expected_token_in, error_tolerance + @staticmethod + def run_quote_test(environment_url, token_out, token_in, human_denoms, single_route, expected_latency_upper_bound_ms, expected_status_code=200) -> QuoteExactAmountOutResponse: + """ + Runs exact amount out test for the /router/quote endpoint with the given input parameters. - def calculate_amount(tokenOut: Coin, denom_in): - # All tokens have the same default exponent, resulting in scaling factor of 1. - spot_price_scaling_factor = 1 + Does basic validation around response status code and latency - token_out_denom = tokenOut.denom - amount_str = tokenOut.amount - amount_out = int(amount_str) + Returns quote for additional validation if needed by client - # Compute expected base out quote spot price - # First, get the USD price of each denom, and then divide to get the expected spot price - in_base_usd_quote_price = conftest.get_usd_price_scaled(denom_in) - out_base_usd_quote_price = conftest.get_usd_price_scaled(token_out_denom) - expected_in_base_out_quote_price = out_base_usd_quote_price / in_base_usd_quote_price + Validates: + - Response status code is as given or default 200 + - Latency is under the given bound + """ - # Compute expected token out - expected_token_in = int(amount_str) * expected_in_base_out_quote_price + service_call = lambda: conftest.SERVICE_MAP[environment_url].get_exact_amount_out_quote(token_out, token_in, human_denoms, single_route) + + response = Quote.run_quote_test(service_call, expected_latency_upper_bound_ms, expected_status_code) - return in_base_usd_quote_price * amount_out + # Return route for more detailed validation + return QuoteExactAmountOutResponse(**response) @staticmethod - def validate_quote_test(quote, expected_amount_out_str, expected_denom_out, spot_price_scaling_factor, expected_in_base_out_quote_price, expected_token_in, denom_in, error_tolerance): + def validate_quote_test(quote, expected_amount_out_str, expected_denom_out, spot_price_scaling_factor, expected_in_base_out_quote_price, expected_token_in, denom_in, error_tolerance, direct_quote=False): """ Runs the following validations: - Basic presence of fields @@ -172,7 +318,7 @@ def validate_quote_test(quote, expected_amount_out_str, expected_denom_out, spot ExactAmountOutQuote.validate_fee(quote) # Validate that the route is valid - ExactAmountOutQuote.validate_route(quote, denom_in, expected_denom_out,) + ExactAmountOutQuote.validate_route(quote, denom_in, expected_denom_out, direct_quote) # Validate that the spot price is present assert quote.in_base_out_quote_spot_price is not None @@ -187,10 +333,10 @@ def validate_quote_test(quote, expected_amount_out_str, expected_denom_out, spot # Validate that the amount out is within the error tolerance amount_in_scaled = quote.amount_in * spot_price_scaling_factor - assert relative_error(amount_in_scaled, expected_token_in) < error_tolerance, f"Error: amount out scaled {amount_in_scaled} is not within {error_tolerance} of expected {expected_token_out}" + assert relative_error(amount_in_scaled, expected_token_in) < error_tolerance, f"Error: amount out scaled {amount_in_scaled} is not within {error_tolerance} of expected {expected_token_in}" @staticmethod - def validate_route(quote, denom_in, denom_out): + def validate_route(quote, denom_in, denom_out, direct_quote=False): """ Validates that the route is valid by checking the following: - The output token is present in each pool denoms @@ -210,8 +356,13 @@ def validate_route(quote, denom_in, denom_out): cur_out_denom = p.token_in_denom - # Last route token in must be equal to denom in - assert denom_in == get_last_route_token_in(route), f"Error: denom in {denom_in} not equal to last token in {get_last_route_token_in(route)}" + if not direct_quote: + # Last route token in must be equal to denom in + assert denom_in == get_last_route_token_in(route), f"Error: denom in {denom_in} not equal to last token in {get_last_route_token_in(route)}" + + if direct_quote: + # For direct custom quotes response always is multi route + assert denom_in == get_last_quote_route_token_in(quote), f"Error: denom in {denom_in} not equal to last token in {get_last_quote_route_token_in(quote)}" @staticmethod def validate_fee(quote): diff --git a/tests/quote_response.py b/tests/quote_response.py index 3d2a85947..d086549f9 100644 --- a/tests/quote_response.py +++ b/tests/quote_response.py @@ -41,6 +41,20 @@ def __init__(self, amount_in, amount_out, route, effective_fee, price_impact, in self.price_impact = Decimal(price_impact) self.in_base_out_quote_spot_price = Decimal(in_base_out_quote_spot_price) + def get_pool_ids(self): + pool_ids = [] + for route in self.route: + for pool in route.pools: + pool_ids.append(pool.id) + return pool_ids + + def get_token_out_denoms(self): + token_out_denoms = [] + for route in self.route: + for pool in route.pools: + token_out_denoms.append(pool.token_out_denom) + return token_out_denoms + # QuoteExactAmountOutResponse represents the response format # of the /router/quote endpoint for Exact Amount Out Quote. class QuoteExactAmountOutResponse: @@ -51,3 +65,17 @@ def __init__(self, amount_in, amount_out, route, effective_fee, price_impact, in self.effective_fee = Decimal(effective_fee) self.price_impact = Decimal(price_impact) self.in_base_out_quote_spot_price = Decimal(in_base_out_quote_spot_price) + + def get_pool_ids(self): + pool_ids = [] + for route in self.route: + for pool in route.pools: + pool_ids.append(pool.id) + return pool_ids + + def get_token_in_denoms(self): + token_in_denoms = [] + for route in self.route: + for pool in route.pools: + token_in_denoms.append(pool.token_in_denom) + return token_in_denoms diff --git a/tests/route.py b/tests/route.py index 86a7e6be8..3d71a7527 100644 --- a/tests/route.py +++ b/tests/route.py @@ -9,3 +9,17 @@ def get_last_route_token_in(route): for pool in route.pools: token_in_denom = pool.token_in_denom return token_in_denom + +def get_last_quote_route_token_in(quote): + token_in_denom = "" + for route in quote.route: + for pool in route.pools: + token_in_denom = pool.token_in_denom + return token_in_denom + +def get_last_quote_route_token_out(quote): + token_out_denom = "" + for route in quote.route: + for pool in route.pools: + token_out_denom = pool.token_out_denom + return token_out_denom diff --git a/tests/sqs_service.py b/tests/sqs_service.py index 49935ac93..b40442d51 100644 --- a/tests/sqs_service.py +++ b/tests/sqs_service.py @@ -109,8 +109,6 @@ def get_exact_amount_in_quote(self, denom_in, denom_out, human_denoms="false", s "singleRoute": singleRoute, } - print(params) - # Send the GET request return requests.get(self.url + ROUTER_QUOTE_URL, params=params, headers=self.headers) @@ -134,7 +132,7 @@ def get_exact_amount_out_quote(self, token_out, denom_in, human_denoms="false", # Send the GET request return requests.get(self.url + ROUTER_QUOTE_URL, params=params, headers=self.headers) - def get_custom_direct_quote(self, denom_in, denom_out, pool_id): + def get_exact_amount_in_custom_direct_quote(self, denom_in, denom_out, pool_id): """ Fetches custom direct quote from the specified endpoint and returns it. @@ -148,6 +146,32 @@ def get_custom_direct_quote(self, denom_in, denom_out, pool_id): "tokenOutDenom": denom_out, "poolID": pool_id, } + + print(params) + + return requests.get( + self.url + ROUTER_CUSTOM_DIRECT_QUOTE_URL, + params=params, + headers=self.headers, + ) + + def get_exact_amount_out_custom_direct_quote(self, token_out, denom_in, pool_id): + """ + Fetches custom direct quote from the specified endpoint and returns it. + + Similar to get_quote, instead of path finding, specific pool is enforced. + + Raises error if non-200 is returned from the endpoint. + """ + + params = { + "tokenOut": token_out, + "tokenInDenom": denom_in, + "poolID": pool_id, + } + + print(params) + return requests.get( self.url + ROUTER_CUSTOM_DIRECT_QUOTE_URL, params=params, diff --git a/tests/test_router_direct_custom_quote_in_given_out.py b/tests/test_router_direct_custom_quote_in_given_out.py new file mode 100644 index 000000000..012634fbb --- /dev/null +++ b/tests/test_router_direct_custom_quote_in_given_out.py @@ -0,0 +1,65 @@ +import time +import pytest + +import conftest +from sqs_service import * +from quote import * +from quote_response import * +from rand_util import * +from e2e_math import * +from decimal import * +from constants import * +from util import * +from route import * + +# Arbitrary choice based on performance at the time of test writing +EXPECTED_LATENCY_UPPER_BOUND_MS = 15000 + +# Test suite for the /router/custom-direct-quote endpoint +# Test runs tests for exact amount out quotes. +class TestExactAmountOutDirectCustomQuote: + @pytest.mark.parametrize("pair", conftest.create_coins_from_pairs(conftest.create_no_dupl_token_pairs(conftest.choose_tokens_liq_range(num_tokens=10, min_liq=500_000, exponent_filter=USDC_PRECISION)), USDC_PRECISION, USDC_PRECISION + 3), ids=id_from_swap_pair) + def test_get_custom_direct_quote(self, environment_url, pair): + TestExactAmountOutDirectCustomQuote.run_get_custom_direct_quote(environment_url, pair['token_in']['amount_str'], pair['token_in']['denom'], pair['out_denom']) + + @staticmethod + def run_get_custom_direct_quote(environment_url, amount_str, token_out_denom, denom_in): + coin = Coin(token_out_denom, amount_str) + token_out = amount_str + token_out_denom + + # Get the optimal quote for the given token pair + # Direct custom quote does not support multiple routes, so we request single/multi hop pool routes only + optimal_quote = ExactAmountOutQuote.run_quote_test(environment_url, token_out, denom_in, False, True, EXPECTED_LATENCY_UPPER_BOUND_MS) + + pool_id = ','.join(map(str, optimal_quote.get_pool_ids())) + denoms_in = ','.join(map(str, optimal_quote.get_token_in_denoms())) + + quote = TestExactAmountOutDirectCustomQuote.run_quote_test(environment_url, token_out, denoms_in, pool_id, EXPECTED_LATENCY_UPPER_BOUND_MS) + + # All tokens have the same default exponent, resulting in scaling factor of 1. + spot_price_scaling_factor = 1 + + expected_in_base_out_quote_price, expected_token_in, token_out_amount_usdc_value = ExactAmountOutQuote.calculate_expected_base_out_quote_spot_price(denom_in, coin) + + # Chose the error tolerance based on amount in swapped. + error_tolerance = Quote.choose_error_tolerance(token_out_amount_usdc_value) + + # Validate that price impact is present. + assert quote.price_impact is not None + + # Validate quote results + ExactAmountOutQuote.validate_quote_test(quote, coin.amount, coin.denom, spot_price_scaling_factor, expected_in_base_out_quote_price, expected_token_in, denom_in, error_tolerance, True) + + @staticmethod + def run_quote_test(environment_url, token_out, denom_in, pool_id, expected_latency_upper_bound_ms, expected_status_code=200) -> QuoteExactAmountOutResponse: + """ + Runs exact amount out test for the /router/custom-direct-quote endpoint with the given input parameters. + """ + + service_call = lambda: conftest.SERVICE_MAP[environment_url].get_exact_amount_out_custom_direct_quote(token_out, denom_in, pool_id) + + response = Quote.run_quote_test(service_call, expected_latency_upper_bound_ms, expected_status_code) + + # Return route for more detailed validation + return QuoteExactAmountOutResponse(**response) + diff --git a/tests/test_router_direct_custom_quote_out_given_in.py b/tests/test_router_direct_custom_quote_out_given_in.py new file mode 100644 index 000000000..3c74a1a3b --- /dev/null +++ b/tests/test_router_direct_custom_quote_out_given_in.py @@ -0,0 +1,64 @@ +import time +import pytest + +import conftest +from sqs_service import * +from quote import * +from quote_response import * +from rand_util import * +from e2e_math import * +from decimal import * +from constants import * +from util import * +from route import * + +# Arbitrary choice based on performance at the time of test writing +EXPECTED_LATENCY_UPPER_BOUND_MS = 15000 + +# Test suite for the /router/custom-direct-quote endpoint +# Test runs tests for exact amount in quotes. +class TestExactAmountInDirectCustomQuote: + @pytest.mark.parametrize("pair", conftest.create_coins_from_pairs(conftest.create_no_dupl_token_pairs(conftest.choose_tokens_liq_range(num_tokens=10, min_liq=500_000, exponent_filter=USDC_PRECISION)), USDC_PRECISION, USDC_PRECISION + 3), ids=id_from_swap_pair) + def test_get_custom_direct_quote(self, environment_url, pair): + TestExactAmountInDirectCustomQuote.run_get_custom_direct_quote(environment_url, pair['token_in']['amount_str'], pair['token_in']['denom'], pair['out_denom']) + + @staticmethod + def run_get_custom_direct_quote(environment_url, amount_str, token_in_denom, denom_out): + coin = Coin(token_in_denom, amount_str) + token_in = amount_str + token_in_denom + + # Get the optimal quote for the given token pair + # Direct custom quote does not support multiple routes, so we request single/multi hop pool routes only + optimal_quote = ExactAmountInQuote.run_quote_test(environment_url, token_in, denom_out, False, True, EXPECTED_LATENCY_UPPER_BOUND_MS) + + pool_id = ','.join(map(str, optimal_quote.get_pool_ids())) + denoms_out = ','.join(map(str, optimal_quote.get_token_out_denoms())) + + quote = TestExactAmountInDirectCustomQuote.run_quote_test(environment_url, token_in, denoms_out, pool_id, EXPECTED_LATENCY_UPPER_BOUND_MS) + + # All tokens have the same default exponent, resulting in scaling factor of 1. + spot_price_scaling_factor = 1 + + expected_in_base_out_quote_price, expected_token_in, token_in_amount_usdc_value = ExactAmountOutQuote.calculate_expected_base_out_quote_spot_price(denom_out, coin) + + # Chose the error tolerance based on amount in swapped. + error_tolerance = Quote.choose_error_tolerance(token_in_amount_usdc_value) + + # Validate that price impact is present. + assert quote.price_impact is not None + + # Validate quote results + ExactAmountInQuote.validate_quote_test(quote, coin.amount, coin.denom, spot_price_scaling_factor, expected_in_base_out_quote_price, expected_token_in, denom_out, error_tolerance, True) + + @staticmethod + def run_quote_test(environment_url, token_in, denom_out, pool_id, expected_latency_upper_bound_ms, expected_status_code=200) -> QuoteExactAmountInResponse: + """ + Runs exact amount in test for the /router/custom-direct-quote endpoint with the given input parameters. + """ + + service_call = lambda: conftest.SERVICE_MAP[environment_url].get_exact_amount_in_custom_direct_quote(token_in, denom_out, pool_id) + + response = Quote.run_quote_test(service_call, expected_latency_upper_bound_ms, expected_status_code) + + # Return route for more detailed validation + return QuoteExactAmountInResponse(**response) diff --git a/tests/test_router_quote_in_given_out.py b/tests/test_router_quote_in_given_out.py index 660e42be7..428bac1dc 100644 --- a/tests/test_router_quote_in_given_out.py +++ b/tests/test_router_quote_in_given_out.py @@ -71,7 +71,8 @@ def test_usdc_in_high_liq_in(self, environment_url, coin_obj): token_out_coin = amount_str + USDC # Run the quote test - quote = self.run_quote_test(environment_url, token_out_coin, denom_in, EXPECTED_LATENCY_UPPER_BOUND_MS) + quote = ExactAmountOutQuote.run_quote_test(environment_url, token_out_coin, denom_in, False, False, EXPECTED_LATENCY_UPPER_BOUND_MS) + ExactAmountOutQuote.validate_quote_test(quote, amount_str, USDC, spot_price_scaling_factor, expected_in_base_out_quote_price, expected_token_out, denom_in, error_tolerance) # - Constructs combinations between each from 10^6 to 10^9 amount input @@ -80,29 +81,25 @@ def test_top_liq_combos_default_exponent(self, environment_url, swap_pair): token_out_obj = swap_pair['token_in'] amount_str = token_out_obj['amount_str'] token_out_denom = token_out_obj['denom'] - token_out_coin = amount_str + token_out_denom denom_in = swap_pair['out_denom'] - amount_out = int(amount_str) - # All tokens have the same default exponent, resulting in scaling factor of 1. - spot_price_scaling_factor = 1 + TestExactAmountOutQuote.run_top_liq_combos_default_exponent(environment_url, amount_str, token_out_denom, denom_in) - # Compute expected base out quote spot price - # First, get the USD price of each denom, and then divide to get the expected spot price - in_base_usd_quote_price = conftest.get_usd_price_scaled(denom_in) - out_base_usd_quote_price = conftest.get_usd_price_scaled(token_out_denom) - expected_in_base_out_quote_price = out_base_usd_quote_price / in_base_usd_quote_price + @staticmethod + def run_top_liq_combos_default_exponent(environment_url, amount_str, token_out_denom, denom_in): + token_out_coin = amount_str + token_out_denom + coin = Coin(token_out_denom, amount_str) - # Compute expected token out - expected_token_in = int(amount_str) * expected_in_base_out_quote_price + # All tokens have the same default exponent, resulting in scaling factor of 1. + spot_price_scaling_factor = 1 - token_out_amount_usdc_value = in_base_usd_quote_price * amount_out + expected_in_base_out_quote_price, expected_token_in, token_out_amount_usdc_value = ExactAmountOutQuote.calculate_expected_base_out_quote_spot_price(denom_in, coin) # Chose the error tolerance based on amount in swapped. error_tolerance = Quote.choose_error_tolerance(token_out_amount_usdc_value) # Run the quote test - quote = self.run_quote_test(environment_url, token_out_coin, denom_in, EXPECTED_LATENCY_UPPER_BOUND_MS) + quote = ExactAmountOutQuote.run_quote_test(environment_url, token_out_coin, denom_in, False, False, EXPECTED_LATENCY_UPPER_BOUND_MS) # Validate that price impact is present. assert quote.price_impact is not None @@ -162,7 +159,7 @@ def test_transmuter_tokens(self, environment_url, amount): expected_token_in = int(amount) * expected_in_base_out_quote_price # Run the quote test - quote = self.run_quote_test(environment_url, amount + denom_out, denom_in, EXPECTED_LATENCY_UPPER_BOUND_MS) + quote = ExactAmountOutQuote.run_quote_test(environment_url, amount + denom_out, denom_in, False, False, EXPECTED_LATENCY_UPPER_BOUND_MS) # Transmuter is expected to be in the route only if the amount out is equal to the amount in # in rare cases, CL pools can be picked up instead of transmuter, providing a higher amount out. @@ -172,30 +169,3 @@ def test_transmuter_tokens(self, environment_url, amount): # Validate the quote test ExactAmountOutQuote.validate_quote_test(quote, amount, denom_out, spot_price_scaling_factor, expected_in_base_out_quote_price, expected_token_in, denom_in, error_tolerance) - - def run_quote_test(self, environment_url, token_out, token_in, expected_latency_upper_bound_ms, expected_status_code=200) -> QuoteExactAmountOutResponse: - """ - Runs exact amount out test for the /router/quote endpoint with the given input parameters. - - Does basic validation around response status code and latency - - Returns quote for additional validation if needed by client - - Validates: - - Response status code is as given or default 200 - - Latency is under the given bound - """ - - sqs_service = conftest.SERVICE_MAP[environment_url] - - start_time = time.time() - response = sqs_service.get_exact_amount_out_quote(token_out, token_in) - elapsed_time_ms = (time.time() - start_time) * 1000 - - assert response.status_code == expected_status_code, f"Error: {response.text}" - assert expected_latency_upper_bound_ms > elapsed_time_ms, f"Error: latency {elapsed_time_ms} exceeded {expected_latency_upper_bound_ms} ms, token in {token_in} and token out {token_out}" - - response_json = response.json() - - # Return route for more detailed validation - return QuoteExactAmountOutResponse(**response_json) \ No newline at end of file diff --git a/tests/test_router_quote_out_given_in.py b/tests/test_router_quote_out_given_in.py index 42bcf6957..1eb2baefa 100644 --- a/tests/test_router_quote_out_given_in.py +++ b/tests/test_router_quote_out_given_in.py @@ -85,8 +85,9 @@ def test_usdc_in_high_liq_out(self, environment_url, coin_obj): token_in_coin = amount_str + USDC # Run the quote test - quote = run_quote_test(environment_url, token_in_coin, denom_out, EXPECTED_LATENCY_UPPER_BOUND_MS) - validate_quote_test(quote, amount_str, USDC, spot_price_scaling_factor, expected_in_base_out_quote_price, expected_token_out, denom_out, error_tolerance) + quote = ExactAmountInQuote.run_quote_test(environment_url, token_in_coin, denom_out, False, False, EXPECTED_LATENCY_UPPER_BOUND_MS) + + ExactAmountInQuote.validate_quote_test(quote, amount_str, USDC, spot_price_scaling_factor, expected_in_base_out_quote_price, expected_token_out, denom_out, error_tolerance) # - Constructs combinations between each from 10^6 to 10^9 amount input @pytest.mark.parametrize("swap_pair", conftest.create_coins_from_pairs(conftest.create_no_dupl_token_pairs(conftest.choose_tokens_liq_range(num_tokens=10, min_liq=500_000, exponent_filter=USDC_PRECISION)), USDC_PRECISION, USDC_PRECISION + 3), ids=id_from_swap_pair) @@ -96,7 +97,33 @@ def test_top_liq_combos_default_exponent(self, environment_url, swap_pair): token_in_denom = token_in_obj['denom'] denom_out = swap_pair['out_denom'] - run_exact_in_quote_test(environment_url, amount_str, token_in_denom, denom_out) + TestExactAmountInQuote.run_top_liq_combos_default_exponent(environment_url, amount_str, token_in_denom, denom_out) + + @staticmethod + def run_top_liq_combos_default_exponent(environment_url, amount_str, token_in_denom, denom_out): + amount_in = int(amount_str) + token_in_coin = amount_str + token_in_denom + coin = Coin(token_in_denom, amount_str) + + # All tokens have the same default exponent, resulting in scaling factor of 1. + spot_price_scaling_factor = 1 + + expected_in_base_out_quote_price, expected_token_out, token_in_amount_usdc_value = ExactAmountOutQuote.calculate_expected_base_out_quote_spot_price(denom_out, coin) + + # Choosse the error tolerance based on amount in swapped. + error_tolerance = Quote.choose_error_tolerance(token_in_amount_usdc_value) + + # Run the quote test + quote = ExactAmountInQuote.run_quote_test(environment_url, token_in_coin, denom_out, False, False, EXPECTED_LATENCY_UPPER_BOUND_MS) + # Validate that price impact is present. + assert quote.price_impact is not None + + # If the token in amount value is less than $HIGH_LIQ_PRICE_IMPACT_CHECK_USD_AMOUNT_IN_THRESHOLD, we expect the price impact to not exceed threshold + if token_in_amount_usdc_value < HIGH_LIQ_PRICE_IMPACT_CHECK_USD_AMOUNT_IN_THRESHOLD: + quote.price_impact * -1 < HIGH_LIQ_MAX_PRICE_IMPACT_THRESHOLD, f"Error: price impact is either None or greater than {HIGH_LIQ_MAX_PRICE_IMPACT_THRESHOLD} {quote.price_impact}" + + # Validate quote results + ExactAmountInQuote.validate_quote_test(quote, amount_str, token_in_denom, spot_price_scaling_factor, expected_in_base_out_quote_price, expected_token_out, denom_out, error_tolerance) def test_custom_direct_quote_single_hop(self, environment_url,): """ @@ -108,7 +135,7 @@ def test_custom_direct_quote_single_hop(self, environment_url,): denom_in = constants.USDC denom_out = constants.UOSMO - response = sqs_service.get_custom_direct_quote(str(amount) + denom_in, denom_out, constants.UOSMO_USDC_POOL_ID) + response = sqs_service.get_exact_amount_in_custom_direct_quote(str(amount) + denom_in, denom_out, constants.UOSMO_USDC_POOL_ID) assert response.status_code == 200, f"Error: {response.text}" res = response.json() @@ -133,7 +160,7 @@ def test_custom_direct_quote_multi_hop(self, environment_url,): wbtc = "factory/osmo1z0qrq605sjgcqpylfl4aa6s90x738j7m58wyatt0tdzflg2ha26q67k743/wbtc" allBtc = "factory/osmo1z6r6qdknhgsc0zeracktgpcxf43j6sekq07nw8sxduc9lg0qjjlqfu25e3/alloyed/allBTC" - response = sqs_service.get_custom_direct_quote(str(amount) + denom_in, f"{wbtc},{allBtc}", "1436,1868") + response = sqs_service.get_exact_amount_in_custom_direct_quote(str(amount) + denom_in, f"{wbtc},{allBtc}", "1436,1868") assert response.status_code == 200, f"Error: {response.text}" res = response.json() @@ -195,7 +222,7 @@ def test_transmuter_tokens(self, environment_url, amount): expected_token_out = int(amount) * expected_in_base_out_quote_price # Run the quote test - quote = run_quote_test(environment_url, amount + denom_in, denom_out, EXPECTED_LATENCY_UPPER_BOUND_MS) + quote = ExactAmountInQuote.run_quote_test(environment_url, amount + denom_in, denom_out, False, False, EXPECTED_LATENCY_UPPER_BOUND_MS) # Transmuter is expected to be in the route only if the amount out is equal to the amount in # in rare cases, CL pools can be picked up instead of transmuter, providing a higher amount out. @@ -204,7 +231,7 @@ def test_transmuter_tokens(self, environment_url, amount): assert Quote.is_transmuter_in_single_route(quote.route) is True # Validate the quote test - validate_quote_test(quote, amount, denom_in, spot_price_scaling_factor, expected_in_base_out_quote_price, expected_token_out, denom_out, error_tolerance) + ExactAmountInQuote.validate_quote_test(quote, amount, denom_in, spot_price_scaling_factor, expected_in_base_out_quote_price, expected_token_out, denom_out, error_tolerance) @pytest.mark.parametrize("amount", [1000]) @pytest.mark.parametrize("token_pair", orderbook_token_pairs()) @@ -223,7 +250,7 @@ def test_orderbook(self, environment_url, amount, token_pair): sqs_service = conftest.SERVICE_MAP[environment_url] start_time = time.time() - response = sqs_service.get_custom_direct_quote(str(amount) + denom_in, denom_out, pool_id) + response = sqs_service.get_exact_amount_in_custom_direct_quote(str(amount) + denom_in, denom_out, pool_id) elapsed_time_ms = (time.time() - start_time) * 1000 assert response.status_code == 200, f"Error: {response.text}" @@ -260,135 +287,3 @@ def test_orderbook(self, environment_url, amount, token_pair): amount_out_diff = relative_error(expected_amount_out, amount_out) assert amount_out_diff < error_tolerance, \ f"Error: difference between calculated and actual amount out is {amount_out_diff} which is greater than {error_tolerance}" - -def run_exact_in_quote_test(environment_url, amount_str, token_in_denom, denom_out): - amount_in = int(amount_str) - token_in_coin = amount_str + token_in_denom - - # All tokens have the same default exponent, resulting in scaling factor of 1. - spot_price_scaling_factor = 1 - - # Compute expected base out quote spot price - # First, get the USD price of each denom, and then divide to get the expected spot price - out_base_usd_quote_price = conftest.get_usd_price_scaled(denom_out) - in_base_usd_quote_price = conftest.get_usd_price_scaled(token_in_denom) - expected_in_base_out_quote_price = in_base_usd_quote_price / out_base_usd_quote_price - - # Compute expected token out - expected_token_out = int(amount_str) * expected_in_base_out_quote_price - - token_in_amount_usdc_value = in_base_usd_quote_price * amount_in - - # Choosse the error tolerance based on amount in swapped. - error_tolerance = Quote.choose_error_tolerance(token_in_amount_usdc_value) - - # Run the quote test - quote = run_quote_test(environment_url, token_in_coin, denom_out, EXPECTED_LATENCY_UPPER_BOUND_MS) - # Validate that price impact is present. - assert quote.price_impact is not None - - # If the token in amount value is less than $HIGH_LIQ_PRICE_IMPACT_CHECK_USD_AMOUNT_IN_THRESHOLD, we expect the price impact to not exceed threshold - if token_in_amount_usdc_value < HIGH_LIQ_PRICE_IMPACT_CHECK_USD_AMOUNT_IN_THRESHOLD: - quote.price_impact * -1 < HIGH_LIQ_MAX_PRICE_IMPACT_THRESHOLD, f"Error: price impact is either None or greater than {HIGH_LIQ_MAX_PRICE_IMPACT_THRESHOLD} {quote.price_impact}" - - # Validate quote results - validate_quote_test(quote, amount_str, token_in_denom, spot_price_scaling_factor, expected_in_base_out_quote_price, expected_token_out, denom_out, error_tolerance) - -def run_quote_test(environment_url, token_in, token_out, expected_latency_upper_bound_ms, expected_status_code=200) -> QuoteExactAmountInResponse: - """ - Runs a test for the /router/quote endpoint with the given input parameters. - - Does basic validation around response status code and latency - - Returns quote for additional validation if needed by client - - Validates: - - Response status code is as given or default 200 - - Latency is under the given bound - """ - - sqs_service = conftest.SERVICE_MAP[environment_url] - - start_time = time.time() - response = sqs_service.get_exact_amount_in_quote(token_in, token_out) - elapsed_time_ms = (time.time() - start_time) * 1000 - - assert response.status_code == expected_status_code, f"Error: {response.text}" - assert expected_latency_upper_bound_ms > elapsed_time_ms, f"Error: latency {elapsed_time_ms} exceeded {expected_latency_upper_bound_ms} ms, token in {token_in} and token out {token_out}" - - response_json = response.json() - - # Return route for more detailed validation - return QuoteExactAmountInResponse(**response_json) - -def validate_quote_test(quote, expected_amount_in_str, expected_denom_in, spot_price_scaling_factor, expected_in_base_out_quote_price, expected_token_out, denom_out, error_tolerance): - """ - Runs the following validations: - - Basic presence of fields - - Transmuter has no price impact. Otherwise, it is negative. - - Token out amount is within error tolerance from expected. - - Returned spot price is within error tolerance from expected. - """ - - # Validate routes are generally present - assert len(quote.route) > 0 - - # Check if the route is a single pool single transmuter route - # For such routes, the price impact is 0. - is_transmuter_route = Quote.is_transmuter_in_single_route(quote.route) - - # Validate price impact - # If it is a single pool single transmuter route, we expect the price impact to be 0 - # Price impact is returned as a negative number for any other route. - assert quote.price_impact is not None - assert (not is_transmuter_route) and (quote.price_impact < 0) or (is_transmuter_route) and (quote.price_impact == 0), f"Error: price impact {quote.price_impact} is zero for non-transmuter route" - price_impact_positive = quote.price_impact * -1 - - # Validate amount in and denom are as input - assert quote.amount_in.amount == int(expected_amount_in_str) - assert quote.amount_in.denom == expected_denom_in - - # Validate that the fee is charged - ExactAmountInQuote.validate_fee(quote) - - # Validate that the route is valid - validate_route(quote, expected_denom_in, denom_out) - - # Validate that the spot price is present - assert quote.in_base_out_quote_spot_price is not None - - # Validate that the spot price is within the error tolerance - assert relative_error(quote.in_base_out_quote_spot_price * spot_price_scaling_factor, expected_in_base_out_quote_price) < error_tolerance, f"Error: in base out quote spot price {quote.in_base_out_quote_spot_price} is not within {error_tolerance} of expected {expected_in_base_out_quote_price}" - - # If there is a price impact greater than the provided error tolerance, we dynamically set the error tolerance to be - # the price impact * (1 + error_tolerance) to account for the price impact - if price_impact_positive > error_tolerance: - error_tolerance = price_impact_positive * Decimal(1 + error_tolerance) - - # Validate that the amount out is within the error tolerance - amount_out_scaled = quote.amount_out * spot_price_scaling_factor - assert relative_error(amount_out_scaled, expected_token_out) < error_tolerance, f"Error: amount out scaled {amount_out_scaled} is not within {error_tolerance} of expected {expected_token_out}" - -def validate_route(quote, denom_in, denom_out): - """ - Validates that the route is valid by checking the following: - - The input token is present in each pool denoms - - The last token out is equal to denom out - """ - for route in quote.route: - cur_token_in_denom = denom_in - for p in route.pools: - pool_id = p.id - pool = conftest.shared_test_state.pool_by_id_map.get(str(pool_id)) - - assert pool, f"Error: pool ID {pool_id} not found in test data" - - denoms = conftest.get_denoms_from_pool_tokens(pool.get("pool_tokens")) - - # Validate route denoms are present in pool - Quote.validate_pool_denoms_in_route(cur_token_in_denom, p.token_out_denom, denoms, pool_id, denom_in, denom_out) - - cur_token_in_denom = p.token_out_denom - - # Last route token out must be equal to denom out - assert denom_out == get_last_route_token_out(route), f"Error: denom out {denom_out} not equal to last token out {get_last_route_token_out(route)}" diff --git a/tests/test_synthetic_geo.py b/tests/test_synthetic_geo.py index b8559f737..a4040645a 100644 --- a/tests/test_synthetic_geo.py +++ b/tests/test_synthetic_geo.py @@ -10,7 +10,10 @@ from test_pools import run_pool_liquidity_cap_test, run_canonical_orderbook_test, run_pool_filters_test from test_passthrough import run_test_portfolio_assets from test_candidate_routes import run_candidate_routes_test -from test_router_quote_out_given_in import run_exact_in_quote_test +from test_router_quote_out_given_in import TestExactAmountInQuote +from test_router_quote_in_given_out import TestExactAmountOutQuote +from test_router_direct_custom_quote_out_given_in import TestExactAmountInDirectCustomQuote +from test_router_direct_custom_quote_in_given_out import TestExactAmountOutDirectCustomQuote expected_latency_upper_bound_ms = 2000 @@ -61,4 +64,37 @@ def test_synth_router_quote_exact_in(self, environment_url): token_pairs = list(itertools.combinations(tokens_to_pair, 2)) for token_pair in token_pairs: - run_exact_in_quote_test(environment_url, default_amount_in, token_pair[0], token_pair[1]) + TestExactAmountInQuote.run_top_liq_combos_default_exponent(environment_url, default_amount_in, token_pair[0], token_pair[1]) + + # /router/quote exact out + def test_synth_router_quote_exact_out(self, environment_url): + tokens_to_pair = [constants.USDC, constants.UOSMO] + # TODO: make selection smarter + default_amount_in = "1000000" + + token_pairs = list(itertools.combinations(tokens_to_pair, 2)) + + for token_pair in token_pairs: + TestExactAmountOutQuote.run_top_liq_combos_default_exponent(environment_url, default_amount_in, token_pair[0], token_pair[1]) + + # /router/custom-direct-quote exact in + def test_synth_router_direct_custom_quote_in(self, environment_url): + tokens_to_pair = [constants.USDC, constants.UOSMO] + # TODO: make selection smarter + default_amount_in = "1000000" + + token_pairs = list(itertools.combinations(tokens_to_pair, 2)) + + for token_pair in token_pairs: + TestExactAmountInDirectCustomQuote.run_get_custom_direct_quote(environment_url, default_amount_in, token_pair[0], token_pair[1]) + + # /router/custom-direct-quote exact out + def test_synth_router_direct_custom_quote_out(self, environment_url): + tokens_to_pair = [constants.USDC, constants.UOSMO] + # TODO: make selection smarter + default_amount_in = "1000000" + + token_pairs = list(itertools.combinations(tokens_to_pair, 2)) + + for token_pair in token_pairs: + TestExactAmountOutDirectCustomQuote.run_get_custom_direct_quote(environment_url, default_amount_in, token_pair[0], token_pair[1])