From f0998ab2aa017009c2749a17dae6e2ee5e8eb8c0 Mon Sep 17 00:00:00 2001 From: Mikhala <122326421+imx-mikhala@users.noreply.github.com> Date: Thu, 28 Sep 2023 14:53:08 +0800 Subject: [PATCH] WT-1662 Bridge and swap route (#907) Co-authored-by: Charlie McKenzie --- .../routing/bridge/bridgeRoute.test.ts | 472 ++------------- .../routing/bridge/bridgeRoute.ts | 67 +-- .../routing/bridge/constants.test.ts | 28 - .../smartCheckout/routing/bridge/constants.ts | 20 - .../bridge/estimateApprovalGas.test.ts | 2 +- .../routing/bridge/estimateApprovalGas.ts | 2 +- .../bridgeAndSwap/bridgeAndSwapRoute.test.ts | 465 +++++++++++++++ .../bridgeAndSwap/bridgeAndSwapRoute.ts | 340 +++++++++++ .../constructBridgeRequirements.test.ts | 550 ++++++++++++++++++ .../constructBridgeRequirements.ts | 117 ++++ .../bridgeAndSwap/fetchL1ToL2Mapping.test.ts | 64 ++ .../bridgeAndSwap/fetchL1ToL2Mappings.ts | 13 + .../bridgeAndSwap/getBalancesByChain.test.ts | 226 +++++++ .../bridgeAndSwap/getBalancesByChain.ts | 31 + .../bridgeAndSwap/getDexQuotes.test.ts | 60 ++ .../routing/bridgeAndSwap/getDexQuotes.ts | 34 ++ .../indexer/fetchL1Representation.test.ts | 97 +++ .../routing/indexer/fetchL1Representation.ts | 55 ++ .../routing/routingCalculator.test.ts | 508 +++++++++++++++- .../routing/routingCalculator.ts | 195 ++++++- .../routing/swap/dexQuoteCache.ts | 2 +- .../routing/swap/quoteFetcher.ts | 3 + .../sdk/src/smartCheckout/smartCheckout.ts | 1 + 23 files changed, 2802 insertions(+), 550 deletions(-) delete mode 100644 packages/checkout/sdk/src/smartCheckout/routing/bridge/constants.test.ts delete mode 100644 packages/checkout/sdk/src/smartCheckout/routing/bridge/constants.ts create mode 100644 packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/bridgeAndSwapRoute.test.ts create mode 100644 packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/bridgeAndSwapRoute.ts create mode 100644 packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/constructBridgeRequirements.test.ts create mode 100644 packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/constructBridgeRequirements.ts create mode 100644 packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/fetchL1ToL2Mapping.test.ts create mode 100644 packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/fetchL1ToL2Mappings.ts create mode 100644 packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/getBalancesByChain.test.ts create mode 100644 packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/getBalancesByChain.ts create mode 100644 packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/getDexQuotes.test.ts create mode 100644 packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/getDexQuotes.ts create mode 100644 packages/checkout/sdk/src/smartCheckout/routing/indexer/fetchL1Representation.test.ts create mode 100644 packages/checkout/sdk/src/smartCheckout/routing/indexer/fetchL1Representation.ts diff --git a/packages/checkout/sdk/src/smartCheckout/routing/bridge/bridgeRoute.test.ts b/packages/checkout/sdk/src/smartCheckout/routing/bridge/bridgeRoute.test.ts index 95b580623e..53b30d63d8 100644 --- a/packages/checkout/sdk/src/smartCheckout/routing/bridge/bridgeRoute.test.ts +++ b/packages/checkout/sdk/src/smartCheckout/routing/bridge/bridgeRoute.test.ts @@ -2,8 +2,8 @@ import { Environment } from '@imtbl/config'; import { JsonRpcProvider } from '@ethersproject/providers'; import { BigNumber } from 'ethers'; import { + BridgeRequirement, bridgeRoute, - fetchL1Representation, getBridgeGasEstimate, hasSufficientL1Eth, isNativeEth, @@ -12,22 +12,19 @@ import { CheckoutConfiguration } from '../../../config'; import { ChainId, FundingRouteType, - ItemType, } from '../../../types'; -import { BalanceRequirement } from '../../balanceCheck/types'; import { TokenBalanceResult } from '../types'; import { createBlockchainDataInstance } from '../../../instance'; import { estimateGasForBridgeApproval } from './estimateApprovalGas'; import { bridgeGasEstimate } from './bridgeGasEstimate'; -import { getImxL1Representation, INDEXER_ETH_ROOT_CONTRACT_ADDRESS } from './constants'; import { CheckoutErrorType } from '../../../errors'; import { allowListCheckForBridge } from '../../allowList/allowListCheck'; +import { INDEXER_ETH_ROOT_CONTRACT_ADDRESS } from '../indexer/fetchL1Representation'; jest.mock('../../../gasEstimate'); jest.mock('../../../instance'); jest.mock('./estimateApprovalGas'); jest.mock('./bridgeGasEstimate'); -jest.mock('./constants'); jest.mock('../../allowList/allowListCheck'); describe('bridgeRoute', () => { @@ -46,35 +43,10 @@ describe('bridgeRoute', () => { ]); describe('Bridge ETH ERC20', () => { - const balanceRequirement: BalanceRequirement = { - type: ItemType.ERC20, - sufficient: false, - delta: { - balance: BigNumber.from(10), - formattedBalance: '10', - }, - current: { - type: ItemType.ERC20, - balance: BigNumber.from(0), - formattedBalance: '0', - token: { - name: 'ETH-ERC20', - symbol: 'ETH-ERC20', - decimals: 18, - address: '0x123', - }, - }, - required: { - type: ItemType.ERC20, - balance: BigNumber.from(10), - formattedBalance: '10', - token: { - name: 'ETH-ERC20', - symbol: 'ETH-ERC20', - decimals: 18, - address: '0x123', - }, - }, + const bridgeRequirement: BridgeRequirement = { + amount: BigNumber.from(10), + formattedAmount: '10', + l2address: '0xL2ADDRESS', }; beforeEach(() => { @@ -132,7 +104,7 @@ describe('bridgeRoute', () => { { bridge: true, }, - balanceRequirement, + bridgeRequirement, balances, feeEstimates, ); @@ -177,7 +149,7 @@ describe('bridgeRoute', () => { { bridge: true, }, - balanceRequirement, + bridgeRequirement, balances, feeEstimates, ); @@ -222,7 +194,7 @@ describe('bridgeRoute', () => { { bridge: true, }, - balanceRequirement, + bridgeRequirement, balances, feeEstimates, ); @@ -266,7 +238,7 @@ describe('bridgeRoute', () => { { bridge: true, }, - balanceRequirement, + bridgeRequirement, balances, feeEstimates, ); @@ -277,35 +249,10 @@ describe('bridgeRoute', () => { }); describe('Bridge non-ETH ERC20', () => { - const balanceRequirement: BalanceRequirement = { - type: ItemType.ERC20, - sufficient: false, - delta: { - balance: BigNumber.from(10), - formattedBalance: '10', - }, - current: { - type: ItemType.ERC20, - balance: BigNumber.from(0), - formattedBalance: '0', - token: { - name: '0xERC20', - symbol: '0xERC20', - decimals: 18, - address: '0x123', - }, - }, - required: { - type: ItemType.ERC20, - balance: BigNumber.from(10), - formattedBalance: '10', - token: { - name: '0xERC20', - symbol: '0xERC20', - decimals: 18, - address: '0x123', - }, - }, + const bridgeRequirement = { + amount: BigNumber.from(10), + formattedAmount: '10', + l2address: '0xL2ADDRESS', }; beforeEach(() => { @@ -364,7 +311,7 @@ describe('bridgeRoute', () => { { bridge: true, }, - balanceRequirement, + bridgeRequirement, balances, feeEstimates, ); @@ -420,7 +367,7 @@ describe('bridgeRoute', () => { { bridge: true, }, - balanceRequirement, + bridgeRequirement, balances, feeEstimates, ); @@ -476,7 +423,7 @@ describe('bridgeRoute', () => { { bridge: true, }, - balanceRequirement, + bridgeRequirement, balances, feeEstimates, ); @@ -519,7 +466,7 @@ describe('bridgeRoute', () => { { bridge: true, }, - balanceRequirement, + bridgeRequirement, balances, feeEstimates, ); @@ -563,7 +510,7 @@ describe('bridgeRoute', () => { { bridge: true, }, - balanceRequirement, + bridgeRequirement, balances, feeEstimates, ); @@ -574,37 +521,20 @@ describe('bridgeRoute', () => { }); it('should return undefined if no balance on layer 1', async () => { - const balanceRequirement: BalanceRequirement = { - type: ItemType.ERC20, - sufficient: false, - delta: { - balance: BigNumber.from(10), - formattedBalance: '10', - }, - current: { - type: ItemType.ERC20, - balance: BigNumber.from(0), - formattedBalance: '0', - token: { - name: '0xERC20', - symbol: '0xERC20', - decimals: 18, - address: '0x123', - }, - }, - required: { - type: ItemType.ERC20, - balance: BigNumber.from(10), - formattedBalance: '10', - token: { - name: '0xERC20', - symbol: '0xERC20', - decimals: 18, - address: '0x123', - }, - }, + const bridgeRequirement = { + amount: BigNumber.from(10), + formattedAmount: '10', + l2address: '0xL2ADDRESS', }; + (allowListCheckForBridge as jest.Mock).mockResolvedValue([ + { + name: 'Ethereum', + symbol: 'ETH', + decimals: 18, + }, + ]); + const balances = new Map([ [ChainId.SEPOLIA, { success: true, @@ -619,7 +549,7 @@ describe('bridgeRoute', () => { { bridge: true, }, - balanceRequirement, + bridgeRequirement, balances, feeEstimates, ); @@ -628,35 +558,10 @@ describe('bridgeRoute', () => { }); it('should return undefined if no token balance result for L1', async () => { - const balanceRequirement: BalanceRequirement = { - type: ItemType.ERC20, - sufficient: false, - delta: { - balance: BigNumber.from(10), - formattedBalance: '10', - }, - current: { - type: ItemType.ERC20, - balance: BigNumber.from(0), - formattedBalance: '0', - token: { - name: '0xERC20', - symbol: '0xERC20', - decimals: 18, - address: '0x123', - }, - }, - required: { - type: ItemType.ERC20, - balance: BigNumber.from(10), - formattedBalance: '10', - token: { - name: '0xERC20', - symbol: '0xERC20', - decimals: 18, - address: '0x123', - }, - }, + const bridgeRequirement = { + amount: BigNumber.from(10), + formattedAmount: '10', + l2address: '0xL2ADDRESS', }; const balances = new Map([ @@ -673,7 +578,7 @@ describe('bridgeRoute', () => { { bridge: true, }, - balanceRequirement, + bridgeRequirement, balances, feeEstimates, ); @@ -682,35 +587,10 @@ describe('bridgeRoute', () => { }); it('should return undefined if token balance returned unsuccessful on L1', async () => { - const balanceRequirement: BalanceRequirement = { - type: ItemType.ERC20, - sufficient: false, - delta: { - balance: BigNumber.from(10), - formattedBalance: '10', - }, - current: { - type: ItemType.ERC20, - balance: BigNumber.from(0), - formattedBalance: '0', - token: { - name: '0xERC20', - symbol: '0xERC20', - decimals: 18, - address: '0x123', - }, - }, - required: { - type: ItemType.ERC20, - balance: BigNumber.from(10), - formattedBalance: '10', - token: { - name: '0xERC20', - symbol: '0xERC20', - decimals: 18, - address: '0x123', - }, - }, + const bridgeRequirement = { + amount: BigNumber.from(10), + formattedAmount: '10', + l2address: '0xL2ADDRESS', }; const balances = new Map([ @@ -727,7 +607,7 @@ describe('bridgeRoute', () => { { bridge: true, }, - balanceRequirement, + bridgeRequirement, balances, feeEstimates, ); @@ -736,35 +616,10 @@ describe('bridgeRoute', () => { }); it('should throw error if readonly providers missing L1', async () => { - const balanceRequirement: BalanceRequirement = { - type: ItemType.ERC20, - sufficient: false, - delta: { - balance: BigNumber.from(10), - formattedBalance: '10', - }, - current: { - type: ItemType.ERC20, - balance: BigNumber.from(0), - formattedBalance: '0', - token: { - name: '0xERC20', - symbol: '0xERC20', - decimals: 18, - address: '0x123', - }, - }, - required: { - type: ItemType.ERC20, - balance: BigNumber.from(10), - formattedBalance: '10', - token: { - name: '0xERC20', - symbol: '0xERC20', - decimals: 18, - address: '0x123', - }, - }, + const bridgeRequirement = { + amount: BigNumber.from(10), + formattedAmount: '10', + l2address: '0xL2ADDRESS', }; const balances = new Map([ @@ -787,7 +642,7 @@ describe('bridgeRoute', () => { { bridge: true, }, - balanceRequirement, + bridgeRequirement, balances, feeEstimates, ); @@ -869,239 +724,6 @@ describe('bridgeRoute', () => { }); }); - describe('fetchL1Representation', () => { - it('should fetch L1 representation of ERC20', async () => { - const balanceRequirement: BalanceRequirement = { - type: ItemType.ERC20, - sufficient: false, - delta: { - balance: BigNumber.from(10), - formattedBalance: '10', - }, - current: { - type: ItemType.ERC20, - balance: BigNumber.from(0), - formattedBalance: '0', - token: { - name: '0xERC20', - symbol: '0xERC20', - decimals: 18, - address: '0x123', - }, - }, - required: { - type: ItemType.ERC20, - balance: BigNumber.from(10), - formattedBalance: '10', - token: { - name: '0xERC20', - symbol: '0xERC20', - decimals: 18, - address: '0x123', - }, - }, - }; - - (createBlockchainDataInstance as jest.Mock).mockReturnValue({ - getToken: jest.fn().mockResolvedValue({ - result: { - // eslint-disable-next-line @typescript-eslint/naming-convention - root_contract_address: '0xROOT_ADDRESS', - }, - }), - }); - - const l1Address = await fetchL1Representation( - config, - balanceRequirement, - ); - - expect(l1Address).toEqual('0xROOT_ADDRESS'); - }); - - it('should fetch L1 representation of NATIVE', async () => { - const balanceRequirement: BalanceRequirement = { - type: ItemType.NATIVE, - sufficient: false, - delta: { - balance: BigNumber.from(10), - formattedBalance: '10', - }, - current: { - type: ItemType.NATIVE, - balance: BigNumber.from(0), - formattedBalance: '0', - token: { - name: '0xNATIVE', - symbol: '0xNATIVE', - decimals: 18, - }, - }, - required: { - type: ItemType.NATIVE, - balance: BigNumber.from(10), - formattedBalance: '10', - token: { - name: '0xNATIVE', - symbol: '0xNATIVE', - decimals: 18, - }, - }, - }; - (getImxL1Representation as jest.Mock).mockResolvedValue('0x2Fa06C6672dDCc066Ab04631192738799231dE4a'); - (createBlockchainDataInstance as jest.Mock).mockReturnValue({ - getToken: jest.fn().mockResolvedValue({}), - }); - - const l1Address = await fetchL1Representation( - config, - balanceRequirement, - ); - - expect(l1Address).toEqual('0x2Fa06C6672dDCc066Ab04631192738799231dE4a'); - expect(createBlockchainDataInstance).not.toHaveBeenCalled(); - }); - - it('should return empty string if indexer returns null', async () => { - const balanceRequirement: BalanceRequirement = { - type: ItemType.ERC20, - sufficient: false, - delta: { - balance: BigNumber.from(10), - formattedBalance: '10', - }, - current: { - type: ItemType.ERC20, - balance: BigNumber.from(0), - formattedBalance: '0', - token: { - name: '0xERC20', - symbol: '0xERC20', - decimals: 18, - address: '0x123', - }, - }, - required: { - type: ItemType.ERC20, - balance: BigNumber.from(10), - formattedBalance: '10', - token: { - name: '0xERC20', - symbol: '0xERC20', - decimals: 18, - address: '0x123', - }, - }, - }; - - (createBlockchainDataInstance as jest.Mock).mockReturnValue({ - getToken: jest.fn().mockResolvedValue({ - result: { - // eslint-disable-next-line @typescript-eslint/naming-convention - root_contract_address: null, - }, - }), - }); - - const l1Address = await fetchL1Representation( - config, - balanceRequirement, - ); - - expect(l1Address).toEqual(''); - }); - - it('should return empty string if no address in ERC20 requirement', async () => { - const balanceRequirement: BalanceRequirement = { - type: ItemType.ERC20, - sufficient: false, - delta: { - balance: BigNumber.from(10), - formattedBalance: '10', - }, - current: { - type: ItemType.ERC20, - balance: BigNumber.from(0), - formattedBalance: '0', - token: { - name: '0xERC20', - symbol: '0xERC20', - decimals: 18, - }, - }, - required: { - type: ItemType.ERC20, - balance: BigNumber.from(10), - formattedBalance: '10', - token: { - name: '0xERC20', - symbol: '0xERC20', - decimals: 18, - }, - }, - }; - - (createBlockchainDataInstance as jest.Mock).mockReturnValue({ - getToken: jest.fn().mockResolvedValue({ - result: { - // eslint-disable-next-line @typescript-eslint/naming-convention - root_contract_address: '0xROOT_ADDRESS', - }, - }), - }); - - const l1Address = await fetchL1Representation( - config, - balanceRequirement, - ); - - expect(l1Address).toEqual(''); - expect(createBlockchainDataInstance).not.toHaveBeenCalled(); - }); - - it('should return empty string if ERC721 requirement', async () => { - const balanceRequirement: BalanceRequirement = { - type: ItemType.ERC721, - sufficient: false, - delta: { - balance: BigNumber.from(10), - formattedBalance: '10', - }, - current: { - type: ItemType.ERC721, - balance: BigNumber.from(0), - formattedBalance: '0', - id: '0', - contractAddress: '0xERC721', - }, - required: { - type: ItemType.ERC721, - balance: BigNumber.from(10), - formattedBalance: '10', - id: '0', - contractAddress: '0xERC721', - }, - }; - - (createBlockchainDataInstance as jest.Mock).mockReturnValue({ - getToken: jest.fn().mockResolvedValue({ - result: { - // eslint-disable-next-line @typescript-eslint/naming-convention - root_contract_address: '0xROOT_ADDRESS', - }, - }), - }); - - const l1Address = await fetchL1Representation( - config, - balanceRequirement, - ); - - expect(l1Address).toEqual(''); - expect(createBlockchainDataInstance).not.toHaveBeenCalled(); - }); - }); - describe('getBridgeGasEstimate', () => { it('should get from cache if already fetched', async () => { (bridgeGasEstimate as jest.Mock).mockResolvedValue(BigNumber.from(1)); diff --git a/packages/checkout/sdk/src/smartCheckout/routing/bridge/bridgeRoute.ts b/packages/checkout/sdk/src/smartCheckout/routing/bridge/bridgeRoute.ts index 636a648648..4bd61b8e5c 100644 --- a/packages/checkout/sdk/src/smartCheckout/routing/bridge/bridgeRoute.ts +++ b/packages/checkout/sdk/src/smartCheckout/routing/bridge/bridgeRoute.ts @@ -1,6 +1,4 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ -import { BigNumber, Contract, ethers } from 'ethers'; -import { Web3Provider } from '@ethersproject/providers'; +import { BigNumber, ethers } from 'ethers'; import { ChainId, FundingRouteType, @@ -9,22 +7,21 @@ import { ItemType, RoutingOptionsAvailable, } from '../../../types'; -import { CheckoutConfiguration, getL1ChainId, getL2ChainId } from '../../../config'; +import { CheckoutConfiguration, getL1ChainId } from '../../../config'; import { FundingRouteStep, TokenBalanceResult } from '../types'; import { BalanceRequirement } from '../../balanceCheck/types'; -import { createBlockchainDataInstance } from '../../../instance'; import { getEthBalance } from './getEthBalance'; import { bridgeGasEstimate } from './bridgeGasEstimate'; -import { INDEXER_ETH_ROOT_CONTRACT_ADDRESS, getImxL1Representation, getIndexerChainName } from './constants'; import { estimateGasForBridgeApproval } from './estimateApprovalGas'; import { CheckoutError, CheckoutErrorType } from '../../../errors'; import { allowListCheckForBridge } from '../../allowList/allowListCheck'; +import { INDEXER_ETH_ROOT_CONTRACT_ADDRESS, fetchL1Representation } from '../indexer/fetchL1Representation'; export const hasSufficientL1Eth = ( - balances: TokenBalanceResult, + tokenBalanceResult: TokenBalanceResult, totalFees: BigNumber, ): boolean => { - const balance = getEthBalance(balances); + const balance = getEthBalance(tokenBalanceResult); return balance.gte(totalFees); }; @@ -42,32 +39,6 @@ export const getTokenAddressFromRequirement = ( return ''; }; -export const fetchL1Representation = async ( - config: CheckoutConfiguration, - balanceRequirement: BalanceRequirement, -): Promise => { - const l2address = getTokenAddressFromRequirement(balanceRequirement); - if (l2address === '') return ''; - - if (l2address === IMX_ADDRESS_ZKEVM) { - return await getImxL1Representation(getL1ChainId(config), config); - } - - const chainName = getIndexerChainName(getL2ChainId(config)); - if (chainName === '') return ''; // Chain name not a valid indexer chain name - - const blockchainData = createBlockchainDataInstance(config); - const tokenData = await blockchainData.getToken({ - chainName, - contractAddress: l2address, - }); - - const l1address = tokenData.result.root_contract_address; - if (l1address === null) return ''; // No L1 representation of this token - - return l1address; -}; - export const getBridgeGasEstimate = async ( config: CheckoutConfiguration, readOnlyProviders: Map, @@ -105,18 +76,24 @@ export const isNativeEth = (address: string | undefined): boolean => { return false; }; +export type BridgeRequirement = { + amount: BigNumber; + formattedAmount: string; + l2address: string; +}; export const bridgeRoute = async ( config: CheckoutConfiguration, readOnlyProviders: Map, depositorAddress: string, availableRoutingOptions: RoutingOptionsAvailable, - balanceRequirement: BalanceRequirement, - balances: Map, + bridgeRequirement: BridgeRequirement, + tokenBalanceResults: Map, feeEstimates: Map, ): Promise => { if (!availableRoutingOptions.bridge) return undefined; + if (bridgeRequirement.l2address === undefined || bridgeRequirement.l2address === '') return undefined; const chainId = getL1ChainId(config); - const tokenBalanceResult = balances.get(chainId); + const tokenBalanceResult = tokenBalanceResults.get(chainId); const l1provider = readOnlyProviders.get(chainId); if (!l1provider) { throw new CheckoutError( @@ -129,7 +106,7 @@ export const bridgeRoute = async ( // If no balances on layer 1 then Bridge cannot be an option if (tokenBalanceResult === undefined || tokenBalanceResult.success === false) return undefined; - const allowedTokenList = await allowListCheckForBridge(config, balances, availableRoutingOptions); + const allowedTokenList = await allowListCheckForBridge(config, tokenBalanceResults, availableRoutingOptions); if (allowedTokenList.length === 0) return undefined; const bridgeFeeEstimate = await getBridgeGasEstimate(config, readOnlyProviders, feeEstimates); @@ -137,7 +114,9 @@ export const bridgeRoute = async ( // If the user has no ETH to cover the bridge fees or approval fees then bridge cannot be an option if (!hasSufficientL1Eth(tokenBalanceResult, bridgeFeeEstimate)) return undefined; - const l1address = await fetchL1Representation(config, balanceRequirement); + const l1RepresentationResult = await fetchL1Representation(config, bridgeRequirement.l2address); + // No mapping on L1 for this token + const { l1address } = l1RepresentationResult; if (l1address === '') return undefined; // Ensure l1address is in the allowed token list @@ -153,7 +132,7 @@ export const bridgeRoute = async ( l1provider, depositorAddress, l1address, - balanceRequirement.delta.balance, + bridgeRequirement.amount, ); if (!hasSufficientL1Eth( @@ -166,7 +145,9 @@ export const bridgeRoute = async ( const nativeETHBalance = tokenBalanceResult.balances .find((balance) => isNativeEth(balance.token.address)); - if (nativeETHBalance && nativeETHBalance.balance.gte(balanceRequirement.delta.balance.add(bridgeFeeEstimate))) { + if (nativeETHBalance && nativeETHBalance.balance.gte( + bridgeRequirement.amount.add(bridgeFeeEstimate), + )) { return constructBridgeFundingRoute(chainId, nativeETHBalance); } @@ -175,7 +156,9 @@ export const bridgeRoute = async ( // Find the balance of the L1 representation of the token and check if the balance covers the delta const erc20balance = tokenBalanceResult.balances.find((balance) => balance.token.address === l1address); - if (erc20balance && erc20balance.balance.gte(balanceRequirement.delta.balance)) { + if (erc20balance && erc20balance.balance.gte( + bridgeRequirement.amount, + )) { return constructBridgeFundingRoute(chainId, erc20balance); } diff --git a/packages/checkout/sdk/src/smartCheckout/routing/bridge/constants.test.ts b/packages/checkout/sdk/src/smartCheckout/routing/bridge/constants.test.ts deleted file mode 100644 index f7321789bf..0000000000 --- a/packages/checkout/sdk/src/smartCheckout/routing/bridge/constants.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { getImxL1Representation } from './constants'; -import { ChainId } from '../../../types'; - -describe('constants', () => { - it('should return the IMX address matching the chainId', async () => { - const config = { - remote: { - getConfig: jest.fn().mockResolvedValue({ - [ChainId.SEPOLIA]: '0x123', - }), - }, - } as any; - - const result = await getImxL1Representation(ChainId.SEPOLIA, config); - expect(result).toBe('0x123'); - }); - - it('should return empty when no matching chainId', async () => { - const config = { - remote: { - getConfig: jest.fn().mockResolvedValue({}), - }, - } as any; - - const result = await getImxL1Representation(ChainId.SEPOLIA, config); - expect(result).toBe(''); - }); -}); diff --git a/packages/checkout/sdk/src/smartCheckout/routing/bridge/constants.ts b/packages/checkout/sdk/src/smartCheckout/routing/bridge/constants.ts deleted file mode 100644 index 343d84f6ec..0000000000 --- a/packages/checkout/sdk/src/smartCheckout/routing/bridge/constants.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { ChainId, ImxAddressConfig } from '../../../types'; -import { CheckoutConfiguration } from '../../../config'; - -// If the root address evaluates to this then its ETH -export const INDEXER_ETH_ROOT_CONTRACT_ADDRESS = '0x0000000000000000000000000000000000000001'; - -export const getIndexerChainName = (chainId: ChainId): string => { - if (chainId === ChainId.IMTBL_ZKEVM_TESTNET) return 'imtbl-zkevm-testnet'; - return ''; -}; - -// Indexer ERC20 call does not support IMX so cannot get root chain mapping from this endpoint. -// Use the remote config instead to find IMX address mapping. -export const getImxL1Representation = async (chainId: ChainId, config: CheckoutConfiguration): Promise => { - const imxMappingConfig = (await config.remote.getConfig( - 'imxAddressMapping', - )) as ImxAddressConfig; - - return imxMappingConfig[chainId] ?? ''; -}; diff --git a/packages/checkout/sdk/src/smartCheckout/routing/bridge/estimateApprovalGas.test.ts b/packages/checkout/sdk/src/smartCheckout/routing/bridge/estimateApprovalGas.test.ts index 46e988b11c..ecb9235889 100644 --- a/packages/checkout/sdk/src/smartCheckout/routing/bridge/estimateApprovalGas.test.ts +++ b/packages/checkout/sdk/src/smartCheckout/routing/bridge/estimateApprovalGas.test.ts @@ -6,7 +6,7 @@ import { CheckoutConfiguration } from '../../../config'; import { ChainId } from '../../../types'; import { estimateApprovalGas, estimateGasForBridgeApproval } from './estimateApprovalGas'; import { CheckoutErrorType } from '../../../errors'; -import { INDEXER_ETH_ROOT_CONTRACT_ADDRESS } from './constants'; +import { INDEXER_ETH_ROOT_CONTRACT_ADDRESS } from '../indexer/fetchL1Representation'; jest.mock('../../../instance'); jest.mock('../../../config'); diff --git a/packages/checkout/sdk/src/smartCheckout/routing/bridge/estimateApprovalGas.ts b/packages/checkout/sdk/src/smartCheckout/routing/bridge/estimateApprovalGas.ts index a939c4e975..2d61f6dca2 100644 --- a/packages/checkout/sdk/src/smartCheckout/routing/bridge/estimateApprovalGas.ts +++ b/packages/checkout/sdk/src/smartCheckout/routing/bridge/estimateApprovalGas.ts @@ -3,7 +3,7 @@ import { CheckoutConfiguration, getL1ChainId, getL2ChainId } from '../../../conf import { ChainId } from '../../../types'; import * as instance from '../../../instance'; import { CheckoutError, CheckoutErrorType } from '../../../errors'; -import { INDEXER_ETH_ROOT_CONTRACT_ADDRESS } from './constants'; +import { INDEXER_ETH_ROOT_CONTRACT_ADDRESS } from '../indexer/fetchL1Representation'; export const estimateApprovalGas = async ( config: CheckoutConfiguration, diff --git a/packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/bridgeAndSwapRoute.test.ts b/packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/bridgeAndSwapRoute.test.ts new file mode 100644 index 0000000000..f271682b1e --- /dev/null +++ b/packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/bridgeAndSwapRoute.test.ts @@ -0,0 +1,465 @@ +import { Environment } from '@imtbl/config'; +import { BigNumber } from 'ethers'; +import { JsonRpcProvider } from '@ethersproject/providers'; +import { Quote } from '@imtbl/dex-sdk'; +import { CheckoutConfiguration } from '../../../config'; +import { + ChainId, + FundingRouteType, + ItemType, + TokenInfo, +} from '../../../types'; +import { BalanceCheckResult, BalanceERC20Requirement } from '../../balanceCheck/types'; +import { + DexQuote, DexQuotes, TokenBalanceResult, +} from '../types'; +import { bridgeAndSwapRoute } from './bridgeAndSwapRoute'; +import { fetchL1ToL2Mappings } from './fetchL1ToL2Mappings'; +import { bridgeRoute } from '../bridge/bridgeRoute'; +import { swapRoute } from '../swap/swapRoute'; +import { getDexQuotes } from './getDexQuotes'; +import { constructBridgeRequirements } from './constructBridgeRequirements'; +import { INDEXER_ETH_ROOT_CONTRACT_ADDRESS } from '../indexer/fetchL1Representation'; + +jest.mock('./fetchL1ToL2Mappings'); +jest.mock('./getDexQuotes'); +jest.mock('../bridge/bridgeRoute'); +jest.mock('../swap/swapRoute'); +jest.mock('./constructBridgeRequirements'); + +describe('bridgeAndSwapRoute', () => { + const config = new CheckoutConfiguration({ + baseConfig: { environment: Environment.SANDBOX }, + }); + + const readonlyProviders = new Map([ + [ChainId.SEPOLIA, {} as JsonRpcProvider], + [ChainId.IMTBL_ZKEVM_TESTNET, {} as JsonRpcProvider], + ]); + + const availableRoutingOptions = { + bridge: true, + swap: true, + }; + + const feeEstimates = new Map([ + [FundingRouteType.BRIDGE, BigNumber.from(1)], + ]); + + const tokenBalances = new Map([ + [ChainId.IMTBL_ZKEVM_TESTNET, { + success: true, + balances: [ + { + balance: BigNumber.from(5), + formattedBalance: '5', + token: { + name: 'zkYEET', + symbol: 'YEET', + decimals: 18, + address: '0xYEET', + }, + }, + { + balance: BigNumber.from(10), + formattedBalance: '10', + token: { + name: 'IMX', + symbol: 'IMX', + decimals: 18, + address: '0xIMX', + }, + }, + ], + }], + [ChainId.SEPOLIA, { + success: true, + balances: [ + { + balance: BigNumber.from(10), + formattedBalance: '10', + token: { + name: 'ETH', + symbol: 'ETH', + decimals: 18, + }, + }, + { + balance: BigNumber.from(10), + formattedBalance: '10', + token: { + name: 'IMX', + symbol: 'IMX', + decimals: 18, + address: '0xIMXL1', + }, + }, + ], + }], + ]); + + const ownerAddress = '0xOWNER'; + + const getTestDexQuotes = (): DexQuotes => { + const dexQuotes = new Map([]); + const dexQuoteIMX: DexQuote = { + quote: { + amount: { + value: BigNumber.from(10), + token: { + address: '0xYEET', + } as TokenInfo, + }, + amountWithMaxSlippage: { + value: BigNumber.from(15), + token: { + address: '0xIMX', + } as TokenInfo, + }, + slippage: 1, + fees: [ + { + amount: { + value: BigNumber.from(5), + token: { + address: '0xIMX', + } as TokenInfo, + }, + recipient: '', + basisPoints: 0, + }, + ], + } as Quote, + approval: undefined, + swap: null, + }; + const dexQuoteETH: DexQuote = { + quote: { + amount: { + value: BigNumber.from(10), + token: { + address: '0xYEET', + } as TokenInfo, + }, + amountWithMaxSlippage: { + value: BigNumber.from(15), + token: { + address: '0xETH', + } as TokenInfo, + }, + slippage: 1, + fees: [ + { + amount: { + value: BigNumber.from(5), + token: { + address: '0xETH', + } as TokenInfo, + }, + recipient: '', + basisPoints: 0, + }, + ], + } as Quote, + approval: undefined, + swap: null, + }; + + dexQuotes.set('0xIMX', dexQuoteIMX); + dexQuotes.set('0xETH', dexQuoteETH); + return dexQuotes; + }; + + const getTestDexQuoteCache = (): Map => { + const dexQuoteCache = new Map([]); + const dexQuotes = getTestDexQuotes(); + + dexQuoteCache.set('0xYEET', dexQuotes); + + return dexQuoteCache; + }; + + it('should return bridge and swap routes', async () => { + (fetchL1ToL2Mappings as jest.Mock).mockResolvedValue( + [ + { + l1address: '0xIMXL1', + l2address: '0xIMX', + }, + { + l1address: INDEXER_ETH_ROOT_CONTRACT_ADDRESS, + l2address: '0xETH', + }, + ], + ); + (getDexQuotes as jest.Mock).mockResolvedValue(getTestDexQuotes()); + (bridgeRoute as jest.Mock) + .mockResolvedValueOnce( + { + type: FundingRouteType.BRIDGE, + chainId: ChainId.SEPOLIA, + asset: { + balance: BigNumber.from(5), + formattedBalance: '5', + token: { + name: 'IMX', + symbol: 'IMX', + decimals: 18, + address: '0xIMXL1', + }, + }, + }, + ) + .mockResolvedValueOnce( + { + type: FundingRouteType.BRIDGE, + chainId: ChainId.SEPOLIA, + asset: { + balance: BigNumber.from(10), + formattedBalance: '10', + token: { + name: 'ETH', + symbol: 'ETH', + decimals: 18, + }, + }, + }, + ); + (swapRoute as jest.Mock).mockResolvedValue( + [ + { + type: FundingRouteType.SWAP, + chainId: ChainId.IMTBL_ZKEVM_TESTNET, + asset: { + balance: BigNumber.from(5), + formattedBalance: '5', + token: { + name: 'IMX', + symbol: 'IMX', + decimals: 18, + address: '0xIMX', + }, + }, + }, + { + type: FundingRouteType.SWAP, + chainId: ChainId.IMTBL_ZKEVM_TESTNET, + asset: { + balance: BigNumber.from(5), + formattedBalance: '5', + token: { + name: 'ETH', + symbol: 'ETH', + decimals: 18, + address: '0xETH', + }, + }, + }, + ], + ); + (constructBridgeRequirements as jest.Mock).mockReturnValue( + [ + { + amount: BigNumber.from(10), + formattedAmount: '10', + l2address: '0xIMX', + }, + { + amount: BigNumber.from(10), + formattedAmount: '10', + l2address: '0xETH', + }, + ], + ); + + const insufficientRequirement: BalanceERC20Requirement = { + type: ItemType.ERC20, + sufficient: false, + delta: { + balance: BigNumber.from(5), + formattedBalance: '5', + }, + current: { + type: ItemType.ERC20, + balance: BigNumber.from(5), + formattedBalance: '5', + token: { + address: '0xYEET', + decimals: 18, + name: 'zkYEET', + symbol: 'YEET', + } as TokenInfo, + }, + required: { + type: ItemType.ERC20, + balance: BigNumber.from(10), + formattedBalance: '10', + token: { + address: '0xYEET', + decimals: 18, + name: 'zkYEET', + symbol: 'YEET', + } as TokenInfo, + }, + }; + + const bridgeableTokens: string[] = [INDEXER_ETH_ROOT_CONTRACT_ADDRESS, '0xIMXL1']; + const swappableTokens: TokenInfo[] = [ + { + address: '0xYEET', + decimals: 18, + name: 'zkYEET', + symbol: 'YEET', + }, + { + address: '0xIMX', + decimals: 18, + name: 'IMX', + symbol: 'IMX', + }, + { + address: '0xETH', + decimals: 18, + name: 'ETH', + symbol: 'ETH', + }, + ]; + + const balanceRequirements = { + sufficient: false, + balanceRequirements: [], + } as BalanceCheckResult; + + const result = await bridgeAndSwapRoute( + config, + readonlyProviders, + availableRoutingOptions, + insufficientRequirement, + getTestDexQuoteCache(), + ownerAddress, + feeEstimates, + tokenBalances, + bridgeableTokens, + swappableTokens, + balanceRequirements, + ); + + expect(result).toEqual( + [ + { + bridgeFundingStep: { + type: FundingRouteType.BRIDGE, + chainId: ChainId.SEPOLIA, + asset: { + balance: BigNumber.from(5), + formattedBalance: '5', + token: { + name: 'IMX', + symbol: 'IMX', + decimals: 18, + address: '0xIMXL1', + }, + }, + }, + swapFundingStep: { + type: FundingRouteType.SWAP, + chainId: ChainId.IMTBL_ZKEVM_TESTNET, + asset: { + balance: BigNumber.from(10), + formattedBalance: '10', + token: { + name: 'IMX', + symbol: 'IMX', + decimals: 18, + address: '0xIMX', + }, + }, + }, + }, + { + bridgeFundingStep: { + type: FundingRouteType.BRIDGE, + chainId: ChainId.SEPOLIA, + asset: { + balance: BigNumber.from(10), + formattedBalance: '10', + token: { + name: 'ETH', + symbol: 'ETH', + decimals: 18, + }, + }, + }, + swapFundingStep: { + type: FundingRouteType.SWAP, + chainId: ChainId.IMTBL_ZKEVM_TESTNET, + asset: { + balance: BigNumber.from(0), + formattedBalance: '0', + token: { + name: 'ETH', + symbol: 'ETH', + decimals: 18, + address: '0xETH', + }, + }, + }, + }, + ], + ); + }); + + it('should return no bridge and swap routes if no bridgeable and swappable tokens available', async () => { + const insufficientRequirement: BalanceERC20Requirement = { + type: ItemType.ERC20, + sufficient: false, + delta: { + balance: BigNumber.from(5), + formattedBalance: '5', + }, + current: { + type: ItemType.ERC20, + balance: BigNumber.from(5), + formattedBalance: '5', + token: { + address: '0xIMX', + decimals: 18, + name: 'zkYEET', + symbol: 'YEET', + } as TokenInfo, + }, + required: { + type: ItemType.ERC20, + balance: BigNumber.from(10), + formattedBalance: '10', + token: { + address: '0xIMX', + decimals: 18, + name: 'zkYEET', + symbol: 'YEET', + } as TokenInfo, + }, + }; + + const bridgeableTokens: string[] = []; + const swappableTokens: TokenInfo[] = []; + + const balanceRequirements = {} as BalanceCheckResult; + + const result = await bridgeAndSwapRoute( + config, + readonlyProviders, + availableRoutingOptions, + insufficientRequirement, + getTestDexQuoteCache(), + ownerAddress, + feeEstimates, + tokenBalances, + bridgeableTokens, + swappableTokens, + balanceRequirements, + ); + + expect(result).toEqual([]); + }); +}); diff --git a/packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/bridgeAndSwapRoute.ts b/packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/bridgeAndSwapRoute.ts new file mode 100644 index 0000000000..b21e349e82 --- /dev/null +++ b/packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/bridgeAndSwapRoute.ts @@ -0,0 +1,340 @@ +import { BigNumber, utils } from 'ethers'; +import { JsonRpcProvider } from '@ethersproject/providers'; +import { CheckoutConfiguration, getL2ChainId } from '../../../config'; +import { + ChainId, + FundingRouteType, + GetBalanceResult, + RoutingOptionsAvailable, + TokenInfo, +} from '../../../types'; +import { + BalanceCheckResult, + BalanceERC20Requirement, + BalanceNativeRequirement, +} from '../../balanceCheck/types'; +import { + DexQuoteCache, + FundingRouteStep, + TokenBalanceResult, +} from '../types'; +import { BridgeRequirement, bridgeRoute } from '../bridge/bridgeRoute'; +import { swapRoute } from '../swap/swapRoute'; +import { getBalancesByChain } from './getBalancesByChain'; +import { constructBridgeRequirements } from './constructBridgeRequirements'; +import { fetchL1ToL2Mappings } from './fetchL1ToL2Mappings'; +import { INDEXER_ETH_ROOT_CONTRACT_ADDRESS, L1ToL2TokenAddressMapping } from '../indexer/fetchL1Representation'; +import { getDexQuotes } from './getDexQuotes'; + +export const abortBridgeAndSwap = ( + bridgeableTokens: string[], + swappableTokens: TokenInfo[], + l1balances: GetBalanceResult[], + l2balances: GetBalanceResult[], + availableRoutingOptions: RoutingOptionsAvailable, + requiredTokenAddress: string | undefined, +) => { + if (bridgeableTokens.length === 0) return true; + if (swappableTokens.length === 0) return true; + if (l1balances.length === 0) return true; + if (l2balances.length === 0) return true; + if (!availableRoutingOptions.bridge) return true; + if (!availableRoutingOptions.swap) return true; + if (requiredTokenAddress === undefined) return true; + if (requiredTokenAddress === '') return true; + return false; +}; + +export const filterSwappableTokensByBridgeableAddresses = ( + requiredTokenAddress: string, + bridgeableTokens: string[], + swappableTokens: TokenInfo[], + l1tol2Addresses: L1ToL2TokenAddressMapping[], +): TokenInfo[] => { + const filteredSwappableTokens: TokenInfo[] = []; + + for (const addresses of l1tol2Addresses) { + if (addresses.l1address === '') continue; + if (addresses.l2address === '') continue; + if (!bridgeableTokens.includes(addresses.l1address)) continue; + // Filter out the token that is required from the swappable tokens list + if (addresses.l2address === requiredTokenAddress) continue; + + const tokenInfo = swappableTokens.find((token) => token.address === addresses.l2address); + if (!tokenInfo) continue; + filteredSwappableTokens.push(tokenInfo); + } + + return filteredSwappableTokens; +}; + +// Modifies a users balance to include the amount as if the user successfully bridged +// This is so the swap route can check against the balance once the user has performed a bridge +const modifyTokenBalancesWithBridgedAmount = ( + config: CheckoutConfiguration, + tokenBalances: Map, + l2balances: GetBalanceResult[], + bridgedTokens: BridgeRequirement[], + swappableTokens: TokenInfo[], // used to construct the token info +): Map => { + const modifiedTokenBalances: Map = new Map(); + for (const [chainId, tokenBalance] of tokenBalances) { + modifiedTokenBalances.set(chainId, { + success: tokenBalance.success, + balances: tokenBalance.balances, + }); + } + + // Construct a map of balances to the L2 token address to make + // it easier to adjust the balances for the tokens that can be bridged + const balanceMap = new Map(); + for (const balance of l2balances) { + if (!balance.token.address) continue; + balanceMap.set(balance.token.address, balance); + } + + // Go through each of the tokens that can be bridged + // and adjust the balances to fake the bridge + for (const bridgedToken of bridgedTokens) { + const { amount, l2address } = bridgedToken; + if (l2address === '') continue; + + let l2balance = BigNumber.from(0); + // Find the current balance of this token + const currentBalance = balanceMap.get(l2address); + if (currentBalance) l2balance = currentBalance.balance; + + const newBalance = l2balance.add(amount); + + const tokenInfo = swappableTokens.find((token) => token.address === l2address) as TokenInfo; + + balanceMap.set(l2address, { + balance: newBalance, + formattedBalance: utils.formatUnits( + newBalance, + tokenInfo.decimals, + ), + token: tokenInfo, + }); + } + + const updatedBalances = Array.from(balanceMap.values()); + modifiedTokenBalances.set( + getL2ChainId(config), + { + success: true, + balances: updatedBalances, + }, + ); + + return modifiedTokenBalances; +}; + +// Reapply the original swap balances after the +// swap route was modified to fake the bridge +export const reapplyOriginalSwapBalances = ( + tokenBalances: Map, + swapRoutes: FundingRouteStep[], +): FundingRouteStep[] => { + const originalSwapSteps: FundingRouteStep[] = []; + for (const route of swapRoutes) { + const { chainId, asset } = route; + const tokenBalance = tokenBalances.get(chainId); + if (!tokenBalance) continue; + + let originalBalance = BigNumber.from(0); + let originalFormattedBalance = '0'; + const l2balance = tokenBalance.balances.find((balance) => balance.token.address === asset.token.address); + if (l2balance) { + originalBalance = l2balance.balance; + originalFormattedBalance = l2balance.formattedBalance; + } + + route.asset.balance = originalBalance; + route.asset.formattedBalance = originalFormattedBalance; + + originalSwapSteps.push(route); + } + + return originalSwapSteps; +}; + +export const constructBridgeAndSwapRoutes = ( + bridgeFundingSteps: (FundingRouteStep | undefined)[], + swapFundingSteps: FundingRouteStep[], + l1tol2Addresses: L1ToL2TokenAddressMapping[], +): BridgeAndSwapRoute[] => { + const bridgeAndSwapRoutes: BridgeAndSwapRoute[] = []; + + for (const bridgeFundingStep of bridgeFundingSteps) { + if (!bridgeFundingStep) continue; + const mapping = l1tol2Addresses.find( + (addresses) => { + if (bridgeFundingStep.asset.token.address === undefined) { + return addresses.l1address === INDEXER_ETH_ROOT_CONTRACT_ADDRESS && addresses.l2address; + } + return addresses.l1address === bridgeFundingStep.asset.token.address && addresses.l2address; + }, + ); + if (!mapping) continue; + + const swapFundingStep = swapFundingSteps.find( + (step) => step.asset.token.address === mapping.l2address, + ); + if (!swapFundingStep) continue; + + bridgeAndSwapRoutes.push({ + bridgeFundingStep, + swapFundingStep, + }); + } + + return bridgeAndSwapRoutes; +}; + +export type BridgeAndSwapRoute = { + bridgeFundingStep: FundingRouteStep, + swapFundingStep: FundingRouteStep, +}; +export const bridgeAndSwapRoute = async ( + config: CheckoutConfiguration, + readOnlyProviders: Map, + availableRoutingOptions: RoutingOptionsAvailable, + insufficientRequirement: BalanceNativeRequirement | BalanceERC20Requirement, + dexQuoteCache: DexQuoteCache, + ownerAddress: string, + feeEstimates: Map, + tokenBalances: Map, + bridgeableTokens: string[], + swappableTokens: TokenInfo[], + balanceRequirements: BalanceCheckResult, +): Promise => { + const { l1balances, l2balances } = getBalancesByChain(config, tokenBalances); + const requiredTokenAddress = insufficientRequirement.required.token.address; + + if (abortBridgeAndSwap( + bridgeableTokens, + swappableTokens, + l1balances, + l2balances, + availableRoutingOptions, + requiredTokenAddress, + )) return []; + + // Fetch L2 to L1 address mapping and based on the L1 address existing then + // filter the bridgeable and swappable tokens list further to only include + // tokens that can be both swapped and bridged + const l1tol2Addresses = await fetchL1ToL2Mappings(config, swappableTokens); + const filteredSwappableTokens = filterSwappableTokensByBridgeableAddresses( + requiredTokenAddress as string, + bridgeableTokens, + swappableTokens, + l1tol2Addresses, + ); + + if (filteredSwappableTokens.length === 0) return []; + + // Fetch all the dex quotes from the list of swappable tokens + const dexQuotes = await getDexQuotes( + config, + dexQuoteCache, + ownerAddress, + requiredTokenAddress as string, + insufficientRequirement, + filteredSwappableTokens, + ); + + // Construct bridge requirements based on L2 balances, slippage and swap fees + const bridgeRequirements = constructBridgeRequirements( + dexQuotes, + l1balances, + l2balances, + l1tol2Addresses, + balanceRequirements, + ); + if (bridgeRequirements.length === 0) return []; + + // Create a mapping of bridge routes to L2 addresses + const bridgePromises = new Map>(); + // Create map of bridgeable tokens to make it easier to get the amount that was bridged when modifying the users balance later + const bridgeableRequirementsMap = new Map(); + const bridgedTokens: BridgeRequirement[] = []; + for (const bridgeRequirement of bridgeRequirements) { + if (!bridgeRequirement.l2address) continue; + bridgePromises.set( + bridgeRequirement.l2address, + bridgeRoute( + config, + readOnlyProviders, + ownerAddress, + availableRoutingOptions, + bridgeRequirement, + tokenBalances, + feeEstimates, + ), + ); + bridgeableRequirementsMap.set(bridgeRequirement.l2address, { + amount: bridgeRequirement.amount, + formattedAmount: bridgeRequirement.formattedAmount, + l2address: bridgeRequirement.l2address, + }); + } + + const bridgeResults = await Promise.all(bridgePromises.values()); + const bridgeKeys = Array.from(bridgePromises.keys()); + + // Create an array to store all the tokens that are able to be bridged + const swappableTokensAfterBridging: string[] = []; + + // Iterate through all the bridge route results + // If a bridge route result was successful then add this token to the + // list of tokens that should be checked with the swap route + bridgeResults.forEach((result, index) => { + const key = bridgeKeys[index]; + if (result === undefined) return; + swappableTokensAfterBridging.push(key); + + const bridgedToken = bridgeableRequirementsMap.get(key); + if (!bridgedToken) return; + bridgedTokens.push({ + amount: bridgedToken.amount, + formattedAmount: bridgedToken.formattedAmount, + l2address: bridgedToken.l2address, + }); + }); + + // Bridge route determined that no tokens could be bridged + if (swappableTokensAfterBridging.length === 0) return []; + if (bridgedTokens.length === 0) return []; // No tokens were bridged + + // Modify the users L2 balance to include the amount as if the user successfully bridged + const modifiedTokenBalances = modifyTokenBalancesWithBridgedAmount( + config, + tokenBalances, + l2balances, + bridgedTokens, + swappableTokens, + ); + + // Call the swap route with the faked bridged balances + const swapRoutes = await swapRoute( + config, + availableRoutingOptions, + dexQuoteCache, + ownerAddress, + insufficientRequirement, + modifiedTokenBalances, + swappableTokensAfterBridging, + ); + if (!swapRoutes) return []; + const originalBalanceSwapRoutes = reapplyOriginalSwapBalances( + tokenBalances, + swapRoutes, + ); + + return constructBridgeAndSwapRoutes( + bridgeResults, + originalBalanceSwapRoutes, + l1tol2Addresses, + ); +}; diff --git a/packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/constructBridgeRequirements.test.ts b/packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/constructBridgeRequirements.test.ts new file mode 100644 index 0000000000..09bc3910f7 --- /dev/null +++ b/packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/constructBridgeRequirements.test.ts @@ -0,0 +1,550 @@ +import { BigNumber } from 'ethers'; +import { TokenInfo as DexTokenInfo } from '@imtbl/dex-sdk'; +import { DexQuote } from '../types'; +import { + ChainId, + GetBalanceResult, + ItemType, + TokenInfo, +} from '../../../types'; +import { constructBridgeRequirements } from './constructBridgeRequirements'; +import { BalanceRequirement } from '../../balanceCheck/types'; +import { INDEXER_ETH_ROOT_CONTRACT_ADDRESS } from '../indexer/fetchL1Representation'; + +describe('constructBridgeRequirements', () => { + const constructDexQuote = ( + swapTokenInfo: DexTokenInfo, + feesTokenInfo: DexTokenInfo, + quoteAmount: number, + slippageQuoteAmount: number, + feeAmount: number, + swap?: number, + approval?: number, + ) => { + const dexQuote: DexQuote = { + approval: undefined, + swap: null, + quote: { + amount: { + value: BigNumber.from(quoteAmount), + token: swapTokenInfo, + }, + amountWithMaxSlippage: { + value: BigNumber.from(slippageQuoteAmount), + token: swapTokenInfo, + }, + slippage: 1, + fees: [ + { + amount: { + value: BigNumber.from(feeAmount), + token: feesTokenInfo, + }, + recipient: '', + basisPoints: 0, + }, + ], + }, + }; + + if (swap) { + dexQuote.swap = { + value: BigNumber.from(swap), + token: swapTokenInfo, + }; + } + + if (approval) { + dexQuote.approval = { + value: BigNumber.from(approval), + token: feesTokenInfo, + }; + } + + return dexQuote; + }; + + it('should construct the bridge requirements', () => { + const swapTokenInfoA: DexTokenInfo = { + chainId: 1, + address: '0xERC20A', + decimals: 18, + symbol: 'ERC20', + name: 'ERC20', + }; + + const swapTokenInfoB: DexTokenInfo = { + chainId: ChainId.IMTBL_ZKEVM_TESTNET, + address: '0xERC20B', + decimals: 18, + symbol: 'ERC20', + name: 'ERC20', + }; + + const feesTokenInfo: DexTokenInfo = { + chainId: ChainId.IMTBL_ZKEVM_TESTNET, + address: '0xERC20', + decimals: 18, + symbol: 'ERC20', + name: 'ERC20', + }; + + const dexQuoteA = constructDexQuote( + swapTokenInfoA, + feesTokenInfo, + 100000000000000, + 200000000000000, + 300000000000000, + 400000000000000, + 500000000000000, + ); + + const dexQuoteB = constructDexQuote( + swapTokenInfoB, + feesTokenInfo, + 600000000000000, + 700000000000000, + 800000000000000, + 900000000000000, + 110000000000000, + ); + + const dexQuotes = new Map([ + ['0xERC20A', dexQuoteA], + ['0xERC20B', dexQuoteB], + ]); + + const l1balances: GetBalanceResult[] = [ + { + token: { + address: 'ERC20AL1', + decimals: 18, + } as TokenInfo, + balance: BigNumber.from(5000000000000000), + formattedBalance: '5', + }, + { + token: { + address: 'ERC20BL1', + decimals: 18, + } as TokenInfo, + balance: BigNumber.from(6000000000000000), + formattedBalance: '6', + }, + ]; + + const l2balances: GetBalanceResult[] = []; + + const requirements = constructBridgeRequirements( + dexQuotes, + l1balances, + l2balances, + [ + { + l1address: 'ERC20AL1', + l2address: '0xERC20A', + }, + { + l1address: 'ERC20BL1', + l2address: '0xERC20B', + }, + ], + { + sufficient: false, + balanceRequirements: [ + { + type: ItemType.ERC20, + sufficient: false, + required: { + type: ItemType.ERC20, + balance: BigNumber.from(1), + formattedBalance: '1', + token: { + address: '0xERC20C', + } as TokenInfo, + }, + }, + ] as BalanceRequirement[], + }, + ); + + expect(requirements).toEqual( + [ + { + amount: BigNumber.from(200000000000000), + formattedAmount: '0.0002', + l2address: '0xERC20A', + }, + { + amount: BigNumber.from(700000000000000), + formattedAmount: '0.0007', + l2address: '0xERC20B', + }, + ], + ); + }); + + it('should handle native L1', () => { + const swapTokenETH: DexTokenInfo = { + chainId: ChainId.IMTBL_ZKEVM_TESTNET, + address: '0xETH', + decimals: 18, + symbol: 'ETH', + name: 'ETH', + }; + + const feesTokenInfo: DexTokenInfo = { + chainId: ChainId.IMTBL_ZKEVM_TESTNET, + address: '0xIMX', + decimals: 18, + symbol: 'IMX', + name: 'IMX', + }; + + const ethDexQuote = constructDexQuote( + swapTokenETH, + feesTokenInfo, + 100000000000000, + 200000000000000, + 300000000000000, + 400000000000000, + 500000000000000, + ); + + const dexQuotes = new Map([ + ['0xETH', ethDexQuote], + ]); + + const l1balances: GetBalanceResult[] = [ + { + token: { + decimals: 18, + } as TokenInfo, + balance: BigNumber.from(300000000000000), + formattedBalance: '3', + }, + ]; + + const l2balances: GetBalanceResult[] = []; + + const requirements = constructBridgeRequirements( + dexQuotes, + l1balances, + l2balances, + [ + { + l1address: INDEXER_ETH_ROOT_CONTRACT_ADDRESS, + l2address: '0xETH', + }, + ], + { + sufficient: false, + balanceRequirements: [ + { + type: ItemType.ERC20, + sufficient: false, + required: { + type: ItemType.ERC20, + balance: BigNumber.from(1), + formattedBalance: '1', + token: { + address: '0xERC', + } as TokenInfo, + }, + }, + ] as BalanceRequirement[], + }, + ); + + expect(requirements).toEqual( + [ + { + amount: BigNumber.from(200000000000000), + formattedAmount: '0.0002', + l2address: '0xETH', + }, + ], + ); + }); + + it('should not return bridge requirement if not enough balance on l1', () => { + const swapTokenInfoA: DexTokenInfo = { + chainId: 1, + address: '0xERC20A', + decimals: 18, + symbol: 'ERC20', + name: 'ERC20', + }; + + const swapTokenInfoB: DexTokenInfo = { + chainId: ChainId.IMTBL_ZKEVM_TESTNET, + address: '0xERC20B', + decimals: 18, + symbol: 'ERC20', + name: 'ERC20', + }; + + const feesTokenInfo: DexTokenInfo = { + chainId: ChainId.IMTBL_ZKEVM_TESTNET, + address: '0xERC20', + decimals: 18, + symbol: 'ERC20', + name: 'ERC20', + }; + + const dexQuoteA = constructDexQuote( + swapTokenInfoA, + feesTokenInfo, + 100000000000000, + 200000000000000, + 300000000000000, + 400000000000000, + 500000000000000, + ); + + const dexQuoteB = constructDexQuote( + swapTokenInfoB, + feesTokenInfo, + 600000000000000, + 700000000000000, + 800000000000000, + 900000000000000, + 110000000000000, + ); + + const dexQuotes = new Map([ + ['0xERC20A', dexQuoteA], + ['0xERC20B', dexQuoteB], + ]); + + const l1balances: GetBalanceResult[] = [ + { + token: { + address: 'ERC20AL1', + decimals: 18, + } as TokenInfo, + balance: BigNumber.from(500000000000000), + formattedBalance: '1', + }, + { + token: { + address: 'ERC20BL1', + decimals: 18, + } as TokenInfo, + // Not enough balance to bridge this ERC20 + balance: BigNumber.from(100000000000000), + formattedBalance: '1', + }, + ]; + + const l2balances: GetBalanceResult[] = []; + + const requirements = constructBridgeRequirements( + dexQuotes, + l1balances, + l2balances, + [ + { + l1address: 'ERC20AL1', + l2address: '0xERC20A', + }, + { + l1address: 'ERC20BL1', + l2address: '0xERC20B', + }, + ], + { + sufficient: false, + balanceRequirements: [ + { + type: ItemType.ERC20, + sufficient: false, + required: { + type: ItemType.ERC20, + balance: BigNumber.from(1), + formattedBalance: '1', + token: { + address: '0xERC20C', + } as TokenInfo, + }, + }, + ] as BalanceRequirement[], + }, + ); + + expect(requirements).toEqual( + [ + { + amount: BigNumber.from(200000000000000), + formattedAmount: '0.0002', + l2address: '0xERC20A', + }, + ], + ); + }); + + it('should add fees and balance requirement if they are same as token address and remove l2 balance', () => { + const swapTokenInfo: DexTokenInfo = { + chainId: 1, + address: '0xIMX', + decimals: 18, + symbol: '0xIMX', + name: '0xIMX', + }; + + const feesTokenInfo: DexTokenInfo = { + chainId: ChainId.IMTBL_ZKEVM_TESTNET, + address: '0xIMX', + decimals: 18, + symbol: '0xIMX', + name: '0xIMX', + }; + + const dexQuote = constructDexQuote( + swapTokenInfo, + feesTokenInfo, + 100000000000000, + 200000000000000, + 300000000000000, + 400000000000000, + 500000000000000, + ); + + const dexQuotes = new Map([ + ['0xIMX', dexQuote], + ]); + + const l1balances: GetBalanceResult[] = [ + { + token: { + address: '0xIMXL1', + decimals: 18, + } as TokenInfo, + balance: BigNumber.from(5000000000000000), + formattedBalance: '5', + }, + ]; + + const l2balances: GetBalanceResult[] = [ + { + token: { + address: '0xIMX', + decimals: 18, + } as TokenInfo, + balance: BigNumber.from(10000000000000), + formattedBalance: '0.1', + }, + ]; + + const requirements = constructBridgeRequirements( + dexQuotes, + l1balances, + l2balances, + [ + { + l1address: '0xIMXL1', + l2address: '0xIMX', + }, + ], + { + sufficient: false, + balanceRequirements: [ + { + type: ItemType.NATIVE, + sufficient: false, + required: { + type: ItemType.NATIVE, + balance: BigNumber.from(120000000000000), + formattedBalance: '1', + token: { + address: '0xIMX', + } as TokenInfo, + }, + }, + ] as BalanceRequirement[], + }, + ); + + expect(requirements).toEqual( + [ + { + amount: BigNumber.from(1110000000000000), + formattedAmount: '0.00111', + l2address: '0xIMX', + }, + ], + ); + }); + + it('should not return a requirement if amount to bridge is 0 due to sufficient l2 balance', () => { + const swapTokenInfo: DexTokenInfo = { + chainId: 1, + address: '0xL2', + decimals: 18, + symbol: '0xL2', + name: '0xL2', + }; + + const feesTokenInfo: DexTokenInfo = { + chainId: ChainId.IMTBL_ZKEVM_TESTNET, + address: '0xIMX', + decimals: 18, + symbol: '0xIMX', + name: '0xIMX', + }; + + const dexQuote = constructDexQuote( + swapTokenInfo, + feesTokenInfo, + 100000000000000, + 200000000000000, + 300000000000000, + 400000000000000, + 500000000000000, + ); + + const dexQuotes = new Map([ + ['0xL2', dexQuote], + ]); + + const l1balances: GetBalanceResult[] = [ + { + token: { + address: '0xL1', + decimals: 18, + } as TokenInfo, + balance: BigNumber.from(100000000000000), + formattedBalance: '1', + }, + ]; + + const l2balances: GetBalanceResult[] = [ + { + token: { + address: '0xL2', + decimals: 18, + } as TokenInfo, + balance: BigNumber.from(9000000000000000), + formattedBalance: '1', + }, + ]; + + const requirements = constructBridgeRequirements( + dexQuotes, + l1balances, + l2balances, + [ + { + l1address: '0xL1', + l2address: '0xL2', + }, + ], + { + sufficient: false, + balanceRequirements: [] as BalanceRequirement[], + }, + ); + + expect(requirements).toEqual([]); + }); +}); diff --git a/packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/constructBridgeRequirements.ts b/packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/constructBridgeRequirements.ts new file mode 100644 index 0000000000..fa681d9f64 --- /dev/null +++ b/packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/constructBridgeRequirements.ts @@ -0,0 +1,117 @@ +import { BigNumber, utils } from 'ethers'; +import { GetBalanceResult, ItemType } from '../../../types'; +import { BridgeRequirement } from '../bridge/bridgeRoute'; +import { DexQuote, DexQuotes } from '../types'; +import { INDEXER_ETH_ROOT_CONTRACT_ADDRESS, L1ToL2TokenAddressMapping } from '../indexer/fetchL1Representation'; +import { BalanceCheckResult } from '../../balanceCheck/types'; + +// The dex will return all the fees which is in a particular token (currently always IMX) +// If any of the fees are in the same token that is trying to be swapped (e.g. trying to swap IMX) +// then these fees need to be added to the amount to bridge, otherwise not enough of the token +// will be bridged over to cover the amount to swap and any fees associated with the swap +export const getFeesForTokenAddress = ( + dexQuote: DexQuote, + tokenAddress: string, +): BigNumber => { + let fees = BigNumber.from(0); + + dexQuote.quote.fees.forEach((fee) => { + if (fee.amount.token.address === tokenAddress) { + fees = fees.add(fee.amount.value); + } + }); + + if (dexQuote.approval) { + if (dexQuote.approval.token.address === tokenAddress) { + fees = fees.add(dexQuote.approval.value); + } + } + + return fees; +}; + +// The token that is being bridged may also be a balance requirement +// Since this token is going to be swapped after bridging then get the +// balance requirement amount so this can be considered when bridging +// enough of the token across +const getAmountFromBalanceRequirement = ( + balanceRequirements: BalanceCheckResult, + tokenAddress: string, +): BigNumber => { + let amount = BigNumber.from(0); + + balanceRequirements.balanceRequirements.forEach((requirement) => { + if (requirement.type === ItemType.NATIVE || requirement.type === ItemType.ERC20) { + if (requirement.required.token.address === tokenAddress) { + amount = amount.add(requirement.required.balance); + } + } + }); + + return amount; +}; + +// to be sent to the bridge route +export const constructBridgeRequirements = ( + dexQuotes: DexQuotes, + l1balances: GetBalanceResult[], + l2balances: GetBalanceResult[], + l1tol2addresses: L1ToL2TokenAddressMapping[], + balanceRequirements: BalanceCheckResult, +): BridgeRequirement[] => { + const bridgeRequirements: BridgeRequirement[] = []; + + for (const [tokenAddress, quote] of dexQuotes) { + // Get the L2 balance for the token address + const l2balance = l2balances.find((balance) => balance.token.address === tokenAddress); + const l1tol2TokenMapping = l1tol2addresses.find( + (token) => token.l2address === tokenAddress, + ); + if (!l1tol2TokenMapping) continue; + + const { l1address, l2address } = l1tol2TokenMapping; + if (!l1address) continue; + + // If the user does not have any L1 balance for this token then cannot bridge + const l1balance = l1balances.find((balance) => { + if (balance.token.address === undefined + && l1address === INDEXER_ETH_ROOT_CONTRACT_ADDRESS) { + return true; + } + return balance.token.address === l1address; + }); + if (!l1balance) continue; + + // Get the total amount using slippage to ensure a small buffer is added to cover price fluctuations + const quotedAmount = quote.quote.amountWithMaxSlippage.value; + // Add fees to the quoted amount if the fees are in the same token as the token being swapped + const fees = getFeesForTokenAddress(quote, tokenAddress); + // If the token being bridged is a balance requirement then add this to the total + // to ensure when its swapped the balance requirement can still be fulfilled + const balanceRequirementAmount = getAmountFromBalanceRequirement(balanceRequirements, tokenAddress); + // Add all the amounts together + const totalAmount = quotedAmount.add(fees).add(balanceRequirementAmount); + + // Subtract any L2 balance from the total amount to only bridge the necessary amount + const amountToBridge = l2balance ? totalAmount.sub(l2balance.balance) : totalAmount; + + if (amountToBridge.lte(0)) { + // If the amount to bridge is 0 then the user already has sufficient L2 balance to swap without bridging + // In this scenario the swap route will be recommended by the router and no bridging is required + continue; + } + + if (amountToBridge.gt(l1balance.balance)) { + continue; + } + + bridgeRequirements.push({ + amount: amountToBridge, + formattedAmount: utils.formatUnits(amountToBridge, l1balance.token.decimals), + // L2 address is used for the bridge requirement as the bridge route uses the indexer to find L1 address + l2address, + }); + } + + return bridgeRequirements; +}; diff --git a/packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/fetchL1ToL2Mapping.test.ts b/packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/fetchL1ToL2Mapping.test.ts new file mode 100644 index 0000000000..b4047b6219 --- /dev/null +++ b/packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/fetchL1ToL2Mapping.test.ts @@ -0,0 +1,64 @@ +import { Environment } from '@imtbl/config'; +import { CheckoutConfiguration } from '../../../config'; +import { fetchL1Representation } from '../indexer/fetchL1Representation'; +import { fetchL1ToL2Mappings } from './fetchL1ToL2Mappings'; + +jest.mock('../indexer/fetchL1Representation'); + +describe('fetchL1ToL2Mappings', () => { + const config = new CheckoutConfiguration({ + baseConfig: { environment: Environment.SANDBOX }, + }); + + it('should fetch the l1 to l2 token mapping', async () => { + (fetchL1Representation as jest.Mock) + .mockResolvedValueOnce( + { + l1address: '0xIMXL1', + l2address: '0xIMX', + }, + ) + .mockResolvedValueOnce( + { + l1address: '0xYEETL1', + l2address: '0xYEET', + }, + ); + + const mapping = await fetchL1ToL2Mappings(config, [ + { + address: '0xIMX', + name: 'IMX', + symbol: 'IMX', + decimals: 18, + }, + { + address: '0xYEET', + name: 'zkYEET', + symbol: 'zkYEET', + decimals: 18, + }, + ]); + + expect(fetchL1Representation).toHaveBeenCalledTimes(2); + expect(mapping).toEqual([ + { + l1address: '0xIMXL1', + l2address: '0xIMX', + }, + { + l1address: '0xYEETL1', + l2address: '0xYEET', + }, + ]); + }); + + it('should return an empty array if no swappable l2 addresses', async () => { + (fetchL1Representation as jest.Mock).mockResolvedValue({}); + + const mapping = await fetchL1ToL2Mappings(config, []); + + expect(fetchL1Representation).toHaveBeenCalledTimes(0); + expect(mapping).toEqual([]); + }); +}); diff --git a/packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/fetchL1ToL2Mappings.ts b/packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/fetchL1ToL2Mappings.ts new file mode 100644 index 0000000000..53f9c5f25c --- /dev/null +++ b/packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/fetchL1ToL2Mappings.ts @@ -0,0 +1,13 @@ +import { CheckoutConfiguration } from '../../../config'; +import { TokenInfo } from '../../../types'; +import { L1ToL2TokenAddressMapping, fetchL1Representation } from '../indexer/fetchL1Representation'; + +export const fetchL1ToL2Mappings = async ( + config: CheckoutConfiguration, + swappableTokens: TokenInfo[], +): Promise => { + const l1tol2addressMappingPromises = swappableTokens.map( + (token) => fetchL1Representation(config, token.address ?? ''), + ); + return await Promise.all(l1tol2addressMappingPromises); +}; diff --git a/packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/getBalancesByChain.test.ts b/packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/getBalancesByChain.test.ts new file mode 100644 index 0000000000..3ed4bf6fa2 --- /dev/null +++ b/packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/getBalancesByChain.test.ts @@ -0,0 +1,226 @@ +import { BigNumber } from 'ethers'; +import { Environment } from '@imtbl/config'; +import { ChainId } from '../../../types'; +import { getBalancesByChain } from './getBalancesByChain'; +import { CheckoutConfiguration } from '../../../config'; + +describe('getBalancesByChain', () => { + const config = new CheckoutConfiguration({ + baseConfig: { environment: Environment.SANDBOX }, + }); + + const l1balances = { + success: true, + balances: [ + { + balance: BigNumber.from(1), + formattedBalance: '1', + token: { + decimals: 18, + symbol: 'ETH', + name: 'ETH', + }, + }, + { + balance: BigNumber.from(2), + formattedBalance: '2', + token: { + address: '0xIMX', + decimals: 18, + symbol: 'IMX', + name: 'IMX', + }, + }, + ], + }; + + const l2balances = { + success: true, + balances: [ + { + balance: BigNumber.from(3), + formattedBalance: '3', + token: { + address: '0xIMX', + decimals: 18, + symbol: 'IMX', + name: 'IMX', + }, + }, + { + balance: BigNumber.from(4), + formattedBalance: '4', + token: { + address: '0xYEET', + decimals: 18, + symbol: 'zkYEET', + name: 'zkYEET', + }, + }, + ], + }; + + it('should return l1balances and l2balances', () => { + const tokenBalances = new Map(); + tokenBalances.set(ChainId.SEPOLIA, l1balances); + + tokenBalances.set(ChainId.IMTBL_ZKEVM_TESTNET, l2balances); + + const result = getBalancesByChain(config, tokenBalances); + expect(result).toEqual( + { + l1balances: [ + { + balance: BigNumber.from(1), + formattedBalance: '1', + token: { + decimals: 18, + symbol: 'ETH', + name: 'ETH', + }, + }, + { + balance: BigNumber.from(2), + formattedBalance: '2', + token: { + address: '0xIMX', + decimals: 18, + symbol: 'IMX', + name: 'IMX', + }, + }, + ], + l2balances: [ + { + balance: BigNumber.from(3), + formattedBalance: '3', + token: { + address: '0xIMX', + decimals: 18, + symbol: 'IMX', + name: 'IMX', + }, + }, + { + balance: BigNumber.from(4), + formattedBalance: '4', + token: { + address: '0xYEET', + decimals: 18, + symbol: 'zkYEET', + name: 'zkYEET', + }, + }, + ], + }, + ); + }); + + it('should return empty arrays if no l1balances', () => { + const tokenBalances = new Map(); + tokenBalances.set(ChainId.IMTBL_ZKEVM_TESTNET, l2balances); + + const result = getBalancesByChain(config, tokenBalances); + expect(result).toEqual( + { + l1balances: [], + l2balances: [], + }, + ); + }); + + it('should return empty arrays if l1balances undefined', () => { + const tokenBalances = new Map(); + tokenBalances.set(ChainId.SEPOLIA, undefined); + tokenBalances.set(ChainId.IMTBL_ZKEVM_TESTNET, l2balances); + + const result = getBalancesByChain(config, tokenBalances); + expect(result).toEqual( + { + l1balances: [], + l2balances: [], + }, + ); + }); + + it('should return empty arrays if l1balances has an error', () => { + const tokenBalances = new Map(); + tokenBalances.set(ChainId.SEPOLIA, { + error: new Error('error'), + balances: [], + }); + tokenBalances.set(ChainId.IMTBL_ZKEVM_TESTNET, l2balances); + + const result = getBalancesByChain(config, tokenBalances); + expect(result).toEqual( + { + l1balances: [], + l2balances: [], + }, + ); + }); + + it('should return empty arrays if l1balances failed', () => { + const tokenBalances = new Map(); + tokenBalances.set(ChainId.SEPOLIA, { + success: false, + balances: [], + }); + tokenBalances.set(ChainId.IMTBL_ZKEVM_TESTNET, l2balances); + + const result = getBalancesByChain(config, tokenBalances); + expect(result).toEqual( + { + l1balances: [], + l2balances: [], + }, + ); + }); + + it('should return empty arrays if no l2balances', () => { + const tokenBalances = new Map(); + tokenBalances.set(ChainId.SEPOLIA, l1balances); + + const result = getBalancesByChain(config, tokenBalances); + expect(result).toEqual( + { + l1balances: [], + l2balances: [], + }, + ); + }); + + it('should return empty arrays if l2balances has an error', () => { + const tokenBalances = new Map(); + tokenBalances.set(ChainId.IMTBL_ZKEVM_TESTNET, { + error: new Error('error'), + balances: [], + }); + tokenBalances.set(ChainId.SEPOLIA, l1balances); + + const result = getBalancesByChain(config, tokenBalances); + expect(result).toEqual( + { + l1balances: [], + l2balances: [], + }, + ); + }); + + it('should return empty arrays if l2balances failed', () => { + const tokenBalances = new Map(); + tokenBalances.set(ChainId.IMTBL_ZKEVM_TESTNET, { + success: false, + balances: [], + }); + tokenBalances.set(ChainId.SEPOLIA, l1balances); + + const result = getBalancesByChain(config, tokenBalances); + expect(result).toEqual( + { + l1balances: [], + l2balances: [], + }, + ); + }); +}); diff --git a/packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/getBalancesByChain.ts b/packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/getBalancesByChain.ts new file mode 100644 index 0000000000..4495b503a7 --- /dev/null +++ b/packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/getBalancesByChain.ts @@ -0,0 +1,31 @@ +import { CheckoutConfiguration, getL1ChainId, getL2ChainId } from '../../../config'; +import { ChainId, GetBalanceResult } from '../../../types'; +import { TokenBalanceResult } from '../types'; + +export const getBalancesByChain = ( + config: CheckoutConfiguration, + tokenBalances: Map, +): { + l1balances: GetBalanceResult[], + l2balances: GetBalanceResult[], +} => { + const balances = { l1balances: [], l2balances: [] }; + + const l1balancesResult = tokenBalances.get(getL1ChainId(config)); + const l2balancesResult = tokenBalances.get(getL2ChainId(config)); + + // If there are no l1 balance then cannot bridge + if (!l1balancesResult) return balances; + if (l1balancesResult.error !== undefined) return balances; + if (!l1balancesResult.success) return balances; + + // If there are no l2 balance then cannot swap + if (!l2balancesResult) return balances; + if (l2balancesResult.error !== undefined) return balances; + if (!l2balancesResult.success) return balances; + + const l1balances = l1balancesResult.balances; + const l2balances = l2balancesResult.balances; + + return { l1balances, l2balances }; +}; diff --git a/packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/getDexQuotes.test.ts b/packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/getDexQuotes.test.ts new file mode 100644 index 0000000000..b2c73fd6f2 --- /dev/null +++ b/packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/getDexQuotes.test.ts @@ -0,0 +1,60 @@ +import { Environment } from '@imtbl/config'; +import { BigNumber } from 'ethers'; +import { CheckoutConfiguration } from '../../../config'; +import { getDexQuotes } from './getDexQuotes'; +import { BalanceNativeRequirement } from '../../balanceCheck/types'; +import { DexQuoteCache } from '../types'; +import { getOrSetQuotesFromCache } from '../swap/dexQuoteCache'; + +jest.mock('../swap/dexQuoteCache'); + +describe('getDexQuotes', () => { + const config = new CheckoutConfiguration({ + baseConfig: { environment: Environment.SANDBOX }, + }); + + it('should send token addresses to getOrSetQuotesFromCache', async () => { + (getOrSetQuotesFromCache as jest.Mock).mockResolvedValue({}); + + await getDexQuotes( + config, + {} as DexQuoteCache, + '0xOWNER', + '0xREQUIRED', + { + delta: { + balance: BigNumber.from(1), + }, + } as BalanceNativeRequirement, + [ + { + address: '0xIMX', + name: 'IMX', + symbol: 'IMX', + decimals: 18, + }, + { + address: '0xYEET', + name: 'zkYEET', + symbol: 'zkYEET', + decimals: 18, + }, + ], + ); + + expect(getOrSetQuotesFromCache).toBeCalledTimes(1); + expect(getOrSetQuotesFromCache).toBeCalledWith( + config, + {}, + '0xOWNER', + { + address: '0xREQUIRED', + amount: BigNumber.from(1), + }, + [ + '0xIMX', + '0xYEET', + ], + ); + }); +}); diff --git a/packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/getDexQuotes.ts b/packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/getDexQuotes.ts new file mode 100644 index 0000000000..1a390a9ff9 --- /dev/null +++ b/packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/getDexQuotes.ts @@ -0,0 +1,34 @@ +import { CheckoutConfiguration } from '../../../config'; +import { getOrSetQuotesFromCache } from '../swap/dexQuoteCache'; +import { DexQuotes, DexQuoteCache } from '../types'; +import { BalanceNativeRequirement, BalanceERC20Requirement } from '../../balanceCheck/types'; +import { TokenInfo } from '../../../types'; + +// Fetch all the dex quotes from the list of swappable tokens +export const getDexQuotes = async ( + config: CheckoutConfiguration, + dexQuoteCache: DexQuoteCache, + ownerAddress: string, + requiredTokenAddress: string, + insufficientRequirement: BalanceNativeRequirement | BalanceERC20Requirement, + filteredSwappableTokens: TokenInfo[], +): Promise => { + const filteredSwappableTokensAddresses: string[] = []; + for (const token of filteredSwappableTokens) { + if (!token.address) continue; + filteredSwappableTokensAddresses.push(token.address); + } + + const dexQuotes = await getOrSetQuotesFromCache( + config, + dexQuoteCache, + ownerAddress, + { + address: requiredTokenAddress as string, + amount: insufficientRequirement.delta.balance, + }, + filteredSwappableTokensAddresses, + ); + + return dexQuotes; +}; diff --git a/packages/checkout/sdk/src/smartCheckout/routing/indexer/fetchL1Representation.test.ts b/packages/checkout/sdk/src/smartCheckout/routing/indexer/fetchL1Representation.test.ts new file mode 100644 index 0000000000..04a0b33b52 --- /dev/null +++ b/packages/checkout/sdk/src/smartCheckout/routing/indexer/fetchL1Representation.test.ts @@ -0,0 +1,97 @@ +import { Environment } from '@imtbl/config'; +import { CheckoutConfiguration } from '../../../config'; +import { createBlockchainDataInstance } from '../../../instance'; +import { ChainId, IMX_ADDRESS_ZKEVM } from '../../../types'; +import { fetchL1Representation } from './fetchL1Representation'; + +jest.mock('../../../instance'); + +describe('fetchL1Representation', () => { + const config = new CheckoutConfiguration({ + baseConfig: { environment: Environment.SANDBOX }, + }); + + it('should fetch L1 representation of ERC20', async () => { + const requiredL2Address = '0x123'; + (createBlockchainDataInstance as jest.Mock).mockReturnValue({ + getToken: jest.fn().mockResolvedValue({ + result: { + // eslint-disable-next-line @typescript-eslint/naming-convention + root_contract_address: '0xROOT_ADDRESS', + }, + }), + }); + + const result = await fetchL1Representation( + config, + requiredL2Address, + ); + + expect(result).toEqual({ l1address: '0xROOT_ADDRESS', l2address: '0x123' }); + }); + + it('should fetch L1 representation of NATIVE', async () => { + const requiredL2Address = IMX_ADDRESS_ZKEVM; + (createBlockchainDataInstance as jest.Mock).mockReturnValue({ + getToken: jest.fn().mockResolvedValue({}), + }); + + const result = await fetchL1Representation( + { + remote: { + getConfig: jest.fn().mockResolvedValue({ + [ChainId.SEPOLIA]: '0x2Fa06C6672dDCc066Ab04631192738799231dE4a', + }), + }, + } as unknown as CheckoutConfiguration, + requiredL2Address, + ); + + expect(result).toEqual( + { + l1address: '0x2Fa06C6672dDCc066Ab04631192738799231dE4a', + l2address: '0x0000000000000000000000000000000000001010', + }, + ); + expect(createBlockchainDataInstance).not.toHaveBeenCalled(); + }); + + it('should return empty string if indexer returns null', async () => { + const requiredL2Address = '0x123'; + (createBlockchainDataInstance as jest.Mock).mockReturnValue({ + getToken: jest.fn().mockResolvedValue({ + result: { + // eslint-disable-next-line @typescript-eslint/naming-convention + root_contract_address: null, + }, + }), + }); + + const result = await fetchL1Representation( + config, + requiredL2Address, + ); + + expect(result).toEqual({ l1address: '', l2address: '0x123' }); + }); + + it('should return empty string if L2 address is empty', async () => { + const requiredL2Address = ''; + (createBlockchainDataInstance as jest.Mock).mockReturnValue({ + getToken: jest.fn().mockResolvedValue({ + result: { + // eslint-disable-next-line @typescript-eslint/naming-convention + root_contract_address: '0xROOT_ADDRESS', + }, + }), + }); + + const result = await fetchL1Representation( + config, + requiredL2Address, + ); + + expect(result).toEqual({ l1address: '', l2address: '' }); + expect(createBlockchainDataInstance).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/checkout/sdk/src/smartCheckout/routing/indexer/fetchL1Representation.ts b/packages/checkout/sdk/src/smartCheckout/routing/indexer/fetchL1Representation.ts new file mode 100644 index 0000000000..88eeb5b606 --- /dev/null +++ b/packages/checkout/sdk/src/smartCheckout/routing/indexer/fetchL1Representation.ts @@ -0,0 +1,55 @@ +import { CheckoutConfiguration, getL1ChainId, getL2ChainId } from '../../../config'; +import { createBlockchainDataInstance } from '../../../instance'; +import { ChainId, IMX_ADDRESS_ZKEVM, ImxAddressConfig } from '../../../types'; + +// If the root address evaluates to this then its ETH +export const INDEXER_ETH_ROOT_CONTRACT_ADDRESS = '0x0000000000000000000000000000000000000001'; + +export const getIndexerChainName = (chainId: ChainId): string => { + if (chainId === ChainId.IMTBL_ZKEVM_TESTNET) return 'imtbl-zkevm-testnet'; + return ''; +}; + +// Indexer ERC20 call does not support IMX so cannot get root chain mapping from this endpoint. +// Use the remote config instead to find IMX address mapping. +export const getImxL1Representation = async (chainId: ChainId, config: CheckoutConfiguration): Promise => { + const imxMappingConfig = (await config.remote.getConfig( + 'imxAddressMapping', + )) as ImxAddressConfig; + + return imxMappingConfig[chainId] ?? ''; +}; + +export type L1ToL2TokenAddressMapping = { + l1address: string, + l2address: string, +}; +export const fetchL1Representation = async ( + config: CheckoutConfiguration, + l2address: string, +): Promise => { + if (l2address === '') return { l1address: '', l2address }; + if (l2address === IMX_ADDRESS_ZKEVM) { + return { + l1address: await getImxL1Representation(getL1ChainId(config), config), + l2address: IMX_ADDRESS_ZKEVM, + }; + } + + const chainName = getIndexerChainName(getL2ChainId(config)); + if (chainName === '') return { l1address: '', l2address }; // Chain name not a valid indexer chain name + + const blockchainData = createBlockchainDataInstance(config); + const tokenData = await blockchainData.getToken({ + chainName, + contractAddress: l2address, + }); + + const l1address = tokenData.result.root_contract_address; + if (l1address === null) return { l1address: '', l2address }; // No L1 representation of this token + + return { + l1address, + l2address, + }; +}; diff --git a/packages/checkout/sdk/src/smartCheckout/routing/routingCalculator.test.ts b/packages/checkout/sdk/src/smartCheckout/routing/routingCalculator.test.ts index 94a05c1b52..68a32dd78c 100644 --- a/packages/checkout/sdk/src/smartCheckout/routing/routingCalculator.test.ts +++ b/packages/checkout/sdk/src/smartCheckout/routing/routingCalculator.test.ts @@ -2,22 +2,41 @@ import { BigNumber } from 'ethers'; import { Environment } from '@imtbl/config'; import { JsonRpcProvider } from '@ethersproject/providers'; import { TokenInfo } from '@imtbl/dex-sdk'; -import { getSwapFundingSteps, routingCalculator } from './routingCalculator'; +import { + getBridgeAndSwapFundingSteps, + getSwapFundingSteps, + routingCalculator, +} from './routingCalculator'; import { CheckoutConfiguration } from '../../config'; import { getAllTokenBalances } from './tokenBalances'; import { - DexQuote, DexQuoteCache, DexQuotes, RouteCalculatorType, TokenBalanceResult, + DexQuote, + DexQuoteCache, + DexQuotes, + RouteCalculatorType, + TokenBalanceResult, } from './types'; import { bridgeRoute } from './bridge/bridgeRoute'; import { - ChainId, FundingRouteType, IMX_ADDRESS_ZKEVM, ItemType, + ChainId, + FundingRouteType, + IMX_ADDRESS_ZKEVM, + ItemType, } from '../../types'; -import { BalanceCheckResult, BalanceERC20Requirement, BalanceRequirement } from '../balanceCheck/types'; +import { + BalanceCheckResult, + BalanceERC20Requirement, + BalanceERC721Requirement, + BalanceRequirement, +} from '../balanceCheck/types'; import { createReadOnlyProviders } from '../../readOnlyProviders/readOnlyProvider'; import { CheckoutError, CheckoutErrorType } from '../../errors'; import { swapRoute } from './swap/swapRoute'; import { allowListCheck } from '../allowList'; import { onRampRoute } from './onRamp'; +import { bridgeAndSwapRoute } from './bridgeAndSwap/bridgeAndSwapRoute'; +import { RoutingTokensAllowList } from '../allowList/types'; +import { INDEXER_ETH_ROOT_CONTRACT_ADDRESS } from './indexer/fetchL1Representation'; jest.mock('./tokenBalances'); jest.mock('./bridge/bridgeRoute'); @@ -26,6 +45,7 @@ jest.mock('../../config/remoteConfigFetcher'); jest.mock('./swap/swapRoute'); jest.mock('./onRamp/onRampRoute'); jest.mock('../allowList'); +jest.mock('./bridgeAndSwap/bridgeAndSwapRoute'); describe('routingCalculator', () => { let config: CheckoutConfiguration; @@ -147,6 +167,11 @@ describe('routingCalculator', () => { ], ); + const readonlyProviders = new Map([ + [ChainId.SEPOLIA, {} as JsonRpcProvider], + [ChainId.IMTBL_ZKEVM_TESTNET, {} as JsonRpcProvider], + ]); + beforeEach(() => { config = new CheckoutConfiguration({ baseConfig: { environment: Environment.SANDBOX }, @@ -796,7 +821,7 @@ describe('routingCalculator', () => { }); }); - it('should return bridge and swap funding step', async () => { + it('should return bridge, swap and bridge & swap funding step', async () => { (allowListCheck as jest.Mock).mockImplementation(() => ( { onRamp: [], @@ -860,7 +885,30 @@ describe('routingCalculator', () => { }; (getAllTokenBalances as jest.Mock).mockResolvedValue(new Map([ - [ChainId.SEPOLIA, []], + [ChainId.SEPOLIA, { + success: true, + balances: [ + { + balance: BigNumber.from(10), + formattedBalance: '10', + token: { + name: 'ETH', + symbol: 'ETH', + decimals: 18, + }, + }, + { + balance: BigNumber.from(10), + formattedBalance: '10', + token: { + name: 'IMX', + symbol: 'IMX', + decimals: 18, + address: '0xIMX', + }, + }, + ], + }], [ChainId.IMTBL_ZKEVM_TESTNET, { success: true, balances: [ @@ -917,6 +965,39 @@ describe('routingCalculator', () => { }, }]); + (bridgeAndSwapRoute as jest.Mock).mockResolvedValue([ + { + bridgeFundingStep: { + type: FundingRouteType.BRIDGE, + chainId: ChainId.SEPOLIA, + asset: { + balance: BigNumber.from(5), + formattedBalance: '5', + token: { + name: 'IMX', + symbol: 'IMX', + decimals: 18, + address: '0xIMXL1', + }, + }, + }, + swapFundingStep: { + type: FundingRouteType.SWAP, + chainId: ChainId.IMTBL_ZKEVM_TESTNET, + asset: { + balance: BigNumber.from(10), + formattedBalance: '10', + token: { + name: 'IMX', + symbol: 'IMX', + decimals: 18, + address: '0xIMX', + }, + }, + }, + }, + ]); + (createReadOnlyProviders as jest.Mock).mockResolvedValue(new Map([ [ChainId.SEPOLIA, {} as JsonRpcProvider], [ChainId.IMTBL_ZKEVM_TESTNET, {} as JsonRpcProvider], @@ -968,6 +1049,39 @@ describe('routingCalculator', () => { }, }], }, + { + priority: 3, + steps: [ + { + type: FundingRouteType.BRIDGE, + chainId: ChainId.SEPOLIA, + asset: { + balance: BigNumber.from(5), + formattedBalance: '5', + token: { + name: 'IMX', + symbol: 'IMX', + decimals: 18, + address: '0xIMXL1', + }, + }, + }, + { + type: FundingRouteType.SWAP, + chainId: ChainId.IMTBL_ZKEVM_TESTNET, + asset: { + balance: BigNumber.from(10), + formattedBalance: '10', + token: { + name: 'IMX', + symbol: 'IMX', + decimals: 18, + address: '0xIMX', + }, + }, + }, + ], + }, ], }); }); @@ -1015,6 +1129,8 @@ describe('routingCalculator', () => { bridge: true, }; + (allowListCheck as jest.Mock).mockResolvedValue(availableRoutingOptions); + (getAllTokenBalances as jest.Mock).mockResolvedValue(new Map([ [ChainId.SEPOLIA, []], ])); @@ -1066,6 +1182,8 @@ describe('routingCalculator', () => { bridge: true, }; + (allowListCheck as jest.Mock).mockResolvedValue(availableRoutingOptions); + (getAllTokenBalances as jest.Mock).mockResolvedValue(new Map([ [ChainId.SEPOLIA, []], ])); @@ -1693,4 +1811,382 @@ describe('routingCalculator', () => { expect(swapFundingSteps).toEqual([]); }); }); + + describe('getBridgeAndSwapFundingSteps', () => { + const dexQuoteCache = {} as DexQuoteCache; + const insufficientRequirement = { + type: ItemType.NATIVE, + sufficient: false, + delta: { + balance: BigNumber.from(5), + formattedBalance: '5', + }, + current: { + type: ItemType.NATIVE, + balance: BigNumber.from(5), + formattedBalance: '5', + token: { + name: 'IMX', + symbol: 'IMX', + decimals: 18, + address: '0xIMX', + }, + }, + required: { + type: ItemType.NATIVE, + balance: BigNumber.from(10), + formattedBalance: '10', + token: { + name: 'IMX', + symbol: 'IMX', + decimals: 18, + address: '0xIMX', + }, + }, + } as BalanceRequirement; + const tokenBalances = new Map([]); + const l1balances = { + success: true, + balances: [ + { + balance: BigNumber.from(10), + formattedBalance: '10', + token: { + name: 'ETH', + symbol: 'ETH', + decimals: 18, + }, + }, + { + balance: BigNumber.from(10), + formattedBalance: '10', + token: { + name: 'IMX', + symbol: 'IMX', + decimals: 18, + address: '0xIMX', + }, + }, + ], + }; + const l2balances = { + success: true, + balances: [ + { + balance: BigNumber.from(10), + formattedBalance: '10', + token: { + name: 'ERC20', + symbol: 'ERC20', + decimals: 18, + address: '0xERC20_2', + }, + }, + { + balance: BigNumber.from(10), + formattedBalance: '10', + token: { + name: 'IMX', + symbol: 'IMX', + decimals: 18, + address: IMX_ADDRESS_ZKEVM, + }, + }, + ], + }; + const tokenAllowList: RoutingTokensAllowList = { + bridge: [ + { + name: 'ERC20_1', + symbol: 'ERC20_1', + decimals: 18, + address: '0xERC20_1', + }, + { + name: 'ETH', + symbol: 'ETH', + decimals: 18, + }, + ], + swap: [{ + name: 'ERC20_2', + symbol: 'ERC20_2', + decimals: 18, + address: '0xERC20_2', + }], + }; + const feeEstimates = new Map(); + const balanceRequirements: BalanceCheckResult = { + sufficient: false, + balanceRequirements: [insufficientRequirement], + }; + + it('should not get bridge and swap funding step if insufficient requirement undefined', async () => { + const result = await getBridgeAndSwapFundingSteps( + config, + readonlyProviders, + { swap: true, bridge: true }, + undefined, + dexQuoteCache, + '0xADDRESS', + tokenBalances, + tokenAllowList, + feeEstimates, + balanceRequirements, + ); + expect(result).toEqual([]); + }); + + it('should not get bridge and swap funding step if no l1 balances', async () => { + const result = await getBridgeAndSwapFundingSteps( + config, + readonlyProviders, + { swap: true, bridge: true }, + insufficientRequirement, + dexQuoteCache, + '0xADDRESS', + tokenBalances, + tokenAllowList, + feeEstimates, + balanceRequirements, + ); + expect(result).toEqual([]); + }); + + it('should not get bridge and swap funding step if l1 balances error', async () => { + tokenBalances.set( + ChainId.SEPOLIA, + { + success: true, + error: new CheckoutError('error', CheckoutErrorType.GET_BALANCE_ERROR), + balances: [], + }, + ); + const result = await getBridgeAndSwapFundingSteps( + config, + readonlyProviders, + { swap: true, bridge: true }, + insufficientRequirement, + dexQuoteCache, + '0xADDRESS', + tokenBalances, + tokenAllowList, + feeEstimates, + balanceRequirements, + ); + expect(result).toEqual([]); + }); + + it('should not get bridge and swap funding step if l1 balances success false', async () => { + tokenBalances.set( + ChainId.SEPOLIA, + { + success: false, + balances: [], + }, + ); + const result = await getBridgeAndSwapFundingSteps( + config, + readonlyProviders, + { swap: true, bridge: true }, + insufficientRequirement, + dexQuoteCache, + '0xADDRESS', + tokenBalances, + tokenAllowList, + feeEstimates, + balanceRequirements, + ); + expect(result).toEqual([]); + }); + + it('should not get bridge and swap funding step if no l2 balances', async () => { + tokenBalances.set(ChainId.SEPOLIA, l1balances); + const result = await getBridgeAndSwapFundingSteps( + config, + readonlyProviders, + { swap: true, bridge: true }, + insufficientRequirement, + dexQuoteCache, + '0xADDRESS', + tokenBalances, + tokenAllowList, + feeEstimates, + balanceRequirements, + ); + expect(result).toEqual([]); + }); + + it('should not get bridge and swap funding step if l2 balances error', async () => { + tokenBalances.set(ChainId.SEPOLIA, l1balances); + tokenBalances.set( + ChainId.IMTBL_ZKEVM_TESTNET, + { + success: true, + error: new CheckoutError('error', CheckoutErrorType.GET_BALANCE_ERROR), + balances: [], + }, + ); + const result = await getBridgeAndSwapFundingSteps( + config, + readonlyProviders, + { swap: true, bridge: true }, + insufficientRequirement, + dexQuoteCache, + '0xADDRESS', + tokenBalances, + tokenAllowList, + feeEstimates, + balanceRequirements, + ); + expect(result).toEqual([]); + }); + + it('should not get bridge and swap funding step if l2 balances success false', async () => { + tokenBalances.set(ChainId.SEPOLIA, l1balances); + tokenBalances.set( + ChainId.IMTBL_ZKEVM_TESTNET, + { + success: false, + balances: [], + }, + ); + const result = await getBridgeAndSwapFundingSteps( + config, + readonlyProviders, + { swap: true, bridge: true }, + insufficientRequirement, + dexQuoteCache, + '0xADDRESS', + tokenBalances, + tokenAllowList, + feeEstimates, + balanceRequirements, + ); + expect(result).toEqual([]); + }); + + it('should not get bridge and swap funding step if item requirement erc721', async () => { + tokenBalances.set(ChainId.SEPOLIA, l1balances); + tokenBalances.set(ChainId.IMTBL_ZKEVM_TESTNET, l2balances); + const result = await getBridgeAndSwapFundingSteps( + config, + readonlyProviders, + { swap: true, bridge: true }, + { + type: ItemType.ERC721, + } as BalanceERC721Requirement, + dexQuoteCache, + '0xADDRESS', + tokenBalances, + tokenAllowList, + feeEstimates, + balanceRequirements, + ); + expect(result).toEqual([]); + }); + + it('should call bridgeAndSwapRoute and return routes', async () => { + (bridgeAndSwapRoute as jest.Mock).mockResolvedValue([ + { + bridgeFundingStep: { + type: FundingRouteType.BRIDGE, + chainId: ChainId.SEPOLIA, + asset: { + balance: BigNumber.from(5), + formattedBalance: '5', + token: { + name: 'IMX', + symbol: 'IMX', + decimals: 18, + address: '0xIMXL1', + }, + }, + }, + swapFundingStep: { + type: FundingRouteType.SWAP, + chainId: ChainId.IMTBL_ZKEVM_TESTNET, + asset: { + balance: BigNumber.from(10), + formattedBalance: '10', + token: { + name: 'IMX', + symbol: 'IMX', + decimals: 18, + address: '0xIMX', + }, + }, + }, + }, + ]); + + tokenBalances.set(ChainId.SEPOLIA, l1balances); + tokenBalances.set(ChainId.IMTBL_ZKEVM_TESTNET, l2balances); + const result = await getBridgeAndSwapFundingSteps( + config, + readonlyProviders, + { swap: true, bridge: true }, + insufficientRequirement, + dexQuoteCache, + '0xADDRESS', + tokenBalances, + tokenAllowList, + feeEstimates, + balanceRequirements, + ); + expect(result) + .toEqual([ + { + bridgeFundingStep: { + type: FundingRouteType.BRIDGE, + chainId: ChainId.SEPOLIA, + asset: { + balance: BigNumber.from(5), + formattedBalance: '5', + token: { + name: 'IMX', + symbol: 'IMX', + decimals: 18, + address: '0xIMXL1', + }, + }, + }, + swapFundingStep: { + type: FundingRouteType.SWAP, + chainId: ChainId.IMTBL_ZKEVM_TESTNET, + asset: { + balance: BigNumber.from(10), + formattedBalance: '10', + token: { + name: 'IMX', + symbol: 'IMX', + decimals: 18, + address: '0xIMX', + }, + }, + }, + }, + ]); + expect(bridgeAndSwapRoute).toBeCalledWith( + config, + readonlyProviders, + { bridge: true, swap: true }, + insufficientRequirement, + dexQuoteCache, + '0xADDRESS', + feeEstimates, + tokenBalances, + ['0xERC20_1', INDEXER_ETH_ROOT_CONTRACT_ADDRESS], + [ + { + address: '0xERC20_2', + decimals: 18, + name: 'ERC20_2', + symbol: 'ERC20_2', + }, + ], + balanceRequirements, + ); + }); + }); }); diff --git a/packages/checkout/sdk/src/smartCheckout/routing/routingCalculator.ts b/packages/checkout/sdk/src/smartCheckout/routing/routingCalculator.ts index 05f90678dd..958f88d3f6 100644 --- a/packages/checkout/sdk/src/smartCheckout/routing/routingCalculator.ts +++ b/packages/checkout/sdk/src/smartCheckout/routing/routingCalculator.ts @@ -1,8 +1,13 @@ import { BigNumber } from 'ethers'; -import { BalanceCheckResult, BalanceRequirement } from '../balanceCheck/types'; +import { JsonRpcProvider } from '@ethersproject/providers'; +import { + BalanceCheckResult, + BalanceRequirement, +} from '../balanceCheck/types'; import { ChainId, FundingRouteType, + ItemType, RoutingOptionsAvailable, TokenInfo, } from '../../types'; @@ -15,13 +20,20 @@ import { TokenBalanceResult, } from './types'; import { getAllTokenBalances } from './tokenBalances'; -import { bridgeRoute } from './bridge/bridgeRoute'; -import { CheckoutConfiguration, getL2ChainId } from '../../config'; +import { + CheckoutConfiguration, + getL1ChainId, + getL2ChainId, +} from '../../config'; import { createReadOnlyProviders } from '../../readOnlyProviders/readOnlyProvider'; import { CheckoutError, CheckoutErrorType } from '../../errors'; import { swapRoute } from './swap/swapRoute'; import { allowListCheck } from '../allowList'; +import { RoutingTokensAllowList } from '../allowList/types'; +import { BridgeAndSwapRoute, bridgeAndSwapRoute } from './bridgeAndSwap/bridgeAndSwapRoute'; +import { BridgeRequirement, bridgeRoute } from './bridge/bridgeRoute'; import { onRampRoute } from './onRamp'; +import { INDEXER_ETH_ROOT_CONTRACT_ADDRESS } from './indexer/fetchL1Representation'; const hasAvailableRoutingOptions = (availableRoutingOptions: RoutingOptionsAvailable) => ( availableRoutingOptions.bridge || availableRoutingOptions.swap || availableRoutingOptions.onRamp @@ -43,6 +55,43 @@ export const getInsufficientRequirement = ( return undefined; }; +export const getBridgeFundingStep = async ( + config: CheckoutConfiguration, + readOnlyProviders: Map, + availableRoutingOptions: RoutingOptionsAvailable, + insufficientRequirement: BalanceRequirement | undefined, + ownerAddress: string, + tokenBalances: Map, + feeEstimates: Map, +): Promise => { + let bridgeFundingStep; + + if (insufficientRequirement === undefined) return undefined; + if (insufficientRequirement.type !== ItemType.NATIVE && insufficientRequirement.type !== ItemType.ERC20) { + return undefined; + } + + const bridgeRequirement: BridgeRequirement = { + amount: insufficientRequirement.delta.balance, + formattedAmount: insufficientRequirement.delta.formattedBalance, + l2address: insufficientRequirement.required.token.address ?? '', + }; + + if (availableRoutingOptions.bridge && insufficientRequirement) { + bridgeFundingStep = await bridgeRoute( + config, + readOnlyProviders, + ownerAddress, + availableRoutingOptions, + bridgeRequirement, + tokenBalances, + feeEstimates, + ); + } + + return bridgeFundingStep; +}; + export const getSwapFundingSteps = async ( config: CheckoutConfiguration, availableRoutingOptions: RoutingOptionsAvailable, @@ -78,6 +127,59 @@ export const getSwapFundingSteps = async ( ); }; +export const getBridgeAndSwapFundingSteps = async ( + config: CheckoutConfiguration, + readOnlyProviders: Map, + availableRoutingOptions: RoutingOptionsAvailable, + insufficientRequirement: BalanceRequirement | undefined, + dexQuoteCache: DexQuoteCache, + ownerAddress: string, + tokenBalances: Map, + tokenAllowList: RoutingTokensAllowList | undefined, + feeEstimates: Map, + balanceRequirements: BalanceCheckResult, +): Promise => { + if (!insufficientRequirement) return []; + + const l1balancesResult = tokenBalances.get(getL1ChainId(config)); + const l2balancesResult = tokenBalances.get(getL2ChainId(config)); + + // If there are no l1 balance then cannot bridge + if (!l1balancesResult) return []; + if (l1balancesResult.error !== undefined || !l1balancesResult.success) return []; + // If there are no l2 balance then cannot swap + if (!l2balancesResult) return []; + if (l2balancesResult.error !== undefined || !l2balancesResult.success) return []; + + // Get a list of all the swappable tokens + const bridgeTokenAllowList = tokenAllowList?.bridge ?? []; + const bridgeableL1Addresses: string[] = bridgeTokenAllowList.map((token) => { + if (token.address === undefined) return INDEXER_ETH_ROOT_CONTRACT_ADDRESS; + return token.address; + }); + const swapTokenAllowList = tokenAllowList?.swap ?? []; + + if (insufficientRequirement.type !== ItemType.NATIVE && insufficientRequirement.type !== ItemType.ERC20) { + return []; + } + + const routes = await bridgeAndSwapRoute( + config, + readOnlyProviders, + availableRoutingOptions, + insufficientRequirement, + dexQuoteCache, + ownerAddress, + feeEstimates, + tokenBalances, + bridgeableL1Addresses, + swapTokenAllowList, + balanceRequirements, + ); + + return routes; +}; + export const getOnRampFundingStep = async ( config: CheckoutConfiguration, availableRoutingOptions: RoutingOptionsAvailable, @@ -129,7 +231,11 @@ export const routingCalculator = async ( availableRoutingOptions, ); - const allowList = await allowListCheck(config, tokenBalances, availableRoutingOptions); + const allowList = await allowListCheck( + config, + tokenBalances, + availableRoutingOptions, + ); // Bridge and swap fee cache const feeEstimates = new Map(); @@ -137,23 +243,22 @@ export const routingCalculator = async ( // Dex quotes cache const dexQuoteCache: DexQuoteCache = new Map(); - // Ensures only 1 balance requirement is insufficient otherwise one bridge or one swap route cannot be recommended + // Ensures only 1 balance requirement is insufficient const insufficientRequirement = getInsufficientRequirement(balanceRequirements); - let bridgeFundingStep; - if (availableRoutingOptions.bridge && insufficientRequirement) { - bridgeFundingStep = await bridgeRoute( - config, - readOnlyProviders, - ownerAddress, - availableRoutingOptions, - insufficientRequirement, - tokenBalances, - feeEstimates, - ); - } + const routePromises = []; + + routePromises.push(getBridgeFundingStep( + config, + readOnlyProviders, + availableRoutingOptions, + insufficientRequirement, + ownerAddress, + tokenBalances, + feeEstimates, + )); - const swapFundingSteps = await getSwapFundingSteps( + routePromises.push(getSwapFundingSteps( config, availableRoutingOptions, insufficientRequirement, @@ -161,18 +266,39 @@ export const routingCalculator = async ( ownerAddress, tokenBalances, allowList.swap, - ); + )); - const onRampFundingStep = await getOnRampFundingStep( + routePromises.push(getOnRampFundingStep( config, availableRoutingOptions, insufficientRequirement, - ); + )); - // Check swap routes - // > Could bridge first - // > Could on-ramp first - // > Could double swap + routePromises.push(getBridgeAndSwapFundingSteps( + config, + readOnlyProviders, + availableRoutingOptions, + insufficientRequirement, + dexQuoteCache, + ownerAddress, + tokenBalances, + allowList, + feeEstimates, + balanceRequirements, + )); + + const resolved = await Promise.all(routePromises); + + let bridgeFundingStep: FundingRouteStep | undefined; + let swapFundingSteps: FundingRouteStep[] = []; + let onRampFundingStep: FundingRouteStep | undefined; + let bridgeAndSwapFundingSteps: BridgeAndSwapRoute[] = []; + resolved.forEach((result, index) => { + if (index === 0) bridgeFundingStep = result as FundingRouteStep | undefined; + if (index === 1) swapFundingSteps = result as FundingRouteStep[]; + if (index === 2) onRampFundingStep = result as FundingRouteStep | undefined; + if (index === 3) bridgeAndSwapFundingSteps = result as BridgeAndSwapRoute[]; + }); const response: RoutingCalculatorResult = { response: { @@ -184,7 +310,10 @@ export const routingCalculator = async ( let priority = 0; - if (bridgeFundingStep || swapFundingSteps.length > 0 || onRampFundingStep) { + if (bridgeFundingStep + || swapFundingSteps.length > 0 + || onRampFundingStep + || bridgeAndSwapFundingSteps.length > 0) { response.response.type = RouteCalculatorType.ROUTES_FOUND; response.response.message = 'Routes found'; } @@ -215,5 +344,19 @@ export const routingCalculator = async ( }); } + if (bridgeAndSwapFundingSteps) { + priority++; + bridgeAndSwapFundingSteps.forEach((bridgeAndSwapFundingStep) => { + const bridgeStep = bridgeAndSwapFundingStep.bridgeFundingStep; + const swapStep = bridgeAndSwapFundingStep.swapFundingStep; + response.fundingRoutes.push({ + priority, + steps: [bridgeStep, swapStep], + }); + }); + } + + // eslint-disable-next-line no-console + console.log('** response', response); return response; }; diff --git a/packages/checkout/sdk/src/smartCheckout/routing/swap/dexQuoteCache.ts b/packages/checkout/sdk/src/smartCheckout/routing/swap/dexQuoteCache.ts index 3c1b45ffd7..9719d32557 100644 --- a/packages/checkout/sdk/src/smartCheckout/routing/swap/dexQuoteCache.ts +++ b/packages/checkout/sdk/src/smartCheckout/routing/swap/dexQuoteCache.ts @@ -14,7 +14,7 @@ export const getOrSetQuotesFromCache = async ( swappableTokens: string[], ): Promise => { const dexQuotes = dexQuoteCache.get(requiredToken.address); - if (dexQuotes) return dexQuotes; + if (dexQuotes && dexQuotes.size > 0) return dexQuotes; const quotes = await quoteFetcher( config, diff --git a/packages/checkout/sdk/src/smartCheckout/routing/swap/quoteFetcher.ts b/packages/checkout/sdk/src/smartCheckout/routing/swap/quoteFetcher.ts index 151d4f279e..581d48208d 100644 --- a/packages/checkout/sdk/src/smartCheckout/routing/swap/quoteFetcher.ts +++ b/packages/checkout/sdk/src/smartCheckout/routing/swap/quoteFetcher.ts @@ -16,6 +16,8 @@ export const quoteFetcher = async ( swappableTokens: string[], ): Promise => { const dexQuotes: DexQuotes = new Map(); + // Apply a small slippage percent as a buffer to cover price fluctuations between token pairs + const slippagePercent = 1; try { const exchange = await instance.createExchangeInstance(chainId, config); @@ -28,6 +30,7 @@ export const quoteFetcher = async ( swappableToken, requiredToken.address, requiredToken.amount, + slippagePercent, )); } diff --git a/packages/checkout/sdk/src/smartCheckout/smartCheckout.ts b/packages/checkout/sdk/src/smartCheckout/smartCheckout.ts index ad3dcef861..e7597085ea 100644 --- a/packages/checkout/sdk/src/smartCheckout/smartCheckout.ts +++ b/packages/checkout/sdk/src/smartCheckout/smartCheckout.ts @@ -64,6 +64,7 @@ export const smartCheckout = async ( // Determine which services are available const availableRoutingOptions = await routingOptionsAvailable(config, provider); + // eslint-disable-next-line @typescript-eslint/no-unused-vars const fundingRoutes = await routingCalculator( config,