Understanding the 'Smart Order Routing' (SOR)

Understanding the 'Smart Order Routing' (SOR)

Brief about the ‘SOR’

Smart Order Routing is an off-chain linear optimization that routes(finds the best swaps and chains it to get the best price) incoming orders across pools for best price execution. The logic for this implementation is written in typescript, in about ~1000 lines of code which you can find in this repo.

The Smart Router helps in curating a pathway or a series of swaps to be made in order to get the best price for the customer.

Prerequisites:

The suitable audience for this blog is Beginner — Intermediate Smart Contract Developers / Enthusiasts. I will definitely go slow and explain each topic as in-depth as possible.

Swapping:

swapExactIn: this swap lets you enter exactly 1 Eth as an input and get a calculated balance in return.

swapExactOut: this swap lets you enter the exact balance you want to receive and calculates the ETH you need to input.

Deep Dive:

This contains the ‘getBestPaths’ function getBestPaths takes in a few params to determine the most optimal paths and swap amounts for a token swap, considering liquidity higher limits and gas fees. Refer below

export const getBestPaths = (
    paths: NewPath[],
    swapType: SwapTypes,
    totalSwapAmount: BigNumber,
    inputDecimals: number,
    outputDecimals: number,
    maxPools: number,
    costReturnToken: BigNumber

Initial If check ensures input validation(makes sure the input is non-zero or null) and sufficient liquidity. if the paths available are 0 or the liquidity is absent or the swap amount given is 0 the function will not proceed further. To check for Liquidity we make a function call to ‘getHighestLimitAmountsForPaths’. This function is explained later. refer below

    // No paths available or totalSwapAmount == 0, return empty solution
    if (paths.length == 0 || totalSwapAmount.isZero()) {
        return [[], ZERO, ZERO, ZERO];
    }
 // Before we start the main loop, we first check if there is enough liquidity for this totalSwapAmount
    const highestLimitAmounts = getHighestLimitAmountsForPaths(paths, maxPools);
    const sumLimitAmounts = highestLimitAmounts.reduce(
        (r: BigNumber[], pathLimit: BigNumber) => {
            r.push(pathLimit.add(r[r.length - 1] || Zero));
            return r;
        },
        []
    );

IF checks for the highest limit across all liquidation pools combined is less than the ‘totalSwapAmount’. refer below

   // If the cumulative limit across all paths is lower than totalSwapAmount then no solution is possible
    if (totalSwapAmount.gt(sumLimitAmounts[sumLimitAmounts.length - 1])) {
        return [[], ZERO, ZERO, ZERO]; // Not enough liquidity, return empty
    }

Here the actual planning begins, the liquidity pool with the highest liquidity is selected for the initial swap, and the total totalSwapAmount might be higher than the ‘highestLimitAmounts’ of the selected pool then in such cases, the rest of the amount which was remaining after the initial swap will be swapped in the next pool.

 // We use the highest limits to define the initial number of pools considered and the initial guess for swapAmounts.
    const initialNumPaths =
        sumLimitAmounts.findIndex((cumulativeLimit) =>
            // If below is true, it means we have enough liquidity
            totalSwapAmount.lte(cumulativeLimit)
        ) + 1;

Visualisation of the liquidation pools and their order used by the ‘SOR’

Example:

Let's take the amount to be swapped ie. ’totalSwapAmount’ to be 3.61 eth, here the SOR will first take the liquidity pool with the highest limit and swap it out there (a high limit usually means deep liquidity, which ensures minimal slippage, to avoid getting high slippage we order the pools in descending order of highest limits and swap our amount from top to bottom), here we take the highest limit to be 2 for example, here 2 amount gets swapped and we have 1.61 left, for the next pool the limit is 1.5 lets say, here 0.11 gets left, now to swap out the exact amount remaining we employ the following logic. (Understand that before we swapped the highestAmount possible, but now the amount we need to swap is lesser than the highest limit, hence the following is executed>)

//  Since the sum of the first i highest limits will be less than totalSwapAmount, we remove the difference to the last swapAmount
    //  so we are sure that the sum of swapAmounts will be equal to totalSwapAmount
    const difference =
        sumLimitAmounts[initialNumPaths - 1].sub(totalSwapAmount);
    initialSwapAmounts[initialSwapAmounts.length - 1] =
        initialSwapAmounts[initialSwapAmounts.length - 1].sub(difference);

Then the received data is fed into

‘const [bestPaths, bestSwapAmounts, bestTotalReturnConsideringFees]’ and this is then formatted( which we will discuss in a different article.), then a final check of whether the bestTotalReturn is valid is done and

‘ [swaps, bestTotalReturn, marketSp, bestTotalReturnConsideringFees]; ’

Is returned

 const [bestPaths, bestSwapAmounts, bestTotalReturnConsideringFees] =
        optimizeSwapAmounts(
            paths,
            swapType,
            totalSwapAmount,
            initialSwapAmounts,
            highestLimitAmounts,
            inputDecimals,
            outputDecimals,
            initialNumPaths,
            maxPools,
            costReturnToken
        );

    const [swaps, bestTotalReturn, marketSp] = formatSwaps(
        bestPaths,
        swapType,
        bnum(formatFixed(totalSwapAmount, inputDecimals)),
        bestSwapAmounts
    );

    if (bestTotalReturn.eq(0)) return [[], ZERO, ZERO, ZERO];

    return [swaps, bestTotalReturn, marketSp, bestTotalReturnConsideringFees];
};

References:

Balancer-sor-v1

Balancer

End-Note:

If you kept reading till the end, thank you for your patience, I hope you got a valuable takeaway from this article.

I would love to connect with you. These are my socials :)