Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Reimplement & test GetCandidateRoute algorithm #379

Merged
7 changes: 5 additions & 2 deletions app/sidecar_query_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,11 @@ func NewSideCarQueryServer(appCodec codec.Codec, config domain.Config, logger lo
// Initialize pools repository, usecase and HTTP handler
poolsUseCase := poolsUseCase.NewPoolsUsecase(config.Pools, config.ChainGRPCGatewayEndpoint, routerRepository, tokensUseCase.GetChainScalingFactorByDenomMut)

// Initialize candidate route searcher
candidateRouteSearcher := routerUseCase.NewCandidateRouteFinder(routerRepository, logger)

// Initialize router repository, usecase
routerUsecase := routerUseCase.NewRouterUsecase(routerRepository, poolsUseCase, tokensUseCase, *config.Router, poolsUseCase.GetCosmWasmPoolConfig(), logger, cache.New(), cache.New())
routerUsecase := routerUseCase.NewRouterUsecase(routerRepository, poolsUseCase, candidateRouteSearcher, tokensUseCase, *config.Router, poolsUseCase.GetCosmWasmPoolConfig(), logger, cache.New(), cache.New())

// Initialize system handler
chainInfoRepository := chaininforepo.New()
Expand All @@ -125,7 +128,7 @@ func NewSideCarQueryServer(appCodec codec.Codec, config domain.Config, logger lo
cosmWasmPoolConfig := poolsUseCase.GetCosmWasmPoolConfig()

// Initialize chain pricing strategy
pricingSimpleRouterUsecase := routerUseCase.NewRouterUsecase(routerRepository, poolsUseCase, tokensUseCase, *config.Router, cosmWasmPoolConfig, logger, cache.New(), cache.New())
pricingSimpleRouterUsecase := routerUseCase.NewRouterUsecase(routerRepository, poolsUseCase, candidateRouteSearcher, tokensUseCase, *config.Router, cosmWasmPoolConfig, logger, cache.New(), cache.New())
chainPricingSource, err := pricing.NewPricingStrategy(*config.Pricing, tokensUseCase, pricingSimpleRouterUsecase)
if err != nil {
return nil, err
Expand Down
24 changes: 24 additions & 0 deletions domain/candidate_routes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package domain

import (
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/osmosis-labs/sqs/sqsdomain"
)

// CandidateRouteSearchOptions represents the options for finding candidate routes.
type CandidateRouteSearchOptions struct {
// MaxRoutes is the maximum number of routes to find.
MaxRoutes int
// MaxPoolsPerRoute is the maximum number of pools to consider for each route.
MaxPoolsPerRoute int
// MinPoolLiquidityCap is the minimum liquidity cap for a pool to be considered.
MinPoolLiquidityCap uint64
}

// CandidateRouteSearcher is the interface for finding candidate routes.
type CandidateRouteSearcher interface {
// FindCandidateRoutes finds candidate routes for a given tokenIn and tokenOutDenom
// using the given options.
// Returns the candidate routes and an error if any.
FindCandidateRoutes(tokenIn sdk.Coin, tokenOutDenom string, options CandidateRouteSearchOptions) (sqsdomain.CandidateRoutes, error)
}
9 changes: 9 additions & 0 deletions domain/mocks/candidate_route_data_holder_mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,12 @@ func (c *CandidateRouteSearchDataHolderMock) GetCandidateRouteSearchData() map[s
func (c *CandidateRouteSearchDataHolderMock) SetCandidateRouteSearchData(candidateRouteSearchData map[string][]sqsdomain.PoolI) {
c.CandidateRouteSearchData = candidateRouteSearchData
}

// GetRankedPoolsByDenom implements mvc.CandidateRouteSearchDataHolder.
func (c *CandidateRouteSearchDataHolderMock) GetRankedPoolsByDenom(denom string) ([]sqsdomain.PoolI, error) {
pools, ok := c.CandidateRouteSearchData[denom]
if !ok {
return []sqsdomain.PoolI{}, nil
}
return pools, nil
}
19 changes: 19 additions & 0 deletions domain/mocks/candidate_route_finder_mock.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package mocks

import (
"github.com/cosmos/cosmos-sdk/types"
"github.com/osmosis-labs/sqs/domain"
"github.com/osmosis-labs/sqs/sqsdomain"
)

type CandidateRouteFinderMock struct {
Routes sqsdomain.CandidateRoutes
Error error
}

var _ domain.CandidateRouteSearcher = CandidateRouteFinderMock{}

// FindCandidateRoutes implements domain.CandidateRouteSearcher.
func (c CandidateRouteFinderMock) FindCandidateRoutes(tokenIn types.Coin, tokenOutDenom string, options domain.CandidateRouteSearchOptions) (sqsdomain.CandidateRoutes, error) {
return c.Routes, c.Error
}
5 changes: 5 additions & 0 deletions domain/mvc/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ type CandidateRouteSearchDataHolder interface {

// GetCandidateRouteSearchData gets the candidate route search data from the holder
GetCandidateRouteSearchData() map[string][]sqsdomain.PoolI

// GetRankedPoolsByDenom returns the ranked candidate route search pools for a given denom.
// Returns an empty slice if the denom is not found.
// Returns error if retrieved pools are not of type sqsdomain.PoolI.
GetRankedPoolsByDenom(denom string) ([]sqsdomain.PoolI, error)
}

// RouterRepository represents the contract for a repository handling tokens information
Expand Down
16 changes: 16 additions & 0 deletions router/repository/memory_router_repository.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package routerrepo

import (
"fmt"
"sync"

"cosmossdk.io/math"
Expand Down Expand Up @@ -135,6 +136,21 @@ func (r *routerRepo) GetCandidateRouteSearchData() map[string][]sqsdomain.PoolI
return candidateRouteSearchData
}

// GetRankedPoolsByDenom implements mvc.CandidateRouteSearchDataHolder.
func (r *routerRepo) GetRankedPoolsByDenom(denom string) ([]sqsdomain.PoolI, error) {
poolsData, ok := r.candidateRouteSearchData.Load(denom)
if !ok {
return []sqsdomain.PoolI{}, nil
}

pools, ok := poolsData.([]sqsdomain.PoolI)
if !ok {
return nil, fmt.Errorf("error casting value to []sqsdomain.PoolI in GetByDenom")
}

return pools, nil
}

// SetCandidateRouteSearchData implements mvc.RouterUsecase.
func (r *routerRepo) SetCandidateRouteSearchData(candidateRouteSearchData map[string][]sqsdomain.PoolI) {
for denom, pools := range candidateRouteSearchData {
Expand Down
52 changes: 52 additions & 0 deletions router/repository/memory_router_repository_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (

"github.com/alecthomas/assert/v2"
"github.com/osmosis-labs/osmosis/osmomath"
"github.com/osmosis-labs/sqs/domain/mocks"
"github.com/osmosis-labs/sqs/log"
routerrepo "github.com/osmosis-labs/sqs/router/repository"
"github.com/osmosis-labs/sqs/sqsdomain"
Expand Down Expand Up @@ -171,3 +172,54 @@ func (suite *RouteRepositoryChatGPTTestSuite) TestSetTakerFees() {
})
}
}

// Sanity checks validating the implementation of the GetRankedPoolsByDenom method
func (suite *RouteRepositoryChatGPTTestSuite) TestGetRankedPoolsByDenom_HappyPath() {
const (
defaultPoolID = 1

denomA = "denomA"
denomB = "denomB"

denomNoPools = "denomNoPools"
)

var (
denomOnePools = []sqsdomain.PoolI{
&sqsdomain.PoolWrapper{
ChainModel: &mocks.ChainPoolMock{
ID: defaultPoolID,
},
}}

denomTwoPools = []sqsdomain.PoolI{
&sqsdomain.PoolWrapper{
ChainModel: &mocks.ChainPoolMock{
ID: defaultPoolID + 1,
},
}}
)

candidateRouteSearchData := map[string][]sqsdomain.PoolI{
denomA: denomOnePools,
denomB: denomTwoPools,
}

// System under test.
suite.repository.SetCandidateRouteSearchData(candidateRouteSearchData)

// Denom a has the expected pools.
actualDenomOnePools, err := suite.repository.GetRankedPoolsByDenom(denomA)
suite.Require().NoError(err)
suite.Require().Equal(denomOnePools, actualDenomOnePools)

// Denom b has the expected pools.
actualDenomTwoPools, err := suite.repository.GetRankedPoolsByDenom(denomB)
suite.Require().NoError(err)
suite.Require().Equal(denomTwoPools, actualDenomTwoPools)

// Denom with no pools returns an empty slice.
actualNoDenomPools, err := suite.repository.GetRankedPoolsByDenom(denomNoPools)
suite.Require().NoError(err)
suite.Require().Empty(actualNoDenomPools)
}
52 changes: 36 additions & 16 deletions router/usecase/candidate_routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"fmt"

sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/osmosis-labs/sqs/domain"
"github.com/osmosis-labs/sqs/domain/mvc"
"github.com/osmosis-labs/sqs/log"
"github.com/osmosis-labs/sqs/sqsdomain"
"go.uber.org/zap"
Expand Down Expand Up @@ -144,11 +146,23 @@ func GetCandidateRoutes(pools []sqsdomain.PoolI, tokenIn sdk.Coin, tokenOutDenom
return validateAndFilterRoutes(routes, tokenIn.Denom, logger)
}

// GetCandidateRoutesNew new algorithm for demo purposes.
// Note: implementation is for demo purposes and is to be further optimized.
// TODO: spec, unit tests via https://linear.app/osmosis/issue/DATA-250/[candidaterouteopt]-reimplement-and-test-getcandidateroute-algorithm
func GetCandidateRoutesNew(poolsByDenom map[string][]sqsdomain.PoolI, tokenIn sdk.Coin, tokenOutDenom string, maxRoutes, maxPoolsPerRoute int, minPoolLiquidityCap uint64, logger log.Logger) (sqsdomain.CandidateRoutes, error) {
routes := make([][]candidatePoolWrapper, 0, maxRoutes)
type candidateRouteFinder struct {
candidateRouteDataHolder mvc.CandidateRouteSearchDataHolder
logger log.Logger
}

var _ domain.CandidateRouteSearcher = candidateRouteFinder{}

func NewCandidateRouteFinder(candidateRouteDataHolder mvc.CandidateRouteSearchDataHolder, logger log.Logger) candidateRouteFinder {
return candidateRouteFinder{
candidateRouteDataHolder: candidateRouteDataHolder,
logger: logger,
}
}

// FindCandidateRoutes implements domain.CandidateRouteFinder.
func (c candidateRouteFinder) FindCandidateRoutes(tokenIn sdk.Coin, tokenOutDenom string, options domain.CandidateRouteSearchOptions) (sqsdomain.CandidateRoutes, error) {
routes := make([][]candidatePoolWrapper, 0, options.MaxRoutes)

// Preallocate constant visited map size to avoid reallocations.
// TODO: choose the best size for the visited map.
Expand All @@ -158,9 +172,9 @@ func GetCandidateRoutesNew(poolsByDenom map[string][]sqsdomain.PoolI, tokenIn sd
// Preallocate constant queue size to avoid dynamic reallocations.
// TODO: choose the best size for the queue.
queue := make([][]candidatePoolWrapper, 0, 100)
queue = append(queue, make([]candidatePoolWrapper, 0, maxPoolsPerRoute))
queue = append(queue, make([]candidatePoolWrapper, 0, options.MaxPoolsPerRoute))

for len(queue) > 0 && len(routes) < maxRoutes {
for len(queue) > 0 && len(routes) < options.MaxRoutes {
currentRoute := queue[0]
queue[0] = nil // Clear the slice to avoid holding onto references
queue = queue[1:]
Expand All @@ -173,12 +187,15 @@ func GetCandidateRoutesNew(poolsByDenom map[string][]sqsdomain.PoolI, tokenIn sd
currenTokenInDenom = lastPool.TokenOutDenom
}

rankedPools, ok := poolsByDenom[currenTokenInDenom]
if !ok {
rankedPools, err := c.candidateRouteDataHolder.GetRankedPoolsByDenom(currenTokenInDenom)
if err != nil {
return sqsdomain.CandidateRoutes{}, err
}
if len(rankedPools) == 0 {
return sqsdomain.CandidateRoutes{}, fmt.Errorf("no pools found for denom %s", currenTokenInDenom)
}

for i := 0; i < len(rankedPools) && len(routes) < maxRoutes; i++ {
for i := 0; i < len(rankedPools) && len(routes) < options.MaxRoutes; i++ {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it may be just a personal preference, but now options.MaxRoute reads much better than maxRoutes, and even better, we have documented everything in the single place!

// Unsafe cast for performance reasons.
// nolint: forcetypeassert
pool := (rankedPools[i]).(*sqsdomain.PoolWrapper)
Expand All @@ -188,7 +205,7 @@ func GetCandidateRoutesNew(poolsByDenom map[string][]sqsdomain.PoolI, tokenIn sd
continue
}

if pool.GetLiquidityCap().Uint64() < minPoolLiquidityCap {
if pool.GetLiquidityCap().Uint64() < options.MinPoolLiquidityCap {
visited[poolID] = struct{}{}
// Skip pools that have less liquidity than the minimum required.
continue
Expand Down Expand Up @@ -247,9 +264,12 @@ func GetCandidateRoutesNew(poolsByDenom map[string][]sqsdomain.PoolI, tokenIn sd
continue
}

_, ok := poolsByDenom[denom]
if !ok {
logger.Debug("no pools found for denom in candidate route search", zap.String("denom", denom))
rankedPools, err := c.candidateRouteDataHolder.GetRankedPoolsByDenom(currenTokenInDenom)
if err != nil {
return sqsdomain.CandidateRoutes{}, err
}
if len(rankedPools) == 0 {
c.logger.Debug("no pools found for denom in candidate route search", zap.String("denom", denom))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just a random idea, would it be any benefit feed this data to our observability service?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for suggestion!. Specifically for this one - I don't think so. The reason is that this branch may be taken in the expected cases. It also happens quite frequently. Adding some counter/alert for this specific case would result in too much noise.

However, it might be helpful for debugging, thus, I added a log at the Debug level.

continue
}

Expand All @@ -267,7 +287,7 @@ func GetCandidateRoutesNew(poolsByDenom map[string][]sqsdomain.PoolI, tokenIn sd
Idx: i,
})

if len(newPath) <= maxPoolsPerRoute {
if len(newPath) <= options.MaxPoolsPerRoute {
if hasTokenOut {
routes = append(routes, newPath)
break
Expand All @@ -284,7 +304,7 @@ func GetCandidateRoutesNew(poolsByDenom map[string][]sqsdomain.PoolI, tokenIn sd
}
}

return validateAndFilterRoutes(routes, tokenIn.Denom, logger)
return validateAndFilterRoutes(routes, tokenIn.Denom, c.logger)
}

// Pool represents a pool in the decentralized exchange.
Expand Down
47 changes: 47 additions & 0 deletions router/usecase/candidate_routes_bench_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package usecase_test

import (
"testing"

sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/osmosis-labs/osmosis/osmomath"
"github.com/osmosis-labs/sqs/domain"
"github.com/osmosis-labs/sqs/router/usecase/routertesting"
)

// Microbenchmark for the GetSplitQuote function.
func BenchmarkCandidateRouteSearcher(b *testing.B) {
// This is a hack to be able to use test suite helpers with the benchmark.
// We need to set testing.T for assertings within the helpers. Otherwise, it would block
s := RouterTestSuite{}
s.SetT(&testing.T{})

mainnetState := s.SetupMainnetState()

usecase := s.SetupRouterAndPoolsUsecase(mainnetState, routertesting.WithLoggerDisabled())

var (
amountIn = osmomath.NewInt(1_000_000)
tokenIn = sdk.NewCoin(UOSMO, amountIn)
tokenOutDenom = ATOM
)

routerConfig := usecase.Router.GetConfig()
candidateRouteOptions := domain.CandidateRouteSearchOptions{
MaxRoutes: routerConfig.MaxRoutes,
MaxPoolsPerRoute: routerConfig.MaxPoolsPerRoute,
MinPoolLiquidityCap: 1,
}

b.ResetTimer()

// Run the benchmark
for i := 0; i < b.N; i++ {
// System under test
_, err := usecase.CandidateRouteSearcher.FindCandidateRoutes(tokenIn, tokenOutDenom, candidateRouteOptions)
s.Require().NoError(err)
if err != nil {
b.Errorf("FindCandidateRoutes returned an error: %v", err)
}
}
}
Loading
Loading