Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 1 addition & 34 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,37 +37,4 @@ jobs:
name: code-coverage-report
path: |
coverage.out
report.json

sonarCloudTrigger:
needs: build
name: SonarCloud Trigger
runs-on: ubuntu-latest
steps:
- name: Clone Repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download code coverage results
uses: actions/download-artifact@v4
with:
name: code-coverage-report
# path: app
- name: Analyze with SonarCloud
uses: sonarsource/sonarqube-scan-action@v7
with:
# projectBaseDir: app
args: >
-Dsonar.projectKey=osmosis-labs-sqs
-Dsonar.organization=osmosis-labs-polaris
-Dsonar.host.url=https://sonarcloud.io
-Dsonar.go.coverage.reportPaths=coverage.out
-Dsonar.go.tests.reportPaths=report.json
-Dsonar.sources=.
-Dsonar.tests=.
-Dsonar.test.inclusions=**/*_test.go,**/testdata/**
-Dsonar.language=go
-Dsonar.go.exclusions=**/vendor/**,**/*_mock.go
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
report.json
10 changes: 9 additions & 1 deletion router/usecase/dynamic_splits.go
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,15 @@ func getComputeAndCacheOutAmountCb(ctx context.Context, totalInAmountDec osmomat
return curRouteAmt
}
// This is the expensive computation that we aim to avoid.
curRouteOutAmountIncrement, _ := routes[routeIndex].CalculateTokenOutByTokenIn(ctx, sdk.NewCoin(tokenInDenom, inAmountIncrement))
curRouteOutAmountIncrement, err := routes[routeIndex].CalculateTokenOutByTokenIn(ctx, sdk.NewCoin(tokenInDenom, inAmountIncrement))

// If the route errors (e.g., insufficient liquidity in orderbook pools),
// treat this route as producing zero output for this increment.
// This ensures routes with liquidity issues are not selected by the DP algorithm.
if err != nil {
routeOutAmtCache[routeIndex][increment] = zero
return zero
}

