diff --git a/packages/sdk-router/src/module/query.test.ts b/packages/sdk-router/src/module/query.test.ts index 8ba4a757ea..811986a44e 100644 --- a/packages/sdk-router/src/module/query.test.ts +++ b/packages/sdk-router/src/module/query.test.ts @@ -7,6 +7,9 @@ import { reduceToQuery, narrowToRouterQuery, narrowToCCTPRouterQuery, + modifyDeadline, + applySlippage, + applySlippageInBips, } from './query' describe('#query', () => { @@ -72,4 +75,309 @@ describe('#query', () => { ) }) }) + + describe('modifyDeadline', () => { + describe('RouterQuery', () => { + it('modifies the deadline', () => { + const query = modifyDeadline(routerQuery, BigNumber.from(42)) + expect(query).toEqual({ + swapAdapter: '1', + tokenOut: '2', + minAmountOut: BigNumber.from(3), + deadline: BigNumber.from(42), + rawParams: '5', + }) + }) + + it('does not modify the original query', () => { + modifyDeadline(routerQuery, BigNumber.from(42)) + expect(routerQuery).toEqual({ + swapAdapter: '1', + tokenOut: '2', + minAmountOut: BigNumber.from(3), + deadline: BigNumber.from(4), + rawParams: '5', + }) + }) + }) + + describe('CCTPRouterQuery', () => { + it('modifies the deadline', () => { + const query = modifyDeadline(cctpRouterQuery, BigNumber.from(42)) + expect(query).toEqual({ + routerAdapter: '6', + tokenOut: '7', + minAmountOut: BigNumber.from(8), + deadline: BigNumber.from(42), + rawParams: '10', + }) + }) + + it('does not modify the original query', () => { + modifyDeadline(cctpRouterQuery, BigNumber.from(42)) + expect(cctpRouterQuery).toEqual({ + routerAdapter: '6', + tokenOut: '7', + minAmountOut: BigNumber.from(8), + deadline: BigNumber.from(9), + rawParams: '10', + }) + }) + }) + }) + + describe('applySlippage', () => { + describe('RouterQuery', () => { + // 1M in 18 decimals + const query: RouterQuery = { + swapAdapter: '1', + tokenOut: '2', + minAmountOut: BigNumber.from(10).pow(18).mul(1_000_000), + deadline: BigNumber.from(4), + rawParams: '5', + } + + it('applies 0% slippage', () => { + const newQuery = applySlippage(query, 0, 10000) + expect(newQuery).toEqual({ + swapAdapter: '1', + tokenOut: '2', + minAmountOut: BigNumber.from(10).pow(18).mul(1_000_000), + deadline: BigNumber.from(4), + rawParams: '5', + }) + }) + + it('applies 0.5% slippage', () => { + // 50 bips + const newQuery = applySlippage(query, 50, 10000) + expect(newQuery).toEqual({ + swapAdapter: '1', + tokenOut: '2', + minAmountOut: BigNumber.from(10).pow(18).mul(995_000), + deadline: BigNumber.from(4), + rawParams: '5', + }) + }) + + it('applies 10% slippage', () => { + const newQuery = applySlippage(query, 10, 100) + expect(newQuery).toEqual({ + swapAdapter: '1', + tokenOut: '2', + minAmountOut: BigNumber.from(10).pow(18).mul(900_000), + deadline: BigNumber.from(4), + rawParams: '5', + }) + }) + + it('applies 100% slippage', () => { + const newQuery = applySlippage(query, 1, 1) + expect(newQuery).toEqual({ + swapAdapter: '1', + tokenOut: '2', + minAmountOut: BigNumber.from(0), + deadline: BigNumber.from(4), + rawParams: '5', + }) + }) + + it('rounds down', () => { + const queryPlusOne = { + ...query, + minAmountOut: query.minAmountOut.add(1), + } + const newQuery = applySlippage(queryPlusOne, 50, 10000) + expect(newQuery).toEqual({ + swapAdapter: '1', + tokenOut: '2', + minAmountOut: BigNumber.from(10).pow(18).mul(995_000).add(1), + deadline: BigNumber.from(4), + rawParams: '5', + }) + }) + + it('does not modify the original query', () => { + applySlippage(query, 50, 10000) + expect(query).toEqual({ + swapAdapter: '1', + tokenOut: '2', + minAmountOut: BigNumber.from(10).pow(18).mul(1_000_000), + deadline: BigNumber.from(4), + rawParams: '5', + }) + }) + }) + + describe('CCTPRouterQuery', () => { + // 1M in 6 decimals + const query: CCTPRouterQuery = { + routerAdapter: '1', + tokenOut: '2', + minAmountOut: BigNumber.from(10).pow(6).mul(1_000_000), + deadline: BigNumber.from(4), + rawParams: '5', + } + + it('applies 0% slippage', () => { + const newQuery = applySlippage(query, 0, 10000) + expect(newQuery).toEqual({ + routerAdapter: '1', + tokenOut: '2', + minAmountOut: BigNumber.from(10).pow(6).mul(1_000_000), + deadline: BigNumber.from(4), + rawParams: '5', + }) + }) + + it('applies 0.5% slippage', () => { + // 50 bips + const newQuery = applySlippage(query, 50, 10000) + expect(newQuery).toEqual({ + routerAdapter: '1', + tokenOut: '2', + minAmountOut: BigNumber.from(10).pow(6).mul(995_000), + deadline: BigNumber.from(4), + rawParams: '5', + }) + }) + + it('applies 10% slippage', () => { + const newQuery = applySlippage(query, 10, 100) + expect(newQuery).toEqual({ + routerAdapter: '1', + tokenOut: '2', + minAmountOut: BigNumber.from(10).pow(6).mul(900_000), + deadline: BigNumber.from(4), + rawParams: '5', + }) + }) + + it('applies 100% slippage', () => { + const newQuery = applySlippage(query, 1, 1) + expect(newQuery).toEqual({ + routerAdapter: '1', + tokenOut: '2', + minAmountOut: BigNumber.from(0), + deadline: BigNumber.from(4), + rawParams: '5', + }) + }) + + it('rounds down', () => { + const queryPlusOne = { + ...query, + minAmountOut: query.minAmountOut.add(1), + } + const newQuery = applySlippage(queryPlusOne, 50, 10000) + expect(newQuery).toEqual({ + routerAdapter: '1', + tokenOut: '2', + minAmountOut: BigNumber.from(10).pow(6).mul(995_000).add(1), + deadline: BigNumber.from(4), + rawParams: '5', + }) + }) + + it('does not modify the original query', () => { + applySlippage(query, 50, 10000) + expect(query).toEqual({ + routerAdapter: '1', + tokenOut: '2', + minAmountOut: BigNumber.from(10).pow(6).mul(1_000_000), + deadline: BigNumber.from(4), + rawParams: '5', + }) + }) + }) + + describe('errors', () => { + it('throws if slippage denominator is zero', () => { + expect(() => applySlippage(routerQuery, 1, 0)).toThrow( + 'Slippage denominator cannot be zero' + ) + }) + + it('throws if slippage numerator is negative', () => { + expect(() => applySlippage(routerQuery, -1, 1)).toThrow( + 'Slippage numerator cannot be negative' + ) + }) + + it('throws if slippage numerator is greater than denominator', () => { + expect(() => applySlippage(routerQuery, 2, 1)).toThrow( + 'Slippage cannot be greater than 1' + ) + }) + }) + }) + + describe('applySlippageInBips parity', () => { + // 1M in 18 decimals + const query: RouterQuery = { + swapAdapter: '1', + tokenOut: '2', + minAmountOut: BigNumber.from(10).pow(18).mul(1_000_000), + deadline: BigNumber.from(4), + rawParams: '5', + } + + it('applies 0% slippage', () => { + const newQuery = applySlippage(query, 0, 10000) + const newQueryInBips = applySlippageInBips(query, 0) + expect(newQuery).toEqual(newQueryInBips) + }) + + it('applies 0.5% slippage', () => { + // 50 bips + const newQuery = applySlippage(query, 50, 10000) + const newQueryInBips = applySlippageInBips(query, 50) + expect(newQuery).toEqual(newQueryInBips) + }) + + it('applies 10% slippage', () => { + const newQuery = applySlippage(query, 10, 100) + const newQueryInBips = applySlippageInBips(query, 1000) + expect(newQuery).toEqual(newQueryInBips) + }) + + it('applies 100% slippage', () => { + const newQuery = applySlippage(query, 1, 1) + const newQueryInBips = applySlippageInBips(query, 10000) + expect(newQuery).toEqual(newQueryInBips) + }) + + it('rounds down', () => { + const queryPlusOne = { + ...query, + minAmountOut: query.minAmountOut.add(1), + } + const newQuery = applySlippage(queryPlusOne, 50, 10000) + const newQueryInBips = applySlippageInBips(queryPlusOne, 50) + expect(newQuery).toEqual(newQueryInBips) + }) + + it('does not modify the original query', () => { + applySlippageInBips(query, 50) + expect(query).toEqual({ + swapAdapter: '1', + tokenOut: '2', + minAmountOut: BigNumber.from(10).pow(18).mul(1_000_000), + deadline: BigNumber.from(4), + rawParams: '5', + }) + }) + + it('throws if basis points are negative', () => { + expect(() => applySlippageInBips(routerQuery, -1)).toThrow( + 'Slippage numerator cannot be negative' + ) + }) + + it('throws if basis points are greater than 10000', () => { + expect(() => applySlippageInBips(routerQuery, 10001)).toThrow( + 'Slippage cannot be greater than 1' + ) + }) + }) }) diff --git a/packages/sdk-router/src/module/query.ts b/packages/sdk-router/src/module/query.ts index 5719fa168a..02b2476f45 100644 --- a/packages/sdk-router/src/module/query.ts +++ b/packages/sdk-router/src/module/query.ts @@ -101,3 +101,67 @@ export const hasComplexBridgeAction = (destQuery: Query): boolean => { destQuery.tokenOut !== ETH_NATIVE_TOKEN_ADDRESS ) } + +/** + * Modifies the deadline of the query and returns the modified query. + * Note: the original query is preserved unchanged. + * + * @param query - The query to modify. + * @param deadline - The new deadline. + * @returns The modified query with the new deadline. + */ +export const modifyDeadline = (query: Query, deadline: BigNumber): Query => { + return { + ...query, + deadline, + } +} + +/** + * Applies the slippage to the query's minAmountOut (rounded down), and returns the modified query + * with the reduced minAmountOut. + * Note: the original query is preserved unchanged. + * + * @param query - The query to modify. + * @param slipNumerator - The numerator of the slippage. + * @param slipDenominator - The denominator of the slippage. + * @returns The modified query with the reduced minAmountOut. + * @throws If the slippage fraction is invalid (<0, >1, or NaN) + */ +export const applySlippage = ( + query: Query, + slipNumerator: number, + slipDenominator: number +): Query => { + invariant(slipDenominator > 0, 'Slippage denominator cannot be zero') + invariant(slipNumerator >= 0, 'Slippage numerator cannot be negative') + invariant( + slipNumerator <= slipDenominator, + 'Slippage cannot be greater than 1' + ) + const slippageAmount = query.minAmountOut + .mul(slipNumerator) + .div(slipDenominator) + return { + ...query, + minAmountOut: query.minAmountOut.sub(slippageAmount), + } +} + +/** + * Applies the slippage (in basis points) to the query's minAmountOut (rounded down), and returns the modified query + * with the reduced minAmountOut. + * Note: the original query is preserved unchanged. + * Note: the slippage is applied as a fraction of 10000, e.g. 100 bips = 1%. + * + * @param query - The query to modify. + * @param slipBasisPoints - The slippage in basis points. + * @returns The modified query with the reduced minAmountOut. + * @throws If the basis points are invalid (<0, >10000) + */ +export const applySlippageInBips = ( + query: Query, + slipBasisPoints: number +): Query => { + return applySlippage(query, slipBasisPoints, 10000) +} diff --git a/packages/sdk-router/src/module/types.ts b/packages/sdk-router/src/module/types.ts index bcb6f823ba..6b9f66712f 100644 --- a/packages/sdk-router/src/module/types.ts +++ b/packages/sdk-router/src/module/types.ts @@ -76,14 +76,3 @@ export type BridgeRoute = { bridgeToken: BridgeToken bridgeModuleName: string } - -/** - * Finds the best route: the one with the maximum amount out in the destination query. - */ -export const findBestRoute = (bridgeRoutes: BridgeRoute[]): BridgeRoute => { - return bridgeRoutes.reduce((best, current) => { - return current.destQuery.minAmountOut.gt(best.destQuery.minAmountOut) - ? current - : best - }) -} diff --git a/packages/sdk-router/src/operations/bridge.ts b/packages/sdk-router/src/operations/bridge.ts index e297674fbd..3bbe789d3a 100644 --- a/packages/sdk-router/src/operations/bridge.ts +++ b/packages/sdk-router/src/operations/bridge.ts @@ -4,7 +4,7 @@ import { BigNumber, PopulatedTransaction } from 'ethers' import { BigintIsh } from '../constants' import { SynapseSDK } from '../sdk' import { handleNativeToken } from '../utils/handleNativeToken' -import { BridgeQuote, Query, findBestRoute } from '../module' +import { BridgeQuote, Query } from '../module' import { RouterSet } from '../router' /** @@ -86,37 +86,75 @@ export async function bridgeQuote( deadline?: BigNumber, excludeCCTP: boolean = false ): Promise { + // Get the quotes sorted by maxAmountOut + const allQuotes = await allBridgeQuotes.call( + this, + originChainId, + destChainId, + tokenIn, + tokenOut, + amountIn, + deadline + ) + // Get the first quote that is not excluded + const bestQuote = allQuotes.find( + (quote) => + !excludeCCTP || + quote.bridgeModuleName !== this.synapseCCTPRouterSet.bridgeModuleName + ) + if (!bestQuote) { + throw new Error('No route found') + } + return bestQuote +} + +/** + * This method tries to fetch all available quotes from all available bridge modules. + * + * @param originChainId - The ID of the original chain. + * @param destChainId - The ID of the destination chain. + * @param tokenIn - The input token. + * @param tokenOut - The output token. + * @param amountIn - The amount of input token. + * @param deadline - The transaction deadline, optional. + * @returns - A promise that resolves to an array of bridge quotes. + */ +export async function allBridgeQuotes( + this: SynapseSDK, + originChainId: number, + destChainId: number, + tokenIn: string, + tokenOut: string, + amountIn: BigintIsh, + deadline?: BigNumber +): Promise { invariant( originChainId !== destChainId, 'Origin chainId cannot be equal to destination chainId' ) tokenOut = handleNativeToken(tokenOut) tokenIn = handleNativeToken(tokenIn) - // Construct objects for both types of routers - const allSets: { set: RouterSet; exclude: boolean }[] = [ - { set: this.synapseRouterSet, exclude: false }, - { set: this.synapseCCTPRouterSet, exclude: excludeCCTP }, - ] - // Fetch bridge routes from both types of routers - const allRoutesPromises = allSets.map(({ set, exclude }) => - exclude - ? Promise.resolve([]) - : set.getBridgeRoutes( - originChainId, - destChainId, - tokenIn, - tokenOut, - amountIn - ) + const allQuotes: BridgeQuote[][] = await Promise.all( + this.allRouterSets.map(async (routerSet) => { + const routes = await routerSet.getBridgeRoutes( + originChainId, + destChainId, + tokenIn, + tokenOut, + amountIn + ) + // Filter out routes with zero minAmountOut and finalize the rest + return Promise.all( + routes + .filter((route) => route.destQuery.minAmountOut.gt(0)) + .map((route) => routerSet.finalizeBridgeRoute(route, deadline)) + ) + }) ) - // Wait for all quotes to resolve and flatten the result - const allRoutes = (await Promise.all(allRoutesPromises)).flat() - invariant(allRoutes.length > 0, 'No route found') - const bestRoute = findBestRoute(allRoutes) - // Find the Router Set that yielded the best route - const bestSet: RouterSet = getRouterSet.call(this, bestRoute.bridgeModuleName) - // Finalize the Bridge Route - return bestSet.finalizeBridgeRoute(bestRoute, deadline) + // Flatten the result and sort by maxAmountOut in descending order + return allQuotes + .flat() + .sort((a, b) => (a.maxAmountOut.gt(b.maxAmountOut) ? -1 : 1)) } /** diff --git a/packages/sdk-router/src/sdk.test.ts b/packages/sdk-router/src/sdk.test.ts index 8e460a1509..0bd32477e6 100644 --- a/packages/sdk-router/src/sdk.test.ts +++ b/packages/sdk-router/src/sdk.test.ts @@ -648,6 +648,55 @@ describe('SynapseSDK', () => { }) }) + describe('allBridgeQuotes', () => { + const synapse = new SynapseSDK( + [SupportedChainId.ETH, SupportedChainId.ARBITRUM], + [ethProvider, arbProvider] + ) + + it('Fetches SynapseBridge and SynapseCCTP quotes for USDC', async () => { + const allQuotes = await synapse.allBridgeQuotes( + SupportedChainId.ETH, + SupportedChainId.ARBITRUM, + ETH_USDC, + ARB_USDT, + BigNumber.from(10).pow(9) + ) + expect(allQuotes.length).toEqual(2) + expectCorrectBridgeQuote(allQuotes[0]) + expectCorrectBridgeQuote(allQuotes[1]) + // First quote should have better quote + expect(allQuotes[0].maxAmountOut.gte(allQuotes[1].maxAmountOut)).toBe( + true + ) + // One should be SynapseBridge and the other SynapseCCTP + expect(allQuotes[0].bridgeModuleName).not.toEqual( + allQuotes[1].bridgeModuleName + ) + expect( + allQuotes[0].bridgeModuleName === 'SynapseBridge' || + allQuotes[1].bridgeModuleName === 'SynapseBridge' + ).toBe(true) + expect( + allQuotes[0].bridgeModuleName === 'SynapseCCTP' || + allQuotes[1].bridgeModuleName === 'SynapseCCTP' + ).toBe(true) + }) + + it('Fetches only SynapseBridge quotes for ETH', async () => { + const allQuotes = await synapse.allBridgeQuotes( + SupportedChainId.ETH, + SupportedChainId.ARBITRUM, + NATIVE_ADDRESS, + NATIVE_ADDRESS, + BigNumber.from(10).pow(18) + ) + expect(allQuotes.length).toEqual(1) + expectCorrectBridgeQuote(allQuotes[0]) + expect(allQuotes[0].bridgeModuleName).toEqual('SynapseBridge') + }) + }) + describe('Errors', () => { const synapse = new SynapseSDK( [SupportedChainId.ETH, SupportedChainId.BSC], diff --git a/packages/sdk-router/src/sdk.ts b/packages/sdk-router/src/sdk.ts index a6de2bee64..c2ef87a802 100644 --- a/packages/sdk-router/src/sdk.ts +++ b/packages/sdk-router/src/sdk.ts @@ -2,6 +2,7 @@ import { Provider } from '@ethersproject/abstract-provider' import invariant from 'tiny-invariant' import { + RouterSet, SynapseRouterSet, SynapseCCTPRouterSet, ChainProvider, @@ -9,9 +10,15 @@ import { } from './router' import * as operations from './operations' import { ETH_NATIVE_TOKEN_ADDRESS } from './utils/handleNativeToken' -import { Query } from './module' +import { + Query, + applySlippage, + applySlippageInBips, + modifyDeadline, +} from './module' class SynapseSDK { + public allRouterSets: RouterSet[] public synapseRouterSet: SynapseRouterSet public synapseCCTPRouterSet: SynapseCCTPRouterSet public providers: { [chainId: number]: Provider } @@ -41,11 +48,13 @@ class SynapseSDK { // Initialize SynapseRouterSet and SynapseCCTPRouterSet this.synapseRouterSet = new SynapseRouterSet(chainProviders) this.synapseCCTPRouterSet = new SynapseCCTPRouterSet(chainProviders) + this.allRouterSets = [this.synapseRouterSet, this.synapseCCTPRouterSet] } // Define Bridge operations public bridge = operations.bridge public bridgeQuote = operations.bridgeQuote + public allBridgeQuotes = operations.allBridgeQuotes public getBridgeModuleName = operations.getBridgeModuleName public getEstimatedTime = operations.getEstimatedTime public getSynapseTxId = operations.getSynapseTxId @@ -65,6 +74,11 @@ class SynapseSDK { // Define Swap operations public swap = operations.swap public swapQuote = operations.swapQuote + + // Define Query operations + public applySlippage = applySlippage + public applySlippageInBips = applySlippageInBips + public modifyDeadline = modifyDeadline } export { SynapseSDK, ETH_NATIVE_TOKEN_ADDRESS, Query, PoolToken }