if curRouteOutAmountIncrement.IsNil() || curRouteOutAmountIncrement.IsZero() {
curRouteOutAmountIncrement.Amount = zero
Expand Down
60 changes: 60 additions & 0 deletions router/usecase/dynamic_splits_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package usecase_test

import (
"context"
"errors"

sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/osmosis-labs/osmosis/osmomath"
"github.com/osmosis-labs/sqs/domain"
"github.com/osmosis-labs/sqs/domain/mocks"
"github.com/osmosis-labs/sqs/router/usecase"
"github.com/osmosis-labs/sqs/router/usecase/route"
"github.com/osmosis-labs/sqs/router/usecase/routertesting"
Expand All @@ -27,6 +29,64 @@ func (s *RouterTestSuite) TestGetSplitQuote() {
s.Require().NoError(err)
}

// TestGetSplitQuote_RouteErrorHandling tests that routes returning errors
// (e.g., orderbook pools with insufficient liquidity) are properly excluded
// from the split quote optimization, rather than silently failing.
func (s *RouterTestSuite) TestGetSplitQuote_RouteErrorHandling() {
const (
tokenInDenom = "uusdc"
tokenOutDenom = "ubtc"
)

// Create a mock pool that always succeeds with good output
goodPool := &mocks.MockRoutablePool{
ID: 1,
TokenOutDenom: tokenOutDenom,
TakerFee: osmomath.ZeroDec(),
SpreadFactor: osmomath.ZeroDec(),
CalculateTokenOutByTokenInFunc: func(ctx context.Context, tokenIn sdk.Coin) (sdk.Coin, error) {
// Returns 1:1 ratio for simplicity
return sdk.NewCoin(tokenOutDenom, tokenIn.Amount), nil
},
}

// Create a mock pool that returns an error (simulating orderbook with insufficient liquidity)
errorPool := &mocks.MockRoutablePool{
ID: 2,
TokenOutDenom: tokenOutDenom,
TakerFee: osmomath.ZeroDec(),
SpreadFactor: osmomath.ZeroDec(),
CalculateTokenOutByTokenInFunc: func(ctx context.Context, tokenIn sdk.Coin) (sdk.Coin, error) {
return sdk.Coin{}, errors.New("orderbook: not enough liquidity to complete swap")
},
}

// Create routes: one good route and one that errors
goodRoute := route.RouteImpl{
Pools: []domain.RoutablePool{goodPool},
}
errorRoute := route.RouteImpl{
Pools: []domain.RoutablePool{errorPool},
}

routes := []route.RouteImpl{goodRoute, errorRoute}

// Test with an amount
tokenIn := sdk.NewCoin(tokenInDenom, osmomath.NewInt(1_000_000))

// Get split quote - should succeed using only the good route
splitQuote, err := usecase.GetSplitQuote(context.TODO(), routes, tokenIn)

// Should not error - the erroring route should be excluded, not cause failure
s.Require().NoError(err)
s.Require().NotNil(splitQuote)

// The output should be reasonable (from the good route only)
// Since the error route returns zero, all traffic should go through the good route
s.Require().True(splitQuote.GetAmountOut().Amount.GT(osmomath.ZeroInt()),
"Expected positive output from good route, got zero")
}

// setupSplitsMainnetTestCase sets up the test case for GetSplitQuote using mainnet state.
// Calls all the relevant functions as if we were estimating the quote up until starting the
// splits computation.
Expand Down
48 changes: 35 additions & 13 deletions router/usecase/optimized_routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,22 +27,44 @@ func (r *routerUseCaseImpl) estimateAndRankSingleRouteQuoteOutGivenIn(ctx contex
// Compute token out for each route
routesWithAmountOut, errors := routes.CalculateTokenOutByTokenIn(ctx, tokenIn)

// If we skipped all routes due to errors, return the first error
// If we skipped all routes due to errors, try a smaller probe amount as fallback.
// This allows the split algorithm to handle routes with limited capacity
// (e.g., orderbook pools that can only handle a portion of the swap).
if len(routesWithAmountOut) == 0 && len(errors) > 0 {
// If we encounter this problem, we attempte to invalidate all caches to recompute the routes
// completely.
// This might be helpful in alloyed cases where the pool gets imbalanced and runs out of liquidity.
// If the original routes were computed only through the zero liquidity token, they will be recomputed
// through another token due to changed order.

// Note: the zero length check occurred at the start of function.
tokenOutDenom := routes[0].GetTokenOutDenom()
// Try with 10% of the original amount as a probe to identify viable routes
probeAmount := tokenIn.Amount.QuoRaw(10)
if probeAmount.IsZero() {
probeAmount = osmomath.OneInt()
}
probeCoin := sdk.NewCoin(tokenIn.Denom, probeAmount)

r.candidateRouteCache.Delete(formatCandidateRouteCacheKey(domain.TokenSwapMethodExactIn, tokenIn.Denom, tokenOutDenom))
tokenInOrderOfMagnitude := GetPrecomputeOrderOfMagnitude(tokenIn.Amount)
r.rankedRouteCache.Delete(formatRankedRouteCacheKey(domain.TokenSwapMethodExactIn, tokenIn.Denom, tokenOutDenom, tokenInOrderOfMagnitude))
// Retry route calculation with smaller probe amount
routesWithAmountOut, _ = routes.CalculateTokenOutByTokenIn(ctx, probeCoin)

return nil, nil, errors[0]
if len(routesWithAmountOut) > 0 {
// Routes work at smaller amounts - they have limited capacity.
// Update InAmount to original requested amount for proper handling downstream.
// The split algorithm will determine actual allocations based on capacity.
for i := range routesWithAmountOut {
routesWithAmountOut[i].InAmount = tokenIn.Amount
}
// Continue to sorting below - don't return error
} else {
// Even probe amount failed - truly no viable routes exist.
// Invalidate caches to force recomputation on next request.
// This might be helpful in alloyed cases where the pool gets imbalanced and runs out of liquidity.
// If the original routes were computed only through the zero liquidity token, they will be recomputed
// through another token due to changed order.

// Note: the zero length check occurred at the start of function.
tokenOutDenom := routes[0].GetTokenOutDenom()

r.candidateRouteCache.Delete(formatCandidateRouteCacheKey(domain.TokenSwapMethodExactIn, tokenIn.Denom, tokenOutDenom))
tokenInOrderOfMagnitude := GetPrecomputeOrderOfMagnitude(tokenIn.Amount)
r.rankedRouteCache.Delete(formatRankedRouteCacheKey(domain.TokenSwapMethodExactIn, tokenIn.Denom, tokenOutDenom, tokenInOrderOfMagnitude))

return nil, nil, errors[0]
}
}

// Sort by amount out in descending order
Expand Down