From 24dbe308e6f21c3436aea637c58ecdef53b86a48 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Thu, 24 Oct 2024 16:44:27 -0700 Subject: [PATCH 01/25] chore: fetch dest token exchange rates and save to bridge state --- ui/ducks/bridge/actions.ts | 3 +- ui/ducks/bridge/bridge.test.ts | 91 +++++++++++++++++++ ui/ducks/bridge/bridge.ts | 27 +++++- .../bridge/prepare/prepare-bridge-page.tsx | 31 ++++++- 4 files changed, 148 insertions(+), 4 deletions(-) diff --git a/ui/ducks/bridge/actions.ts b/ui/ducks/bridge/actions.ts index a61d2fdcd8fd..e8c1b5d7c5b9 100644 --- a/ui/ducks/bridge/actions.ts +++ b/ui/ducks/bridge/actions.ts @@ -11,7 +11,7 @@ import { forceUpdateMetamaskState } from '../../store/actions'; import { submitRequestToBackground } from '../../store/background-connection'; import { QuoteRequest } from '../../pages/bridge/types'; import { MetaMaskReduxDispatch } from '../../store/store'; -import { bridgeSlice } from './bridge'; +import { bridgeSlice, setDestTokenExchangeRates } from './bridge'; const { setToChainId, @@ -27,6 +27,7 @@ export { setToToken, setFromToken, setFromTokenInputValue, + setDestTokenExchangeRates, }; const callBridgeControllerMethod = ( diff --git a/ui/ducks/bridge/bridge.test.ts b/ui/ducks/bridge/bridge.test.ts index dc9596fcafba..daaf8f9c843b 100644 --- a/ui/ducks/bridge/bridge.test.ts +++ b/ui/ducks/bridge/bridge.test.ts @@ -10,6 +10,7 @@ import { // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths } from '../../../app/scripts/controllers/bridge/types'; +import * as util from '../../helpers/utils/util'; import bridgeReducer from './bridge'; import { setBridgeFeatureFlags, @@ -22,6 +23,7 @@ import { setToChainId, updateQuoteRequestParams, resetBridgeState, + setDestTokenExchangeRates, } from './actions'; const middleware = [thunk]; @@ -147,6 +149,7 @@ describe('Ducks - Bridge', () => { fromToken: null, toToken: null, fromTokenInputValue: null, + toTokenExchangeRate: null, }); }); }); @@ -205,6 +208,94 @@ describe('Ducks - Bridge', () => { fromToken: null, toToken: null, fromTokenInputValue: null, + toTokenExchangeRate: null, + }); + }); + }); + describe('setDestTokenExchangeRates', () => { + it('fetches token prices and updates dest exchange rates in state, native dest token', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mockStore = configureMockStore(middleware)( + createBridgeMockStore({}, {}), + ); + const state = mockStore.getState().bridge; + const fetchTokenExchangeRatesSpy = jest + .spyOn(util, 'fetchTokenExchangeRates') + .mockResolvedValue({ + '0x0000000000000000000000000000000000000000': 0.356628, + }); + + await mockStore.dispatch( + setDestTokenExchangeRates({ + chainId: CHAIN_IDS.LINEA_MAINNET, + tokenAddress: zeroAddress(), + currency: 'usd', + }) as never, + ); + + expect(fetchTokenExchangeRatesSpy).toHaveBeenCalledTimes(1); + expect(fetchTokenExchangeRatesSpy).toHaveBeenCalledWith( + 'usd', + ['0x0000000000000000000000000000000000000000'], + CHAIN_IDS.LINEA_MAINNET, + ); + + const actions = mockStore.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0].type).toStrictEqual( + 'bridge/setDestTokenExchangeRates/pending', + ); + expect(actions[1].type).toStrictEqual( + 'bridge/setDestTokenExchangeRates/fulfilled', + ); + const newState = bridgeReducer(state, actions[1]); + expect(newState).toStrictEqual({ + toChainId: null, + toTokenExchangeRate: 0.356628, + }); + }); + + it('fetches token prices and updates dest exchange rates in state, erc20 dest token', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mockStore = configureMockStore(middleware)( + createBridgeMockStore({}, {}), + ); + const state = mockStore.getState().bridge; + const fetchTokenExchangeRatesSpy = jest + .spyOn(util, 'fetchTokenExchangeRates') + .mockResolvedValue({ + '0x0000000000000000000000000000000000000000': 0.356628, + '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359': 0.999881, + }); + + await mockStore.dispatch( + setDestTokenExchangeRates({ + chainId: CHAIN_IDS.LINEA_MAINNET, + tokenAddress: + '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359'.toLowerCase(), + currency: 'usd', + }) as never, + ); + + expect(fetchTokenExchangeRatesSpy).toHaveBeenCalledTimes(1); + expect(fetchTokenExchangeRatesSpy).toHaveBeenCalledWith( + 'usd', + ['0x3c499c542cef5e3811e1192ce70d8cc03d5c3359'], + CHAIN_IDS.LINEA_MAINNET, + ); + + const actions = mockStore.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0].type).toStrictEqual( + 'bridge/setDestTokenExchangeRates/pending', + ); + expect(actions[1].type).toStrictEqual( + 'bridge/setDestTokenExchangeRates/fulfilled', + ); + const newState = bridgeReducer(state, actions[1]); + expect(newState).toStrictEqual({ + toChainId: null, + toTokenExchangeRate: 0.999881, }); }); }); diff --git a/ui/ducks/bridge/bridge.ts b/ui/ducks/bridge/bridge.ts index c75030c7591d..487fc2a985b6 100644 --- a/ui/ducks/bridge/bridge.ts +++ b/ui/ducks/bridge/bridge.ts @@ -1,15 +1,17 @@ -import { createSlice } from '@reduxjs/toolkit'; - +import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; import { Hex } from '@metamask/utils'; +import { getAddress } from 'ethers/lib/utils'; import { swapsSlice } from '../swaps/swaps'; import { SwapsTokenObject } from '../../../shared/constants/swaps'; import { SwapsEthToken } from '../../selectors'; +import { fetchTokenExchangeRates } from '../../helpers/utils/util'; export type BridgeState = { toChainId: Hex | null; fromToken: SwapsTokenObject | SwapsEthToken | null; toToken: SwapsTokenObject | SwapsEthToken | null; fromTokenInputValue: string | null; + toTokenExchangeRate: number | null; }; const initialState: BridgeState = { @@ -17,8 +19,24 @@ const initialState: BridgeState = { fromToken: null, toToken: null, fromTokenInputValue: null, + toTokenExchangeRate: null, }; +export const setDestTokenExchangeRates = createAsyncThunk( + 'bridge/setDestTokenExchangeRates', + async (request: { chainId: Hex; tokenAddress: string; currency: string }) => { + const { chainId, tokenAddress, currency } = request; + const exchangeRates = await fetchTokenExchangeRates( + currency, + [tokenAddress], + chainId, + ); + return { + toTokenExchangeRate: exchangeRates?.[getAddress(tokenAddress)], + }; + }, +); + const bridgeSlice = createSlice({ name: 'bridge', initialState: { ...initialState }, @@ -40,6 +58,11 @@ const bridgeSlice = createSlice({ ...initialState, }), }, + extraReducers: (builder) => { + builder.addCase(setDestTokenExchangeRates.fulfilled, (state, action) => { + state.toTokenExchangeRate = action.payload.toTokenExchangeRate ?? null; + }); + }, }); export { bridgeSlice }; diff --git a/ui/pages/bridge/prepare/prepare-bridge-page.tsx b/ui/pages/bridge/prepare/prepare-bridge-page.tsx index b0553407686d..8cd40f7da0d6 100644 --- a/ui/pages/bridge/prepare/prepare-bridge-page.tsx +++ b/ui/pages/bridge/prepare/prepare-bridge-page.tsx @@ -2,7 +2,9 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import classnames from 'classnames'; import { debounce } from 'lodash'; +import { Hex } from '@metamask/utils'; import { + setDestTokenExchangeRates, setFromChain, setFromToken, setFromTokenInputValue, @@ -42,6 +44,8 @@ import { calcTokenValue } from '../../../../shared/lib/swaps-utils'; import { BridgeQuoteCard } from '../quotes/bridge-quote-card'; import { isValidQuoteRequest } from '../utils/quote'; import { getProviderConfig } from '../../../../shared/modules/selectors/networks'; +import { getCurrentCurrency } from '../../../selectors'; +import { SECOND } from '../../../../shared/constants/time'; import { BridgeInputGroup } from './bridge-input-group'; const PrepareBridgePage = () => { @@ -49,6 +53,8 @@ const PrepareBridgePage = () => { const t = useI18nContext(); + const currentCurrency = useSelector(getCurrentCurrency); + const fromToken = useSelector(getFromToken); const fromTokens = useSelector(getFromTokens); const fromTopAssets = useSelector(getFromTopAssets); @@ -125,6 +131,18 @@ const PrepareBridgePage = () => { debouncedUpdateQuoteRequestInController(quoteParams); }, Object.values(quoteParams)); + const debouncedFetchToExchangeRate = debounce( + async (toChainId: Hex, toTokenAddress: string) => + dispatch( + setDestTokenExchangeRates({ + chainId: toChainId, + tokenAddress: toTokenAddress, + currency: currentCurrency, + }), + ), + SECOND, + ); + return (
@@ -192,6 +210,12 @@ const PrepareBridgePage = () => { fromChain?.chainId && dispatch(setToChain(fromChain.chainId)); fromChain?.chainId && dispatch(setToChainId(fromChain.chainId)); dispatch(setToToken(fromToken)); + fromChain?.chainId && + fromToken?.address && + debouncedFetchToExchangeRate( + fromChain.chainId, + fromToken.address, + ); }} /> @@ -200,7 +224,12 @@ const PrepareBridgePage = () => { className="bridge-box" header={t('bridgeTo')} token={toToken} - onAssetChange={(token) => dispatch(setToToken(token))} + onAssetChange={(token) => { + dispatch(setToToken(token)); + toChain?.chainId && + token?.address && + debouncedFetchToExchangeRate(toChain.chainId, token.address); + }} networkProps={{ network: toChain, networks: toChains, From 832dc616a587914e3308c27e81d573b82e8c107d Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Tue, 5 Nov 2024 15:02:07 -0800 Subject: [PATCH 02/25] chore: fetch src token exchange rate --- ui/ducks/bridge/actions.ts | 7 +++- ui/ducks/bridge/bridge.test.ts | 2 ++ ui/ducks/bridge/bridge.ts | 18 +++++++++++ ui/pages/bridge/index.tsx | 28 ++++++++++++---- .../bridge/prepare/prepare-bridge-page.tsx | 32 +++++++++++++------ 5 files changed, 71 insertions(+), 16 deletions(-) diff --git a/ui/ducks/bridge/actions.ts b/ui/ducks/bridge/actions.ts index e8c1b5d7c5b9..5c2c9aa71f88 100644 --- a/ui/ducks/bridge/actions.ts +++ b/ui/ducks/bridge/actions.ts @@ -11,7 +11,11 @@ import { forceUpdateMetamaskState } from '../../store/actions'; import { submitRequestToBackground } from '../../store/background-connection'; import { QuoteRequest } from '../../pages/bridge/types'; import { MetaMaskReduxDispatch } from '../../store/store'; -import { bridgeSlice, setDestTokenExchangeRates } from './bridge'; +import { + bridgeSlice, + setDestTokenExchangeRates, + setSrcTokenExchangeRates, +} from './bridge'; const { setToChainId, @@ -28,6 +32,7 @@ export { setFromToken, setFromTokenInputValue, setDestTokenExchangeRates, + setSrcTokenExchangeRates, }; const callBridgeControllerMethod = ( diff --git a/ui/ducks/bridge/bridge.test.ts b/ui/ducks/bridge/bridge.test.ts index daaf8f9c843b..2d05c8b31a91 100644 --- a/ui/ducks/bridge/bridge.test.ts +++ b/ui/ducks/bridge/bridge.test.ts @@ -150,6 +150,7 @@ describe('Ducks - Bridge', () => { toToken: null, fromTokenInputValue: null, toTokenExchangeRate: null, + fromTokenExchangeRate: null, }); }); }); @@ -209,6 +210,7 @@ describe('Ducks - Bridge', () => { toToken: null, fromTokenInputValue: null, toTokenExchangeRate: null, + fromTokenExchangeRate: null, }); }); }); diff --git a/ui/ducks/bridge/bridge.ts b/ui/ducks/bridge/bridge.ts index 487fc2a985b6..7feeeac78083 100644 --- a/ui/ducks/bridge/bridge.ts +++ b/ui/ducks/bridge/bridge.ts @@ -11,6 +11,7 @@ export type BridgeState = { fromToken: SwapsTokenObject | SwapsEthToken | null; toToken: SwapsTokenObject | SwapsEthToken | null; fromTokenInputValue: string | null; + fromTokenExchangeRate: number | null; toTokenExchangeRate: number | null; }; @@ -19,9 +20,23 @@ const initialState: BridgeState = { fromToken: null, toToken: null, fromTokenInputValue: null, + fromTokenExchangeRate: null, toTokenExchangeRate: null, }; +export const setSrcTokenExchangeRates = createAsyncThunk( + 'bridge/setSrcTokenExchangeRates', + async (request: { chainId: Hex; tokenAddress: string; currency: string }) => { + const { chainId, tokenAddress, currency } = request; + const exchangeRates = await fetchTokenExchangeRates( + currency, + [tokenAddress], + chainId, + ); + return exchangeRates?.[getAddress(tokenAddress)]; + }, +); + export const setDestTokenExchangeRates = createAsyncThunk( 'bridge/setDestTokenExchangeRates', async (request: { chainId: Hex; tokenAddress: string; currency: string }) => { @@ -62,6 +77,9 @@ const bridgeSlice = createSlice({ builder.addCase(setDestTokenExchangeRates.fulfilled, (state, action) => { state.toTokenExchangeRate = action.payload.toTokenExchangeRate ?? null; }); + builder.addCase(setSrcTokenExchangeRates.fulfilled, (state, action) => { + state.fromTokenExchangeRate = action.payload ?? null; + }); }, }); diff --git a/ui/pages/bridge/index.tsx b/ui/pages/bridge/index.tsx index 687057094005..8c87f1bdae5e 100644 --- a/ui/pages/bridge/index.tsx +++ b/ui/pages/bridge/index.tsx @@ -1,6 +1,7 @@ import React, { useContext, useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { Switch, useHistory } from 'react-router-dom'; +import { zeroAddress } from 'ethereumjs-util'; import { I18nContext } from '../../contexts/i18n'; import { clearSwapsState } from '../../ducks/swaps/swaps'; import { @@ -16,16 +17,24 @@ import { ButtonIconSize, IconName, } from '../../components/component-library'; -import { getIsBridgeChain, getIsBridgeEnabled } from '../../selectors'; import { getProviderConfig } from '../../../shared/modules/selectors/networks'; +import { + getCurrentCurrency, + getIsBridgeChain, + getIsBridgeEnabled, +} from '../../selectors'; import useBridging from '../../hooks/bridge/useBridging'; import { Content, Footer, Header, } from '../../components/multichain/pages/page'; -import { resetBridgeState, setFromChain } from '../../ducks/bridge/actions'; import { useSwapsFeatureFlags } from '../swaps/hooks/useSwapsFeatureFlags'; +import { + resetBridgeState, + setFromChain, + setSrcTokenExchangeRates, +} from '../../ducks/bridge/actions'; import PrepareBridgePage from './prepare/prepare-bridge-page'; import { BridgeCTAButton } from './prepare/bridge-cta-button'; @@ -42,13 +51,20 @@ const CrossChainSwap = () => { const isBridgeEnabled = useSelector(getIsBridgeEnabled); const providerConfig = useSelector(getProviderConfig); const isBridgeChain = useSelector(getIsBridgeChain); + const currency = useSelector(getCurrentCurrency); useEffect(() => { - isBridgeChain && - isBridgeEnabled && - providerConfig && + if (isBridgeChain && isBridgeEnabled && providerConfig && currency) { dispatch(setFromChain(providerConfig.chainId)); - }, [isBridgeChain, isBridgeEnabled, providerConfig]); + dispatch( + setSrcTokenExchangeRates({ + chainId: providerConfig.chainId, + tokenAddress: zeroAddress(), + currency, + }), + ); + } + }, [isBridgeChain, isBridgeEnabled, providerConfig, currency]); const resetControllerAndInputStates = async () => { await dispatch(resetBridgeState()); diff --git a/ui/pages/bridge/prepare/prepare-bridge-page.tsx b/ui/pages/bridge/prepare/prepare-bridge-page.tsx index 8cd40f7da0d6..1044872c504f 100644 --- a/ui/pages/bridge/prepare/prepare-bridge-page.tsx +++ b/ui/pages/bridge/prepare/prepare-bridge-page.tsx @@ -3,11 +3,13 @@ import { useSelector, useDispatch } from 'react-redux'; import classnames from 'classnames'; import { debounce } from 'lodash'; import { Hex } from '@metamask/utils'; +import { zeroAddress } from 'ethereumjs-util'; import { setDestTokenExchangeRates, setFromChain, setFromToken, setFromTokenInputValue, + setSrcTokenExchangeRates, setToChain, setToChainId, setToToken, @@ -53,7 +55,7 @@ const PrepareBridgePage = () => { const t = useI18nContext(); - const currentCurrency = useSelector(getCurrentCurrency); + const currency = useSelector(getCurrentCurrency); const fromToken = useSelector(getFromToken); const fromTokens = useSelector(getFromTokens); @@ -131,15 +133,17 @@ const PrepareBridgePage = () => { debouncedUpdateQuoteRequestInController(quoteParams); }, Object.values(quoteParams)); + const debouncedFetchFromExchangeRate = debounce( + (chainId: Hex, tokenAddress: string) => { + dispatch(setSrcTokenExchangeRates({ chainId, tokenAddress, currency })); + }, + SECOND, + ); + const debouncedFetchToExchangeRate = debounce( - async (toChainId: Hex, toTokenAddress: string) => - dispatch( - setDestTokenExchangeRates({ - chainId: toChainId, - tokenAddress: toTokenAddress, - currency: currentCurrency, - }), - ), + (chainId: Hex, tokenAddress: string) => { + dispatch(setDestTokenExchangeRates({ chainId, tokenAddress, currency })); + }, SECOND, ); @@ -156,6 +160,9 @@ const PrepareBridgePage = () => { onAssetChange={(token) => { dispatch(setFromToken(token)); dispatch(setFromTokenInputValue(null)); + fromChain?.chainId && + token?.address && + debouncedFetchFromExchangeRate(fromChain.chainId, token.address); }} networkProps={{ network: fromChain, @@ -216,6 +223,13 @@ const PrepareBridgePage = () => { fromChain.chainId, fromToken.address, ); + toChain?.chainId && + toToken?.address && + toToken.address !== zeroAddress() && + debouncedFetchFromExchangeRate( + toChain.chainId, + toToken.address, + ); }} /> From 359a5b2ca7ae18380c2e0d80c3e93d928d102a90 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Fri, 25 Oct 2024 11:47:17 -0700 Subject: [PATCH 03/25] chore: calculate quote metadata in selectors --- shared/constants/bridge.ts | 4 + .../data/bridge/mock-quotes-native-erc20.json | 2 +- test/jest/mock-store.js | 9 + ui/ducks/bridge/bridge.test.ts | 1 + ui/ducks/bridge/bridge.ts | 6 + ui/ducks/bridge/selectors.test.ts | 192 ++++++++++++- ui/ducks/bridge/selectors.ts | 227 +++++++++++++--- ui/pages/bridge/index.tsx | 4 + ui/pages/bridge/prepare/bridge-cta-button.tsx | 16 +- .../bridge/prepare/bridge-input-group.tsx | 12 +- .../bridge-quote-card.test.tsx.snap | 12 +- .../bridge-quotes-modal.test.tsx.snap | 4 +- ui/pages/bridge/quotes/bridge-quote-card.tsx | 31 ++- .../bridge/quotes/bridge-quotes-modal.tsx | 19 +- ui/pages/bridge/types.ts | 18 ++ ui/pages/bridge/utils/quote.test.ts | 252 ++++++++++++++++++ ui/pages/bridge/utils/quote.ts | 157 +++++++++-- 17 files changed, 865 insertions(+), 101 deletions(-) create mode 100644 ui/pages/bridge/utils/quote.test.ts diff --git a/shared/constants/bridge.ts b/shared/constants/bridge.ts index 10f2587d3fbd..8ad27dce4944 100644 --- a/shared/constants/bridge.ts +++ b/shared/constants/bridge.ts @@ -26,3 +26,7 @@ export const BRIDGE_CLIENT_ID = 'extension'; export const ETH_USDT_ADDRESS = '0xdac17f958d2ee523a2206206994597c13d831ec7'; export const METABRIDGE_ETHEREUM_ADDRESS = '0x0439e60F02a8900a951603950d8D4527f400C3f1'; +export const BRIDGE_QUOTE_MAX_ETA_SECONDS = 60 * 60; // 1 hour +export const BRIDGE_QUOTE_MAX_RETURN_DIFFERENCE_PERCENTAGE = 0.8; // if a quote returns in x times less return than the best quote, ignore it + +export const BRIDGE_PREFERRED_GAS_ESTIMATE = 'medium'; diff --git a/test/data/bridge/mock-quotes-native-erc20.json b/test/data/bridge/mock-quotes-native-erc20.json index fb6ecfcc0b73..f7efe7950ba0 100644 --- a/test/data/bridge/mock-quotes-native-erc20.json +++ b/test/data/bridge/mock-quotes-native-erc20.json @@ -289,6 +289,6 @@ "data": "0x3ce33bff00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002714711487800000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000f736f636b657441646170746572563200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc00000000000000000000000003a23f943181408eac424116af7b7790c94cb97a50000000000000000000000003a23f943181408eac424116af7b7790c94cb97a5000000000000000000000000000000000000000000000000000000000000008900000000000000000000000000000000000000000000000000000000000000000000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c33590000000000000000000000000000000000000000000000000023375dc1560800000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000004f94ae6af800000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a51870000000000000000000000000000000000000000000000000000000000000c6437c6145a0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000bc4123506490000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000001960000000000000000000000000000000000000000000000000000000000000180000000000000000000000000000000000000000000000000000000000000018c0000000000000000000000000000000000000000000000000000000000000ac00000000000000000000000000000000000000000000000000000000000000084ad69fa4f00000000000000000000000000000000000000000000000000038d7ea4c68000000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c1883800000000000000000000000000000000000000000000000000000000000000890000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000904ee8f0b86000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000023375dc156080000000000000000000000000000000000000000000000000000000000000000c400000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000828415565b0000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000023375dc15608000000000000000000000000000000000000000000000000000000000001734d0800000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000004e000000000000000000000000000000000000000000000000000000000000005e0000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000040000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000000000000000000000000000023375dc15608000000000000000000000000000000000000000000000000000000000000000011000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000003600000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000042000000000000000000000000000000000000060000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff8500000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000320000000000000000000000000000000000000000000000000000000000000032000000000000000000000000000000000000000000000000000000000000002e00000000000000000000000000000000000000000000000000023375dc1560800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000012556e69737761705633000000000000000000000000000000000000000000000000000000000000000023375dc1560800000000000000000000000000000000000000000000000000000000000173dbd3000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000e592427a0aece92de3edee1f18e0157c0586156400000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002b42000000000000000000000000000000000000060001f40b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000008ecb000000000000000000000000ad01c20d5886137e056775af56915de824c8fce5000000000000000000000000000000000000000000000000000000000000000b000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000020000000000000000000000004200000000000000000000000000000000000006000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000000000000000000000000000000000000000000869584cd00000000000000000000000010000000000000000000000000000000000000110000000000000000000000000000000000000000974132b87a5cb75e32f034280000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c18838000000000000000000000000000000000000000000000000000000000000000700000000000000000000000000000000000000000000000000000000000000890000000000000000000000000000000000000000000000000000000000030d4000000000000000000000000000000000000000000000000000000000000000c400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003f9e43204a24f476db20f2518722627a122d31a1bc7c63fc15412e6a327295a9460b76bea5bb53b1f73fa6a15811055f6bada592d2e9e6c8cf48a855ce6968951c", "gasLimit": 664389 }, - "estimatedProcessingTimeInSeconds": 1560 + "estimatedProcessingTimeInSeconds": 15 } ] diff --git a/test/jest/mock-store.js b/test/jest/mock-store.js index 4720bf427372..f75b0df8bd09 100644 --- a/test/jest/mock-store.js +++ b/test/jest/mock-store.js @@ -719,6 +719,15 @@ export const createBridgeMockStore = ( { chainId: CHAIN_IDS.MAINNET }, { chainId: CHAIN_IDS.LINEA_MAINNET }, ), + gasFeeEstimates: { + high: { + suggestedMaxFeePerGas: '0.00010456', + suggestedMaxPriorityFeePerGas: '0.0001', + }, + }, + currencyRates: { + ETH: { conversionRate: 2524.25 }, + }, ...metamaskStateOverrides, bridgeState: { ...(swapsStore.metamask.bridgeState ?? {}), diff --git a/ui/ducks/bridge/bridge.test.ts b/ui/ducks/bridge/bridge.test.ts index 2d05c8b31a91..a972d074f512 100644 --- a/ui/ducks/bridge/bridge.test.ts +++ b/ui/ducks/bridge/bridge.test.ts @@ -149,6 +149,7 @@ describe('Ducks - Bridge', () => { fromToken: null, toToken: null, fromTokenInputValue: null, + sortOrder: 0, toTokenExchangeRate: null, fromTokenExchangeRate: null, }); diff --git a/ui/ducks/bridge/bridge.ts b/ui/ducks/bridge/bridge.ts index 7feeeac78083..22a780081499 100644 --- a/ui/ducks/bridge/bridge.ts +++ b/ui/ducks/bridge/bridge.ts @@ -5,6 +5,7 @@ import { swapsSlice } from '../swaps/swaps'; import { SwapsTokenObject } from '../../../shared/constants/swaps'; import { SwapsEthToken } from '../../selectors'; import { fetchTokenExchangeRates } from '../../helpers/utils/util'; +import { SortOrder } from '../../pages/bridge/types'; export type BridgeState = { toChainId: Hex | null; @@ -13,6 +14,7 @@ export type BridgeState = { fromTokenInputValue: string | null; fromTokenExchangeRate: number | null; toTokenExchangeRate: number | null; + sortOrder: SortOrder; }; const initialState: BridgeState = { @@ -22,6 +24,7 @@ const initialState: BridgeState = { fromTokenInputValue: null, fromTokenExchangeRate: null, toTokenExchangeRate: null, + sortOrder: SortOrder.COST_ASC, }; export const setSrcTokenExchangeRates = createAsyncThunk( @@ -72,6 +75,9 @@ const bridgeSlice = createSlice({ resetInputFields: () => ({ ...initialState, }), + setSortOrder: (state, action) => { + state.sortOrder = action.payload; + }, }, extraReducers: (builder) => { builder.addCase(setDestTokenExchangeRates.fulfilled, (state, action) => { diff --git a/ui/ducks/bridge/selectors.test.ts b/ui/ducks/bridge/selectors.test.ts index e39f73f2fa15..416959b34d66 100644 --- a/ui/ducks/bridge/selectors.test.ts +++ b/ui/ducks/bridge/selectors.test.ts @@ -4,9 +4,14 @@ import { CHAIN_IDS, FEATURED_RPCS, } from '../../../shared/constants/network'; -import { ALLOWED_BRIDGE_CHAIN_IDS } from '../../../shared/constants/bridge'; +import { + ALLOWED_BRIDGE_CHAIN_IDS, + BRIDGE_QUOTE_MAX_ETA_SECONDS, +} from '../../../shared/constants/bridge'; import { mockNetworkState } from '../../../test/stub/networks'; import mockErc20Erc20Quotes from '../../../test/data/bridge/mock-quotes-erc20-erc20.json'; +import mockBridgeQuotesNativeErc20 from '../../../test/data/bridge/mock-quotes-native-erc20.json'; +import { SortOrder } from '../../pages/bridge/types'; import { getAllBridgeableNetworks, getBridgeQuotes, @@ -570,4 +575,189 @@ describe('Bridge selectors', () => { }); }); }); + + describe('getBridgeQuotes', () => { + it('should return empty values when quotes are not present', () => { + const state = createBridgeMockStore(); + + const result = getBridgeQuotes(state as never); + + expect(result).toStrictEqual({ + activeQuote: undefined, + isLoading: false, + quotesLastFetchedMs: undefined, + recommendedQuote: undefined, + sortedQuotes: [], + }); + }); + + it('should sort quotes by adjustedReturn', () => { + const state = createBridgeMockStore({ + bridgeStateOverrides: { quotes: mockBridgeQuotesNativeErc20 }, + }); + + const { activeQuote, recommendedQuote, sortedQuotes } = getBridgeQuotes( + state as never, + ); + + const quoteMetadataKeys = [ + 'adjustedReturn', + 'toTokenAmount', + 'sentAmount', + 'totalNetworkFee', + 'swapRate', + ]; + expect( + quoteMetadataKeys.every((k) => + Object.keys(activeQuote ?? {}).includes(k), + ), + ).toBe(true); + expect(activeQuote?.quote.requestId).toStrictEqual( + '381c23bc-e3e4-48fe-bc53-257471e388ad', + ); + expect(recommendedQuote?.quote.requestId).toStrictEqual( + '381c23bc-e3e4-48fe-bc53-257471e388ad', + ); + expect(sortedQuotes).toHaveLength(2); + sortedQuotes.forEach((quote, idx) => { + expect( + quoteMetadataKeys.every((k) => Object.keys(quote ?? {}).includes(k)), + ).toBe(true); + expect(quote?.quote.requestId).toStrictEqual( + mockBridgeQuotesNativeErc20[idx]?.quote.requestId, + ); + }); + }); + + it('should sort quotes by ETA', () => { + const state = createBridgeMockStore({ + bridgeSliceOverrides: { sortOrder: SortOrder.ETA_ASC }, + bridgeStateOverrides: { + quotes: [ + ...mockBridgeQuotesNativeErc20, + { + ...mockBridgeQuotesNativeErc20[0], + estimatedProcessingTimeInSeconds: 1, + quote: { + ...mockBridgeQuotesNativeErc20[0].quote, + requestId: 'fastestQuote', + }, + }, + ], + }, + }); + + const { activeQuote, recommendedQuote, sortedQuotes } = getBridgeQuotes( + state as never, + ); + + expect(activeQuote?.quote.requestId).toStrictEqual('fastestQuote'); + expect(recommendedQuote?.quote.requestId).toStrictEqual('fastestQuote'); + expect(sortedQuotes).toHaveLength(3); + expect(sortedQuotes[0]?.quote.requestId).toStrictEqual('fastestQuote'); + expect(sortedQuotes[1]?.quote.requestId).toStrictEqual( + mockBridgeQuotesNativeErc20[1]?.quote.requestId, + ); + expect(sortedQuotes[2]?.quote.requestId).toStrictEqual( + mockBridgeQuotesNativeErc20[0]?.quote.requestId, + ); + }); + + it('should recommend 2nd cheapest quote if ETA exceeds 1 hour', () => { + const state = createBridgeMockStore({ + bridgeSliceOverrides: { sortOrder: SortOrder.COST_ASC }, + bridgeStateOverrides: { + quotes: [ + mockBridgeQuotesNativeErc20[1], + { + ...mockBridgeQuotesNativeErc20[0], + estimatedProcessingTimeInSeconds: + BRIDGE_QUOTE_MAX_ETA_SECONDS + 1, + quote: { + ...mockBridgeQuotesNativeErc20[0].quote, + requestId: 'cheapestQuoteWithLongETA', + }, + }, + ], + }, + }); + + const { activeQuote, recommendedQuote, sortedQuotes } = getBridgeQuotes( + state as never, + ); + + expect(activeQuote?.quote.requestId).toStrictEqual( + '4277a368-40d7-4e82-aa67-74f29dc5f98a', + ); + expect(recommendedQuote?.quote.requestId).toStrictEqual( + '4277a368-40d7-4e82-aa67-74f29dc5f98a', + ); + expect(sortedQuotes).toHaveLength(2); + expect(sortedQuotes[0]?.quote.requestId).toStrictEqual( + '4277a368-40d7-4e82-aa67-74f29dc5f98a', + ); + expect(sortedQuotes[1]?.quote.requestId).toStrictEqual( + 'cheapestQuoteWithLongETA', + ); + }); + + it('should recommend 2nd fastest quote if adjustedReturn is less than 80% of cheapest quote', () => { + const state = createBridgeMockStore({ + bridgeSliceOverrides: { + sortOrder: SortOrder.ETA_ASC, + toTokenExchangeRate: 0.998781, + toNativeExchangeRate: 0.354073, + }, + bridgeStateOverrides: { + quotes: [ + ...mockBridgeQuotesNativeErc20, + { + ...mockBridgeQuotesNativeErc20[0], + estimatedProcessingTimeInSeconds: 1, + quote: { + ...mockBridgeQuotesNativeErc20[0].quote, + requestId: 'fastestQuote', + destTokenAmount: '1', + }, + }, + ], + }, + }); + + const { activeQuote, recommendedQuote, sortedQuotes } = getBridgeQuotes( + state as never, + ); + const { + sentAmount, + totalNetworkFee, + toTokenAmount, + adjustedReturn, + cost, + } = activeQuote ?? {}; + + expect(activeQuote?.quote.requestId).toStrictEqual( + '4277a368-40d7-4e82-aa67-74f29dc5f98a', + ); + expect(recommendedQuote?.quote.requestId).toStrictEqual( + '4277a368-40d7-4e82-aa67-74f29dc5f98a', + ); + expect(sentAmount?.fiat?.toString()).toStrictEqual('25.2425'); + expect(totalNetworkFee?.fiat?.toString()).toStrictEqual( + '2.52459306428938562', + ); + expect(toTokenAmount?.fiat?.toString()).toStrictEqual('24.226654664163'); + expect(adjustedReturn?.fiat?.toString()).toStrictEqual( + '21.70206159987361438', + ); + expect(cost?.fiat?.toString()).toStrictEqual('-3.54043840012638562'); + expect(sortedQuotes).toHaveLength(3); + expect(sortedQuotes[0]?.quote.requestId).toStrictEqual('fastestQuote'); + expect(sortedQuotes[1]?.quote.requestId).toStrictEqual( + '4277a368-40d7-4e82-aa67-74f29dc5f98a', + ); + expect(sortedQuotes[2]?.quote.requestId).toStrictEqual( + '381c23bc-e3e4-48fe-bc53-257471e388ad', + ); + }); + }); }); diff --git a/ui/ducks/bridge/selectors.ts b/ui/ducks/bridge/selectors.ts index 86f4c8155b17..187e9a0a8230 100644 --- a/ui/ducks/bridge/selectors.ts +++ b/ui/ducks/bridge/selectors.ts @@ -1,12 +1,22 @@ -import { NetworkConfiguration } from '@metamask/network-controller'; -import { uniqBy } from 'lodash'; +import { + NetworkConfiguration, + NetworkState, +} from '@metamask/network-controller'; +import { orderBy, uniqBy } from 'lodash'; import { createSelector } from 'reselect'; +import { GasFeeEstimates } from '@metamask/gas-fee-controller'; +import { BigNumber } from 'bignumber.js'; import { getIsBridgeEnabled, getSwapsDefaultToken, SwapsEthToken, } from '../../selectors/selectors'; -import { ALLOWED_BRIDGE_CHAIN_IDS } from '../../../shared/constants/bridge'; +import { + ALLOWED_BRIDGE_CHAIN_IDS, + BRIDGE_PREFERRED_GAS_ESTIMATE, + BRIDGE_QUOTE_MAX_ETA_SECONDS, + BRIDGE_QUOTE_MAX_RETURN_DIFFERENCE_PERCENTAGE, +} from '../../../shared/constants/bridge'; import { BridgeControllerState, BridgeFeatureFlagsKey, @@ -15,21 +25,37 @@ import { } from '../../../app/scripts/controllers/bridge/types'; import { createDeepEqualSelector } from '../../../shared/modules/selectors/util'; import { - NetworkState, getProviderConfig, getNetworkConfigurationsByChainId, } from '../../../shared/modules/selectors/networks'; import { SwapsTokenObject } from '../../../shared/constants/swaps'; +import { getConversionRate, getGasFeeEstimates } from '../metamask/metamask'; import { calcTokenAmount } from '../../../shared/lib/transactions-controller-utils'; // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths import { RequestStatus } from '../../../app/scripts/controllers/bridge/constants'; +import { + QuoteMetadata, + QuoteResponse, + SortOrder, +} from '../../pages/bridge/types'; +import { + calcAdjustedReturn, + calcCost, + calcSentAmount, + calcSwapRate, + calcToAmount, + calcTotalNetworkFee, + isNativeAddress, +} from '../../pages/bridge/utils/quote'; +import { decGWEIToHexWEI } from '../../../shared/modules/conversion.utils'; import { BridgeState } from './bridge'; -type BridgeAppState = NetworkState & { - metamask: { bridgeState: BridgeControllerState } & { - useExternalServices: boolean; - }; +type BridgeAppState = { + metamask: { bridgeState: BridgeControllerState } & NetworkState & { + useExternalServices: boolean; + currencyRates: { [currency: string]: { conversionRate: number } }; + }; bridge: BridgeState; }; @@ -140,46 +166,179 @@ export const getBridgeQuotesConfig = (state: BridgeAppState) => BridgeFeatureFlagsKey.EXTENSION_CONFIG ] ?? {}; +const _getBridgeFeesPerGas = createSelector( + getGasFeeEstimates, + (gasFeeEstimates) => ({ + estimatedBaseFeeInDecGwei: (gasFeeEstimates as GasFeeEstimates) + ?.estimatedBaseFee, + maxPriorityFeePerGasInDecGwei: (gasFeeEstimates as GasFeeEstimates)?.[ + BRIDGE_PREFERRED_GAS_ESTIMATE + ]?.suggestedMaxPriorityFeePerGas, + maxFeePerGas: decGWEIToHexWEI( + (gasFeeEstimates as GasFeeEstimates)?.high?.suggestedMaxFeePerGas, + ), + maxPriorityFeePerGas: decGWEIToHexWEI( + (gasFeeEstimates as GasFeeEstimates)?.high?.suggestedMaxPriorityFeePerGas, + ), + }), +); + +const _getBridgeSortOrder = (state: BridgeAppState) => state.bridge.sortOrder; + +// A dest network can be selected before it's imported +// The cached exchange rate won't be available so the rate from the bridge state is used +const _getToTokenExchangeRate = createSelector( + (state) => state.metamask.currencyRates, + (state: BridgeAppState) => state.bridge.toTokenExchangeRate, + getToChain, + getToToken, + (cachedCurrencyRates, toTokenExchangeRate, toChain, toToken) => { + return ( + toTokenExchangeRate ?? + (isNativeAddress(toToken?.address) && toChain?.nativeCurrency + ? cachedCurrencyRates[toChain.nativeCurrency]?.conversionRate + : null) + ); + }, +); + +const _getQuotesWithMetadata = createDeepEqualSelector( + (state) => state.metamask.bridgeState.quotes, + _getToTokenExchangeRate, + (state: BridgeAppState) => state.bridge.fromTokenExchangeRate, + getConversionRate, + _getBridgeFeesPerGas, + ( + quotes, + toTokenExchangeRate, + fromTokenExchangeRate, + nativeExchangeRate, + { estimatedBaseFeeInDecGwei, maxPriorityFeePerGasInDecGwei }, + ): (QuoteResponse & QuoteMetadata)[] => { + return quotes.map((quote: QuoteResponse) => { + const toTokenAmount = calcToAmount(quote.quote, toTokenExchangeRate); + const totalNetworkFee = calcTotalNetworkFee( + quote, + estimatedBaseFeeInDecGwei, + maxPriorityFeePerGasInDecGwei, + nativeExchangeRate, + ); + const sentAmount = calcSentAmount( + quote.quote, + isNativeAddress(quote.quote.srcAsset.address) + ? nativeExchangeRate + : fromTokenExchangeRate, + ); + const adjustedReturn = calcAdjustedReturn( + toTokenAmount.fiat, + totalNetworkFee.fiat, + ); + + return { + ...quote, + toTokenAmount, + sentAmount, + totalNetworkFee, + adjustedReturn, + swapRate: calcSwapRate(sentAmount.raw, toTokenAmount.raw), + cost: calcCost(adjustedReturn.fiat, sentAmount.fiat), + }; + }); + }, +); + +const _getSortedQuotesWithMetadata = createDeepEqualSelector( + _getQuotesWithMetadata, + _getBridgeSortOrder, + (quotesWithMetadata, sortOrder) => { + switch (sortOrder) { + case SortOrder.ETA_ASC: + return orderBy( + quotesWithMetadata, + (quote) => quote.estimatedProcessingTimeInSeconds, + 'asc', + ); + case SortOrder.COST_ASC: + default: + return orderBy(quotesWithMetadata, ({ cost }) => cost.fiat, 'asc'); + } + }, +); + +const _getRecommendedQuote = createDeepEqualSelector( + _getSortedQuotesWithMetadata, + _getBridgeSortOrder, + (sortedQuotesWithMetadata, sortOrder) => { + if (!sortedQuotesWithMetadata.length) { + return undefined; + } + + const bestReturnValue = BigNumber.max( + sortedQuotesWithMetadata.map( + ({ adjustedReturn }) => adjustedReturn.fiat ?? 0, + ), + ); + + const isFastestQuoteValueReasonable = ( + adjustedReturnInFiat: BigNumber | null, + ) => + adjustedReturnInFiat + ? adjustedReturnInFiat + .div(bestReturnValue) + .gte(BRIDGE_QUOTE_MAX_RETURN_DIFFERENCE_PERCENTAGE) + : true; + + const isBestPricedQuoteETAReasonable = ( + estimatedProcessingTimeInSeconds: number, + ) => estimatedProcessingTimeInSeconds < BRIDGE_QUOTE_MAX_ETA_SECONDS; + + return ( + sortedQuotesWithMetadata.find((quote) => { + return sortOrder === SortOrder.ETA_ASC + ? isFastestQuoteValueReasonable(quote.adjustedReturn.fiat) + : isBestPricedQuoteETAReasonable( + quote.estimatedProcessingTimeInSeconds, + ); + }) ?? sortedQuotesWithMetadata[0] + ); + }, +); + export const getBridgeQuotes = createSelector( - (state: BridgeAppState) => state.metamask.bridgeState.quotes, - (state: BridgeAppState) => state.metamask.bridgeState.quotesLastFetched, - (state: BridgeAppState) => + _getSortedQuotesWithMetadata, + _getRecommendedQuote, + (state) => state.metamask.bridgeState.quotesLastFetched, + (state) => state.metamask.bridgeState.quotesLoadingStatus === RequestStatus.LOADING, (state: BridgeAppState) => state.metamask.bridgeState.quotesRefreshCount, getBridgeQuotesConfig, getQuoteRequest, ( - quotes, + sortedQuotesWithMetadata, + recommendedQuote, quotesLastFetchedMs, isLoading, quotesRefreshCount, { maxRefreshCount }, { insufficientBal }, - ) => { - return { - quotes, - quotesLastFetchedMs, - isLoading, - quotesRefreshCount, - isQuoteGoingToRefresh: insufficientBal - ? false - : quotesRefreshCount < maxRefreshCount, - }; - }, -); - -export const getRecommendedQuote = createSelector( - getBridgeQuotes, - ({ quotes }) => { - return quotes[0]; - }, + ) => ({ + sortedQuotes: sortedQuotesWithMetadata, + recommendedQuote, + activeQuote: recommendedQuote, + quotesLastFetchedMs, + isLoading, + quotesRefreshCount, + isQuoteGoingToRefresh: insufficientBal + ? false + : quotesRefreshCount < maxRefreshCount, + }), ); -export const getToAmount = createSelector(getRecommendedQuote, (quote) => - quote +export const getToAmount = createSelector(getBridgeQuotes, ({ activeQuote }) => + activeQuote ? calcTokenAmount( - quote.quote.destTokenAmount, - quote.quote.destAsset.decimals, + activeQuote.quote.destTokenAmount, + activeQuote.quote.destAsset.decimals, ) : undefined, ); diff --git a/ui/pages/bridge/index.tsx b/ui/pages/bridge/index.tsx index 8c87f1bdae5e..6dd54b424d06 100644 --- a/ui/pages/bridge/index.tsx +++ b/ui/pages/bridge/index.tsx @@ -35,6 +35,7 @@ import { setFromChain, setSrcTokenExchangeRates, } from '../../ducks/bridge/actions'; +import { useGasFeeEstimates } from '../../hooks/useGasFeeEstimates'; import PrepareBridgePage from './prepare/prepare-bridge-page'; import { BridgeCTAButton } from './prepare/bridge-cta-button'; @@ -82,6 +83,9 @@ const CrossChainSwap = () => { }; }, []); + // Needed for refreshing gas estimates + useGasFeeEstimates(providerConfig?.id); + const redirectToDefaultRoute = async () => { history.push({ pathname: DEFAULT_ROUTE, diff --git a/ui/pages/bridge/prepare/bridge-cta-button.tsx b/ui/pages/bridge/prepare/bridge-cta-button.tsx index 06d784f2e0ea..81889c037dbe 100644 --- a/ui/pages/bridge/prepare/bridge-cta-button.tsx +++ b/ui/pages/bridge/prepare/bridge-cta-button.tsx @@ -2,14 +2,13 @@ import React, { useMemo } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { Button } from '../../../components/component-library'; import { - getBridgeQuotes, getFromAmount, getFromChain, getFromToken, - getRecommendedQuote, getToAmount, getToChain, getToToken, + getBridgeQuotes, } from '../../../ducks/bridge/selectors'; import { useI18nContext } from '../../../hooks/useI18nContext'; import useSubmitBridgeTransaction from '../hooks/useSubmitBridgeTransaction'; @@ -27,13 +26,18 @@ export const BridgeCTAButton = () => { const fromAmount = useSelector(getFromAmount); const toAmount = useSelector(getToAmount); - const { isLoading } = useSelector(getBridgeQuotes); - const quoteResponse = useSelector(getRecommendedQuote); + const { isLoading, activeQuote } = useSelector(getBridgeQuotes); const { submitBridgeTransaction } = useSubmitBridgeTransaction(); const isTxSubmittable = - fromToken && toToken && fromChain && toChain && fromAmount && toAmount; + fromToken && + toToken && + fromChain && + toChain && + fromAmount && + toAmount && + activeQuote; const label = useMemo(() => { if (isLoading && !isTxSubmittable) { @@ -59,7 +63,7 @@ export const BridgeCTAButton = () => { data-testid="bridge-cta-button" onClick={() => { if (isTxSubmittable) { - dispatch(submitBridgeTransaction(quoteResponse)); + dispatch(submitBridgeTransaction(activeQuote)); } }} disabled={!isTxSubmittable} diff --git a/ui/pages/bridge/prepare/bridge-input-group.tsx b/ui/pages/bridge/prepare/bridge-input-group.tsx index 266af5b9a3cc..0dbecf6cffdd 100644 --- a/ui/pages/bridge/prepare/bridge-input-group.tsx +++ b/ui/pages/bridge/prepare/bridge-input-group.tsx @@ -28,10 +28,7 @@ import { CHAIN_ID_TOKEN_IMAGE_MAP, } from '../../../../shared/constants/network'; import useLatestBalance from '../../../hooks/bridge/useLatestBalance'; -import { - getBridgeQuotes, - getRecommendedQuote, -} from '../../../ducks/bridge/selectors'; +import { getBridgeQuotes } from '../../../ducks/bridge/selectors'; const generateAssetFromToken = ( chainId: Hex, @@ -82,8 +79,7 @@ export const BridgeInputGroup = ({ >) => { const t = useI18nContext(); - const { isLoading } = useSelector(getBridgeQuotes); - const recommendedQuote = useSelector(getRecommendedQuote); + const { isLoading, activeQuote } = useSelector(getBridgeQuotes); const tokenFiatValue = useTokenFiatAmount( token?.address || undefined, @@ -134,9 +130,7 @@ export const BridgeInputGroup = ({ type={TextFieldType.Number} className="amount-input" placeholder={ - isLoading && !recommendedQuote - ? t('bridgeCalculatingAmount') - : '0' + isLoading && !activeQuote ? t('bridgeCalculatingAmount') : '0' } onChange={(e) => { onAmountChange?.(e.target.value); diff --git a/ui/pages/bridge/quotes/__snapshots__/bridge-quote-card.test.tsx.snap b/ui/pages/bridge/quotes/__snapshots__/bridge-quote-card.test.tsx.snap index cb7b5afb4c77..fea4026e216f 100644 --- a/ui/pages/bridge/quotes/__snapshots__/bridge-quote-card.test.tsx.snap +++ b/ui/pages/bridge/quotes/__snapshots__/bridge-quote-card.test.tsx.snap @@ -90,7 +90,7 @@ exports[`BridgeQuoteCard should render the recommended quote 1`] = `

- 1 USDC = 0.9989 USDC + 0.998877142857142857142857142857142857

@@ -131,13 +131,13 @@ Fees are based on network traffic and transaction complexity. MetaMask does not

- 0.01 ETH + 0.00100007141025952

- $0.01 + 2.52443025734759336

@@ -263,7 +263,7 @@ exports[`BridgeQuoteCard should render the recommended quote while loading new q

- 1 ETH = 2465.4630 USDC + 2443.8902

@@ -304,13 +304,13 @@ Fees are based on network traffic and transaction complexity. MetaMask does not

- 0.01 ETH + 0.00100012486628784

- $0.01 + 2.52456519372708012

diff --git a/ui/pages/bridge/quotes/__snapshots__/bridge-quotes-modal.test.tsx.snap b/ui/pages/bridge/quotes/__snapshots__/bridge-quotes-modal.test.tsx.snap index 41d8a03d1ac1..8d55f0abce94 100644 --- a/ui/pages/bridge/quotes/__snapshots__/bridge-quotes-modal.test.tsx.snap +++ b/ui/pages/bridge/quotes/__snapshots__/bridge-quotes-modal.test.tsx.snap @@ -103,7 +103,7 @@ exports[`BridgeQuotesModal should render the modal 1`] = `

- $0.01 + 2.52443025734759336

- $0.01 + 2.52445909711870752

{ const t = useI18nContext(); - const recommendedQuote = useSelector(getRecommendedQuote); - const { isLoading, isQuoteGoingToRefresh } = useSelector(getBridgeQuotes); - - const { etaInMinutes, totalFees, quoteRate } = - getQuoteDisplayData(recommendedQuote); + const { isLoading, isQuoteGoingToRefresh, activeQuote } = + useSelector(getBridgeQuotes); const secondsUntilNextRefresh = useCountdownTimer(); const [showAllQuotes, setShowAllQuotes] = useState(false); - if (isLoading && !recommendedQuote) { + if (isLoading && !activeQuote) { return ( @@ -37,7 +31,7 @@ export const BridgeQuoteCard = () => { ); } - return etaInMinutes && totalFees && quoteRate ? ( + return activeQuote ? ( { + - diff --git a/ui/pages/bridge/quotes/bridge-quotes-modal.tsx b/ui/pages/bridge/quotes/bridge-quotes-modal.tsx index 7e78e515af6e..ebe2db59838b 100644 --- a/ui/pages/bridge/quotes/bridge-quotes-modal.tsx +++ b/ui/pages/bridge/quotes/bridge-quotes-modal.tsx @@ -17,17 +17,18 @@ import { TextAlign, TextVariant, } from '../../../helpers/constants/design-system'; -import { getBridgeQuotes } from '../../../ducks/bridge/selectors'; -import { getQuoteDisplayData } from '../utils/quote'; import { useI18nContext } from '../../../hooks/useI18nContext'; +import { getBridgeQuotes } from '../../../ducks/bridge/selectors'; +import { formatEtaInMinutes } from '../utils/quote'; export const BridgeQuotesModal = ({ onClose, ...modalProps }: Omit, 'children'>) => { - const { quotes } = useSelector(getBridgeQuotes); const t = useI18nContext(); + const { sortedQuotes } = useSelector(getBridgeQuotes); + return ( @@ -49,12 +50,16 @@ export const BridgeQuotesModal = ({ })} - {quotes.map((quote, index) => { - const { totalFees, etaInMinutes } = getQuoteDisplayData(quote); + {sortedQuotes.map((quote, index) => { + const { totalNetworkFee, estimatedProcessingTimeInSeconds } = quote; return ( - {totalFees?.fiat} - {t('bridgeTimingMinutes', [etaInMinutes])} + {totalNetworkFee?.fiat?.toString()} + + {t('bridgeTimingMinutes', [ + formatEtaInMinutes(estimatedProcessingTimeInSeconds), + ])} + ); })} diff --git a/ui/pages/bridge/types.ts b/ui/pages/bridge/types.ts index a1ee163eca48..b9bf9bf54e7c 100644 --- a/ui/pages/bridge/types.ts +++ b/ui/pages/bridge/types.ts @@ -1,3 +1,21 @@ +import { BigNumber } from 'bignumber.js'; + +// Values derived from the quote response +export type QuoteMetadata = { + totalNetworkFee: { raw: BigNumber; fiat: BigNumber | null }; // gasFees + relayerFees + toTokenAmount: { raw: BigNumber; fiat: BigNumber | null }; + adjustedReturn: { fiat: BigNumber | null }; // destTokenAmount - totalNetworkFee + sentAmount: { raw: BigNumber; fiat: BigNumber | null }; // srcTokenAmount + metabridgeFee + swapRate: BigNumber; // destTokenAmount / sentAmount + cost: { fiat: BigNumber | null }; // sentAmount - adjustedReturn +}; + +// Sort order set by the user +export enum SortOrder { + COST_ASC, + ETA_ASC, +} + // Types copied from Metabridge API export enum BridgeFlag { EXTENSION_CONFIG = 'extension-config', diff --git a/ui/pages/bridge/utils/quote.test.ts b/ui/pages/bridge/utils/quote.test.ts new file mode 100644 index 000000000000..6d03177d1fe7 --- /dev/null +++ b/ui/pages/bridge/utils/quote.test.ts @@ -0,0 +1,252 @@ +import { BigNumber } from 'bignumber.js'; +import { zeroAddress } from '../../../__mocks__/ethereumjs-util'; +import { + calcAdjustedReturn, + calcSentAmount, + calcSwapRate, + calcToAmount, + calcTotalNetworkFee, + formatEtaInMinutes, +} from './quote'; + +const ERC20_TOKEN = { + decimals: 6, + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85'.toLowerCase(), +}; +const NATIVE_TOKEN = { decimals: 18, address: zeroAddress() }; + +describe('Bridge quote utils', () => { + // @ts-expect-error This is missing from the Mocha type definitions + it.each([ + [ + 'native', + NATIVE_TOKEN, + '1009000000000000000', + { toTokenExchangeRate: 1, toNativeExchangeRate: 2521.73 }, + { raw: '1.009', fiat: '2544.42557' }, + ], + [ + 'erc20', + ERC20_TOKEN, + '2543140000', + { toTokenExchangeRate: 0.999781, toNativeExchangeRate: 0.352999 }, + { raw: '2543.14', fiat: '2542.58305234' }, + ], + [ + 'erc20 with null exchange rates', + ERC20_TOKEN, + '2543140000', + { toTokenExchangeRate: null, toNativeExchangeRate: null }, + { raw: '2543.14', fiat: undefined }, + ], + ])( + 'calcToAmount: toToken is %s', + ( + _: string, + destAsset: { decimals: number; address: string }, + destTokenAmount: string, + { + toTokenExchangeRate, + toNativeExchangeRate, + }: { toTokenExchangeRate: number; toNativeExchangeRate: number }, + { raw, fiat }: { raw: string; fiat: string }, + ) => { + const result = calcToAmount( + { + destAsset, + destTokenAmount, + } as never, + toTokenExchangeRate, + toNativeExchangeRate, + ); + expect(result.raw?.toString()).toStrictEqual(raw); + expect(result.fiat?.toString()).toStrictEqual(fiat); + }, + ); + + // @ts-expect-error This is missing from the Mocha type definitions + it.each([ + [ + 'native', + NATIVE_TOKEN, + '1009000000000000000', + { + '0x0000000000000000000000000000000000000000': 1.0000825923770915, + }, + 2515.02, + { + raw: '1.143217728', + fiat: '2875.21545027456', + }, + ], + [ + 'erc20', + ERC20_TOKEN, + '100000000', + { + '0x0b2c639c533813f4aa9d7837caf62653d097ff85': 0.999781, + }, + 2517.14, + { raw: '100.512', fiat: '100.489987872' }, + ], + [ + 'erc20 with null exchange rates', + ERC20_TOKEN, + '2543140000', + {}, + null, + { raw: '2543.652', fiat: undefined }, + ], + ])( + 'calcSentAmount: fromToken is %s', + ( + _: string, + srcAsset: { decimals: number; address: string }, + srcTokenAmount: string, + fromTokenExchangeRates: Record, + fromNativeExchangeRate: number, + { raw, fiat }: { raw: string; fiat: string }, + ) => { + const result = calcSentAmount( + { + srcAsset, + srcTokenAmount, + feeData: { + metabridge: { + amount: Math.pow(8 * 10, srcAsset.decimals / 2), + }, + }, + } as never, + fromTokenExchangeRates, + fromNativeExchangeRate, + ); + expect(result.raw?.toString()).toStrictEqual(raw); + expect(result.fiat?.toString()).toStrictEqual(fiat); + }, + ); + + // @ts-expect-error This is missing from the Mocha type definitions + it.each([ + [ + 'native', + NATIVE_TOKEN, + '1000000000000000000', + '0x0de0b6b3a7640000', + { raw: '2.2351800712e-7', fiat: '0.0005626887014840304' }, + undefined, + ], + [ + 'erc20', + ERC20_TOKEN, + '100000000', + '0x00', + { raw: '2.2351800712e-7', fiat: '0.0005626887014840304' }, + undefined, + ], + [ + 'erc20 with approval', + ERC20_TOKEN, + '100000000', + '0x00', + { raw: '4.4703601424e-7', fiat: '0.0011253774029680608' }, + 1092677, + ], + [ + 'erc20 with relayer fee', + ERC20_TOKEN, + '100000000', + '0x0de0b6b3a7640000', + { raw: '1.00000022351800712', fiat: '2517.4205626887014840304' }, + undefined, + ], + [ + 'native with relayer fee', + NATIVE_TOKEN, + '1000000000000000000', + '0x0de1b6b3a7640000', + { raw: '0.000281698494717776', fiat: '0.70915342457242365792' }, + undefined, + ], + ])( + 'calcTotalNetworkFee: fromToken is %s', + ( + _: string, + srcAsset: { decimals: number; address: string }, + srcTokenAmount: string, + value: string, + { raw, fiat }: { raw: string; fiat: string }, + approvalGasLimit?: number, + ) => { + const feeData = { metabridge: { amount: 0 } }; + const result = calcTotalNetworkFee( + { + trade: { value, gasLimit: 1092677 }, + approval: approvalGasLimit + ? { gasLimit: approvalGasLimit } + : undefined, + quote: { srcAsset, srcTokenAmount, feeData }, + } as never, + '0x19870', + '0x186a0', + 2517.42, + ); + expect(result.raw?.toString()).toStrictEqual(raw); + expect(result.fiat?.toString()).toStrictEqual(fiat); + }, + ); + + // @ts-expect-error This is missing from the Mocha type definitions + it.each([ + [ + 'available', + new BigNumber('100'), + new BigNumber('5'), + new BigNumber('95'), + ], + ['unavailable', null, null, null], + ])( + 'calcAdjustedReturn: fiat amounts are %s', + ( + _: string, + destTokenAmountInFiat: BigNumber, + totalNetworkFeeInFiat: BigNumber, + fiat: string, + ) => { + const result = calcAdjustedReturn( + destTokenAmountInFiat, + totalNetworkFeeInFiat, + ); + expect(result.fiat).toStrictEqual(fiat); + }, + ); + + // @ts-expect-error This is missing from the Mocha type definitions + it.each([ + ['< 1', new BigNumber('100'), new BigNumber('5'), new BigNumber('0.05')], + ['>= 1', new BigNumber('1'), new BigNumber('2000'), new BigNumber('2000')], + ['0', new BigNumber('1'), new BigNumber('0'), new BigNumber('0')], + ])( + 'calcSwapRate: %s rate', + ( + _: string, + sentAmount: BigNumber, + destTokenAmount: BigNumber, + rate: string, + ) => { + const result = calcSwapRate(sentAmount, destTokenAmount); + expect(result).toStrictEqual(rate); + }, + ); + + // @ts-expect-error This is missing from the Mocha type definitions + it.each([ + ['exact', 120, '2'], + ['rounded down', 2000, '33'], + ])( + 'formatEtaInMinutes: %s conversion', + (_: string, estimatedProcessingTimeInSeconds: number, minutes: string) => { + const result = formatEtaInMinutes(estimatedProcessingTimeInSeconds); + expect(result).toStrictEqual(minutes); + }, + ); +}); diff --git a/ui/pages/bridge/utils/quote.ts b/ui/pages/bridge/utils/quote.ts index b5945f64a9df..98018fb11bd6 100644 --- a/ui/pages/bridge/utils/quote.ts +++ b/ui/pages/bridge/utils/quote.ts @@ -1,5 +1,13 @@ +import { zeroAddress } from 'ethereumjs-util'; +import { BigNumber } from 'bignumber.js'; import { calcTokenAmount } from '../../../../shared/lib/transactions-controller-utils'; -import { QuoteResponse, QuoteRequest } from '../types'; +import { QuoteResponse, QuoteRequest, Quote } from '../types'; +import { + hexToDecimal, + sumDecimals, +} from '../../../../shared/modules/conversion.utils'; + +export const isNativeAddress = (address?: string) => address === zeroAddress(); export const isValidQuoteRequest = ( partialRequest: Partial, @@ -33,27 +41,138 @@ export const isValidQuoteRequest = ( ); }; -export const getQuoteDisplayData = (quoteResponse?: QuoteResponse) => { - const { quote, estimatedProcessingTimeInSeconds } = quoteResponse ?? {}; - if (!quoteResponse || !quote || !estimatedProcessingTimeInSeconds) { - return {}; +export const calcToAmount = ( + { destTokenAmount, destAsset }: Quote, + exchangeRate: number | null, +) => { + const normalizedDestAmount = calcTokenAmount( + destTokenAmount, + destAsset.decimals, + ); + return { + raw: normalizedDestAmount, + fiat: exchangeRate ? normalizedDestAmount.mul(exchangeRate) : null, + }; +}; + +export const calcSentAmount = ( + { srcTokenAmount, srcAsset, feeData }: Quote, + exchangeRate: number | null, +) => { + const normalizedSentAmount = calcTokenAmount( + new BigNumber(srcTokenAmount).plus(feeData.metabridge.amount), + srcAsset.decimals, + ); + return { + raw: normalizedSentAmount, + fiat: exchangeRate + ? normalizedSentAmount.mul(exchangeRate.toString()) + : null, + }; +}; + +const calcRelayerFee = ( + bridgeQuote: QuoteResponse, + nativeExchangeRate?: number, +) => { + const { + quote: { srcAsset, srcTokenAmount, feeData }, + trade, + } = bridgeQuote; + const relayerFeeInNative = calcTokenAmount( + new BigNumber(hexToDecimal(trade.value)).minus( + isNativeAddress(srcAsset.address) + ? new BigNumber(srcTokenAmount).plus(feeData.metabridge.amount) + : 0, + ), + 18, + ); + return { + raw: relayerFeeInNative, + fiat: nativeExchangeRate + ? relayerFeeInNative.mul(nativeExchangeRate) + : null, + }; +}; + +const calcTotalGasFee = ( + bridgeQuote: QuoteResponse, + estimatedBaseFeeInDecGwei: string, + maxPriorityFeePerGasInDecGwei: string, + nativeExchangeRate?: number, + l1GasInDecGwei?: BigNumber, +) => { + const { approval, trade } = bridgeQuote; + const totalGasLimitInDec = sumDecimals( + trade.gasLimit?.toString() ?? '0', + approval?.gasLimit?.toString() ?? '0', + ); + const feePerGasInDecGwei = sumDecimals( + estimatedBaseFeeInDecGwei, + maxPriorityFeePerGasInDecGwei, + ); + const gasFeesInDecGwei = totalGasLimitInDec.times(feePerGasInDecGwei); + + if (l1GasInDecGwei) { + gasFeesInDecGwei.add(l1GasInDecGwei); } - const etaInMinutes = (estimatedProcessingTimeInSeconds / 60).toFixed(); - const quoteRate = `1 ${quote.srcAsset.symbol} = ${calcTokenAmount( - quote.destTokenAmount, - quote.destAsset.decimals, - ) - .div(calcTokenAmount(quote.srcTokenAmount, quote.srcAsset.decimals)) - .toFixed(4) - .toString()} ${quote.destAsset.symbol}`; + const gasFeesInDecEth = new BigNumber( + gasFeesInDecGwei.shiftedBy(9).toString(), + ); + const gasFeesInUSD = nativeExchangeRate + ? gasFeesInDecEth.times(nativeExchangeRate) + : null; + + return { + raw: gasFeesInDecEth, + fiat: gasFeesInUSD, + }; +}; +export const calcTotalNetworkFee = ( + bridgeQuote: QuoteResponse, + estimatedBaseFeeInDecGwei: string, + maxPriorityFeePerGasInDecGwei: string, + nativeExchangeRate?: number, +) => { + const normalizedGasFee = calcTotalGasFee( + bridgeQuote, + estimatedBaseFeeInDecGwei, + maxPriorityFeePerGasInDecGwei, + nativeExchangeRate, + ); + const normalizedRelayerFee = calcRelayerFee(bridgeQuote, nativeExchangeRate); return { - etaInMinutes, - totalFees: { - amount: '0.01 ETH', // TODO implement gas + relayer fee - fiat: '$0.01', - }, - quoteRate, + raw: normalizedGasFee.raw.plus(normalizedRelayerFee.raw), + fiat: normalizedGasFee.fiat?.plus(normalizedRelayerFee.fiat || '0') ?? null, }; }; + +export const calcAdjustedReturn = ( + destTokenAmountInFiat: BigNumber | null, + totalNetworkFeeInFiat: BigNumber | null, +) => ({ + fiat: + destTokenAmountInFiat && totalNetworkFeeInFiat + ? destTokenAmountInFiat.minus(totalNetworkFeeInFiat) + : null, +}); + +export const calcSwapRate = ( + sentAmount: BigNumber, + destTokenAmount: BigNumber, +) => destTokenAmount.div(sentAmount); + +export const calcCost = ( + adjustedReturnInFiat: BigNumber | null, + sentAmountInFiat: BigNumber | null, +) => ({ + fiat: + adjustedReturnInFiat && sentAmountInFiat + ? adjustedReturnInFiat.minus(sentAmountInFiat) + : null, +}); + +export const formatEtaInMinutes = (estimatedProcessingTimeInSeconds: number) => + (estimatedProcessingTimeInSeconds / 60).toFixed(); From 7fc8ec0548005a8ce7c8e6e5f0d6f66e968bb717 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Thu, 14 Nov 2024 12:37:08 -0800 Subject: [PATCH 04/25] chore: add L1 fees for Base and Optimism --- .../bridge/bridge-controller.test.ts | 183 +++- .../controllers/bridge/bridge-controller.ts | 80 +- app/scripts/controllers/bridge/types.ts | 12 +- app/scripts/metamask-controller.js | 4 + .../data/bridge/mock-quotes-erc20-native.json | 894 ++++++++++++++++++ .../bridge/mock-quotes-native-erc20-eth.json | 258 +++++ ui/ducks/bridge/selectors.ts | 1 + ui/pages/bridge/types.ts | 4 + ui/pages/bridge/utils/quote.test.ts | 73 +- ui/pages/bridge/utils/quote.ts | 29 +- 10 files changed, 1509 insertions(+), 29 deletions(-) create mode 100644 test/data/bridge/mock-quotes-erc20-native.json create mode 100644 test/data/bridge/mock-quotes-native-erc20-eth.json diff --git a/app/scripts/controllers/bridge/bridge-controller.test.ts b/app/scripts/controllers/bridge/bridge-controller.test.ts index 8369d910f78b..5cadcb1bd375 100644 --- a/app/scripts/controllers/bridge/bridge-controller.test.ts +++ b/app/scripts/controllers/bridge/bridge-controller.test.ts @@ -1,4 +1,6 @@ import nock from 'nock'; +import { BigNumber } from 'bignumber.js'; +import { add0x } from '@metamask/utils'; import { BRIDGE_API_BASE_URL } from '../../../../shared/constants/bridge'; import { CHAIN_IDS } from '../../../../shared/constants/network'; import { SWAPS_API_V2_BASE_URL } from '../../../../shared/constants/swaps'; @@ -7,6 +9,13 @@ import { flushPromises } from '../../../../test/lib/timer-helpers'; // eslint-disable-next-line import/no-restricted-paths import * as bridgeUtil from '../../../../ui/pages/bridge/bridge.util'; import * as balanceUtils from '../../../../shared/modules/bridge-utils/balance'; +import mockBridgeQuotesErc20Native from '../../../../test/data/bridge/mock-quotes-erc20-native.json'; +import mockBridgeQuotesNativeErc20 from '../../../../test/data/bridge/mock-quotes-native-erc20.json'; +import mockBridgeQuotesNativeErc20Eth from '../../../../test/data/bridge/mock-quotes-native-erc20-eth.json'; +// TODO: Remove restricted import +// eslint-disable-next-line import/no-restricted-paths +import { QuoteResponse } from '../../../../ui/pages/bridge/types'; +import { decimalToHex } from '../../../../shared/modules/conversion.utils'; import BridgeController from './bridge-controller'; import { BridgeControllerMessenger } from './types'; import { DEFAULT_BRIDGE_CONTROLLER_STATE } from './constants'; @@ -35,6 +44,7 @@ jest.mock('@ethersproject/providers', () => { Web3Provider: jest.fn(), }; }); +const getLayer1GasFeeMock = jest.fn(); describe('BridgeController', function () { let bridgeController: BridgeController; @@ -42,6 +52,7 @@ describe('BridgeController', function () { beforeAll(function () { bridgeController = new BridgeController({ messenger: messengerMock, + getLayer1GasFee: getLayer1GasFeeMock, }); }); @@ -278,7 +289,7 @@ describe('BridgeController', function () { .mockImplementationOnce(async () => { return await new Promise((resolve) => { return setTimeout(() => { - resolve([1, 2, 3] as never); + resolve(mockBridgeQuotesNativeErc20Eth as never); }, 5000); }); }); @@ -286,7 +297,10 @@ describe('BridgeController', function () { fetchBridgeQuotesSpy.mockImplementationOnce(async () => { return await new Promise((resolve) => { return setTimeout(() => { - resolve([5, 6, 7] as never); + resolve([ + ...mockBridgeQuotesNativeErc20Eth, + ...mockBridgeQuotesNativeErc20Eth, + ] as never); }, 10000); }); }); @@ -363,7 +377,7 @@ describe('BridgeController', function () { expect(bridgeController.state.bridgeState).toEqual( expect.objectContaining({ quoteRequest: { ...quoteRequest, insufficientBal: false }, - quotes: [1, 2, 3], + quotes: mockBridgeQuotesNativeErc20Eth, quotesLoadingStatus: 1, }), ); @@ -377,7 +391,10 @@ describe('BridgeController', function () { expect(bridgeController.state.bridgeState).toEqual( expect.objectContaining({ quoteRequest: { ...quoteRequest, insufficientBal: false }, - quotes: [5, 6, 7], + quotes: [ + ...mockBridgeQuotesNativeErc20Eth, + ...mockBridgeQuotesNativeErc20Eth, + ], quotesLoadingStatus: 1, quotesRefreshCount: 2, }), @@ -394,7 +411,10 @@ describe('BridgeController', function () { expect(bridgeController.state.bridgeState).toEqual( expect.objectContaining({ quoteRequest: { ...quoteRequest, insufficientBal: false }, - quotes: [5, 6, 7], + quotes: [ + ...mockBridgeQuotesNativeErc20Eth, + ...mockBridgeQuotesNativeErc20Eth, + ], quotesLoadingStatus: 2, quotesRefreshCount: 3, }), @@ -404,6 +424,7 @@ describe('BridgeController', function () { ); expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); + expect(getLayer1GasFeeMock).not.toHaveBeenCalled(); }); it('updateBridgeQuoteRequestParams should only poll once if insufficientBal=true', async function () { @@ -426,7 +447,7 @@ describe('BridgeController', function () { .mockImplementationOnce(async () => { return await new Promise((resolve) => { return setTimeout(() => { - resolve([1, 2, 3] as never); + resolve(mockBridgeQuotesNativeErc20Eth as never); }, 5000); }); }); @@ -434,7 +455,10 @@ describe('BridgeController', function () { fetchBridgeQuotesSpy.mockImplementation(async () => { return await new Promise((resolve) => { return setTimeout(() => { - resolve([5, 6, 7] as never); + resolve([ + ...mockBridgeQuotesNativeErc20Eth, + ...mockBridgeQuotesNativeErc20Eth, + ] as never); }, 10000); }); }); @@ -503,7 +527,7 @@ describe('BridgeController', function () { expect(bridgeController.state.bridgeState).toEqual( expect.objectContaining({ quoteRequest: { ...quoteRequest, insufficientBal: true }, - quotes: [1, 2, 3], + quotes: mockBridgeQuotesNativeErc20Eth, quotesLoadingStatus: 1, quotesRefreshCount: 1, }), @@ -519,7 +543,7 @@ describe('BridgeController', function () { expect(bridgeController.state.bridgeState).toEqual( expect.objectContaining({ quoteRequest: { ...quoteRequest, insufficientBal: true }, - quotes: [1, 2, 3], + quotes: mockBridgeQuotesNativeErc20Eth, quotesLoadingStatus: 1, quotesRefreshCount: 1, }), @@ -527,6 +551,7 @@ describe('BridgeController', function () { const secondFetchTime = bridgeController.state.bridgeState.quotesLastFetched; expect(secondFetchTime).toStrictEqual(firstFetchTime); + expect(getLayer1GasFeeMock).not.toHaveBeenCalled(); }); it('updateBridgeQuoteRequestParams should not trigger quote polling if request is invalid', function () { @@ -574,6 +599,7 @@ describe('BridgeController', function () { address: '0x123', provider: jest.fn(), } as never); + const allowance = await bridgeController.getBridgeERC20Allowance( '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', '0xa', @@ -581,4 +607,143 @@ describe('BridgeController', function () { expect(allowance).toBe('100000000000000000000'); }); }); + + // @ts-expect-error This is missing from the Mocha type definitions + it.each([ + [ + 'should append l1GasFees if srcChain is 10 and srcToken is erc20', + mockBridgeQuotesErc20Native, + add0x(decimalToHex(new BigNumber('2608710388388').mul(2).toFixed())), + 12, + ], + [ + 'should append l1GasFees if srcChain is 10 and srcToken is native', + mockBridgeQuotesNativeErc20, + add0x(decimalToHex(new BigNumber('2608710388388').toFixed())), + 2, + ], + [ + 'should not append l1GasFees if srcChain is not 10', + mockBridgeQuotesNativeErc20Eth, + undefined, + 0, + ], + ])( + 'updateBridgeQuoteRequestParams: %s', + async ( + _: string, + quoteResponse: QuoteResponse[], + l1GasFeesInHexWei: string, + getLayer1GasFeeMockCallCount: number, + ) => { + jest.useFakeTimers(); + const stopAllPollingSpy = jest.spyOn(bridgeController, 'stopAllPolling'); + const startPollingByNetworkClientIdSpy = jest.spyOn( + bridgeController, + 'startPollingByNetworkClientId', + ); + const hasSufficientBalanceSpy = jest + .spyOn(balanceUtils, 'hasSufficientBalance') + .mockResolvedValue(false); + messengerMock.call.mockReturnValue({ + address: '0x123', + provider: jest.fn(), + } as never); + getLayer1GasFeeMock.mockResolvedValue('0x25F63418AA4'); + + const fetchBridgeQuotesSpy = jest + .spyOn(bridgeUtil, 'fetchBridgeQuotes') + .mockImplementationOnce(async () => { + return await new Promise((resolve) => { + return setTimeout(() => { + resolve(quoteResponse as never); + }, 1000); + }); + }); + + const quoteParams = { + srcChainId: 10, + destChainId: 1, + srcTokenAddress: '0x4200000000000000000000000000000000000006', + destTokenAddress: '0x0000000000000000000000000000000000000000', + srcTokenAmount: '991250000000000000', + }; + const quoteRequest = { + ...quoteParams, + slippage: 0.5, + walletAddress: '0x123', + }; + await bridgeController.updateBridgeQuoteRequestParams(quoteParams); + + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingByNetworkClientIdSpy).toHaveBeenCalledTimes(1); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); + expect(startPollingByNetworkClientIdSpy).toHaveBeenCalledWith( + expect.anything(), + { + ...quoteRequest, + insufficientBal: true, + }, + ); + + expect(bridgeController.state.bridgeState).toStrictEqual( + expect.objectContaining({ + quoteRequest: { ...quoteRequest, walletAddress: undefined }, + quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes, + quotesLastFetched: DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched, + quotesLoadingStatus: + DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLoadingStatus, + }), + ); + + // // Loading state + jest.advanceTimersByTime(500); + await flushPromises(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith( + { + ...quoteRequest, + insufficientBal: true, + }, + expect.any(AbortSignal), + ); + expect( + bridgeController.state.bridgeState.quotesLastFetched, + ).toStrictEqual(undefined); + + expect(bridgeController.state.bridgeState).toEqual( + expect.objectContaining({ + quoteRequest: { ...quoteRequest, insufficientBal: true }, + quotes: [], + quotesLoadingStatus: 0, + }), + ); + + // After first fetch + jest.advanceTimersByTime(1500); + await flushPromises(); + const { quotes } = bridgeController.state.bridgeState; + expect(bridgeController.state.bridgeState).toEqual( + expect.objectContaining({ + quoteRequest: { ...quoteRequest, insufficientBal: true }, + quotesLoadingStatus: 1, + quotesRefreshCount: 1, + }), + ); + quotes.forEach((quote) => { + const expectedQuote = l1GasFeesInHexWei + ? { ...quote, l1GasFeesInHexWei } + : quote; + expect(quote).toStrictEqual(expectedQuote); + }); + + const firstFetchTime = + bridgeController.state.bridgeState.quotesLastFetched ?? 0; + expect(firstFetchTime).toBeGreaterThan(0); + + expect(getLayer1GasFeeMock).toHaveBeenCalledTimes( + getLayer1GasFeeMockCallCount, + ); + }, + ); }); diff --git a/app/scripts/controllers/bridge/bridge-controller.ts b/app/scripts/controllers/bridge/bridge-controller.ts index 2518e9caa9bd..bbe016ac7aea 100644 --- a/app/scripts/controllers/bridge/bridge-controller.ts +++ b/app/scripts/controllers/bridge/bridge-controller.ts @@ -6,6 +6,8 @@ import { Contract } from '@ethersproject/contracts'; import { abiERC20 } from '@metamask/metamask-eth-abis'; import { Web3Provider } from '@ethersproject/providers'; import { BigNumber } from '@ethersproject/bignumber'; +import { TransactionParams } from '@metamask/transaction-controller'; +import type { ChainId } from '@metamask/controller-utils'; import { fetchBridgeFeatureFlags, fetchBridgeQuotes, @@ -16,14 +18,23 @@ import { // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths import { fetchTopAssetsList } from '../../../../ui/pages/swaps/swaps.util'; -import { decimalToHex } from '../../../../shared/modules/conversion.utils'; -// TODO: Remove restricted import -// eslint-disable-next-line import/no-restricted-paths -import { QuoteRequest } from '../../../../ui/pages/bridge/types'; +import { + decimalToHex, + sumHexes, +} from '../../../../shared/modules/conversion.utils'; +import { + L1GasFees, + QuoteRequest, + QuoteResponse, + TxData, + // TODO: Remove restricted import + // eslint-disable-next-line import/no-restricted-paths +} from '../../../../ui/pages/bridge/types'; // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths import { isValidQuoteRequest } from '../../../../ui/pages/bridge/utils/quote'; import { hasSufficientBalance } from '../../../../shared/modules/bridge-utils/balance'; +import { CHAIN_IDS } from '../../../../shared/constants/network'; import { BRIDGE_CONTROLLER_NAME, DEFAULT_BRIDGE_CONTROLLER_STATE, @@ -53,7 +64,21 @@ export default class BridgeController extends StaticIntervalPollingController< > { #abortController: AbortController | undefined; - constructor({ messenger }: { messenger: BridgeControllerMessenger }) { + #getLayer1GasFee: (params: { + transactionParams: TransactionParams; + chainId: ChainId; + }) => Promise; + + constructor({ + messenger, + getLayer1GasFee, + }: { + messenger: BridgeControllerMessenger; + getLayer1GasFee: (params: { + transactionParams: TransactionParams; + chainId: ChainId; + }) => Promise; + }) { super({ name: BRIDGE_CONTROLLER_NAME, metadata, @@ -91,6 +116,8 @@ export default class BridgeController extends StaticIntervalPollingController< `${BRIDGE_CONTROLLER_NAME}:getBridgeERC20Allowance`, this.getBridgeERC20Allowance.bind(this), ); + + this.#getLayer1GasFee = getLayer1GasFee; } _executePoll = async ( @@ -226,10 +253,12 @@ export default class BridgeController extends StaticIntervalPollingController< this.stopAllPolling(); } + const quotesWithL1GasFees = await this.#appendL1GasFees(quotes); + this.update((_state) => { _state.bridgeState = { ..._state.bridgeState, - quotes, + quotes: quotesWithL1GasFees, quotesLastFetched: Date.now(), quotesLoadingStatus: RequestStatus.FETCHED, quotesRefreshCount: newQuotesRefreshCount, @@ -253,6 +282,45 @@ export default class BridgeController extends StaticIntervalPollingController< } }; + #appendL1GasFees = async ( + quotes: QuoteResponse[], + ): Promise<(QuoteResponse & L1GasFees)[]> => { + return await Promise.all( + quotes.map(async (quoteResponse) => { + const { quote, trade, approval } = quoteResponse; + const chainId = add0x(decimalToHex(quote.srcChainId)) as ChainId; + if ( + [CHAIN_IDS.OPTIMISM.toString(), CHAIN_IDS.BASE.toString()].includes( + chainId, + ) + ) { + const getTxParams = (txData: TxData) => ({ + from: txData.from, + to: txData.to, + value: txData.value, + data: txData.data, + gasLimit: txData.gasLimit?.toString(), + }); + const approvalL1GasFees = approval + ? await this.#getLayer1GasFee({ + transactionParams: getTxParams(approval), + chainId, + }) + : '0'; + const tradeL1GasFees = await this.#getLayer1GasFee({ + transactionParams: getTxParams(trade), + chainId, + }); + return { + ...quoteResponse, + l1GasFeesInHexWei: sumHexes(approvalL1GasFees, tradeL1GasFees), + }; + } + return quoteResponse; + }), + ); + }; + #setTopAssets = async ( chainId: Hex, stateKey: 'srcTopAssets' | 'destTopAssets', diff --git a/app/scripts/controllers/bridge/types.ts b/app/scripts/controllers/bridge/types.ts index 577a9fa99836..c9e221b82418 100644 --- a/app/scripts/controllers/bridge/types.ts +++ b/app/scripts/controllers/bridge/types.ts @@ -9,9 +9,13 @@ import { NetworkControllerGetSelectedNetworkClientAction, } from '@metamask/network-controller'; import { SwapsTokenObject } from '../../../../shared/constants/swaps'; -// TODO: Remove restricted import -// eslint-disable-next-line import/no-restricted-paths -import { QuoteRequest, QuoteResponse } from '../../../../ui/pages/bridge/types'; +import { + L1GasFees, + QuoteRequest, + QuoteResponse, + // TODO: Remove restricted import + // eslint-disable-next-line import/no-restricted-paths +} from '../../../../ui/pages/bridge/types'; import BridgeController from './bridge-controller'; import { BRIDGE_CONTROLLER_NAME, RequestStatus } from './constants'; @@ -39,7 +43,7 @@ export type BridgeControllerState = { destTokens: Record; destTopAssets: { address: string }[]; quoteRequest: Partial; - quotes: QuoteResponse[]; + quotes: (QuoteResponse & L1GasFees)[]; quotesLastFetched?: number; quotesLoadingStatus?: RequestStatus; quotesRefreshCount: number; diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 548b2f9d940c..29e6f09a3aa9 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -2178,6 +2178,10 @@ export default class MetamaskController extends EventEmitter { }); this.bridgeController = new BridgeController({ messenger: bridgeControllerMessenger, + // TODO: Remove once TransactionController exports this action type + getLayer1GasFee: this.txController.getLayer1GasFee.bind( + this.txController, + ), }); const smartTransactionsControllerMessenger = diff --git a/test/data/bridge/mock-quotes-erc20-native.json b/test/data/bridge/mock-quotes-erc20-native.json new file mode 100644 index 000000000000..cd4a1963c6fc --- /dev/null +++ b/test/data/bridge/mock-quotes-erc20-native.json @@ -0,0 +1,894 @@ +[ + { + "quote": { + "requestId": "a63df72a-75ae-4416-a8ab-aff02596c75c", + "srcChainId": 10, + "srcTokenAmount": "991250000000000000", + "srcAsset": { + "address": "0x4200000000000000000000000000000000000006", + "chainId": 10, + "symbol": "WETH", + "decimals": 18, + "name": "Wrapped ETH", + "coinKey": "WETH", + "logoURI": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png", + "priceUSD": "3136", + "icon": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png" + }, + "destChainId": 42161, + "destTokenAmount": "991225000000000000", + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 42161, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3135.46", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + }, + "feeData": { + "metabridge": { + "amount": "8750000000000000", + "asset": { + "address": "0x4200000000000000000000000000000000000006", + "chainId": 10, + "symbol": "WETH", + "decimals": 18, + "name": "Wrapped ETH", + "coinKey": "WETH", + "logoURI": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png", + "priceUSD": "3136", + "icon": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png" + } + } + }, + "bridgeId": "lifi", + "bridges": ["stargate"], + "steps": [ + { + "action": "bridge", + "srcChainId": 10, + "destChainId": 42161, + "protocol": { + "name": "stargate", + "displayName": "StargateV2 (Fast mode)", + "icon": "https://raw.githubusercontent.com/lifinance/types/5685c638772f533edad80fcb210b4bb89e30a50f/src/assets/icons/bridges/stargate.png" + }, + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 10, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3136", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + }, + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 42161, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3135.46", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + }, + "srcAmount": "991250000000000000", + "destAmount": "991225000000000000" + } + ] + }, + "approval": { + "chainId": 10, + "to": "0x4200000000000000000000000000000000000006", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x00", + "data": "0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000de0b6b3a7640000", + "gasLimit": 29122 + }, + "trade": { + "chainId": 10, + "to": "0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x1c8598b5db2e", + "data": "0x3ce33bff000000000000000000000000000000000000000000000000000000000000008000000000000000000000000042000000000000000000000000000000000000060000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006c00000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae0000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae000000000000000000000000000000000000000000000000000000000000a4b1000000000000000000000000420000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc1a09f859b20000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000001f161421c8e000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a51870000000000000000000000000000000000000000000000000000000000000564a6010a660000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000022000000000000000000000000000000000000000000000000000000000000003804bdedbea3f94faf8c8fac5ec841251d96cf5e64e8706ada4688877885e5249520000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c188380000000000000000000000000000000000000000000000000dc1a09f859b2000000000000000000000000000000000000000000000000000000000000000a4b100000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a7374617267617465563200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f6d6574616d61736b2d6272696467650000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000005215e9fd223bc909083fbdb2860213873046e45d0000000000000000000000005215e9fd223bc909083fbdb2860213873046e45d000000000000000000000000420000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc1a09f859b200000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000043ccfd60b00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000d00000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000001c8598b5db2e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c18838000000000000000000000000000000000000000000000000000000000000759e000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c188380000000000000000000000000000000000000000000000000dc1a09f859b2000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c83dc7c11df600d7293f778cb365d3dfcc1ffa2221cf5447a8f2ea407a97792135d9f585ecb68916479dfa1f071f169cbe1cfec831b5ad01f4e4caa09204e5181c", + "gasLimit": 641446 + }, + "estimatedProcessingTimeInSeconds": 64 + }, + { + "quote": { + "requestId": "aad73198-a64d-4310-b12d-9dcc81c412e2", + "srcChainId": 10, + "srcTokenAmount": "991250000000000000", + "srcAsset": { + "address": "0x4200000000000000000000000000000000000006", + "chainId": 10, + "symbol": "WETH", + "decimals": 18, + "name": "Wrapped ETH", + "coinKey": "WETH", + "logoURI": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png", + "priceUSD": "3136", + "icon": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png" + }, + "destChainId": 42161, + "destTokenAmount": "991147696728676903", + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 42161, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3135.46", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + }, + "feeData": { + "metabridge": { + "amount": "8750000000000000", + "asset": { + "address": "0x4200000000000000000000000000000000000006", + "chainId": 10, + "symbol": "WETH", + "decimals": 18, + "name": "Wrapped ETH", + "coinKey": "WETH", + "logoURI": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png", + "priceUSD": "3136", + "icon": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png" + } + } + }, + "bridgeId": "lifi", + "bridges": ["celer"], + "steps": [ + { + "action": "bridge", + "srcChainId": 10, + "destChainId": 42161, + "protocol": { + "name": "celer", + "displayName": "Celer cBridge", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/cbridge.svg" + }, + "srcAsset": { + "address": "0x4200000000000000000000000000000000000006", + "chainId": 10, + "symbol": "WETH", + "decimals": 18, + "name": "Wrapped ETH", + "coinKey": "WETH", + "logoURI": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png", + "priceUSD": "3136", + "icon": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png" + }, + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 42161, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3135.46", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + }, + "srcAmount": "991250000000000000", + "destAmount": "991147696728676903" + } + ] + }, + "approval": { + "chainId": 10, + "to": "0x4200000000000000000000000000000000000006", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x00", + "data": "0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000de0b6b3a7640000", + "gasLimit": 29122 + }, + "trade": { + "chainId": 10, + "to": "0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x00", + "data": "0x3ce33bff000000000000000000000000000000000000000000000000000000000000008000000000000000000000000042000000000000000000000000000000000000060000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001a0000000000000000000000000e7bf43c55551b1036e796e7fd3b125d1f9903e2e000000000000000000000000e7bf43c55551b1036e796e7fd3b125d1f9903e2e000000000000000000000000000000000000000000000000000000000000a4b1000000000000000000000000420000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc1a09f859b20000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000001f161421c8e000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a51870000000000000000000000000000000000000000000000000000000000000050f68486970f93a855b27794b8141d32a89a1e0a5ef360034a2f60a4b917c188380000a4b1420000000000000000000000000000000000000600000000000000000dc1a09f859b20002c03873900002777000000000000000000000000000000002d68122053030bf8df41a8bb8c6f0a9de411c7d94eed376b7d91234e1585fd9f77dcf974dd25160d0c2c16c8382d8aa85b0edd429edff19b4d4cdcf50d0a9d4d1c", + "gasLimit": 203352 + }, + "estimatedProcessingTimeInSeconds": 53 + }, + { + "quote": { + "requestId": "6cfd4952-c9b2-4aec-9349-af39c212f84b", + "srcChainId": 10, + "srcTokenAmount": "991250000000000000", + "srcAsset": { + "address": "0x4200000000000000000000000000000000000006", + "chainId": 10, + "symbol": "WETH", + "decimals": 18, + "name": "Wrapped ETH", + "coinKey": "WETH", + "logoURI": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png", + "priceUSD": "3136", + "icon": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png" + }, + "destChainId": 42161, + "destTokenAmount": "991112862890876485", + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 42161, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3135.46", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + }, + "feeData": { + "metabridge": { + "amount": "8750000000000000", + "asset": { + "address": "0x4200000000000000000000000000000000000006", + "chainId": 10, + "symbol": "WETH", + "decimals": 18, + "name": "Wrapped ETH", + "coinKey": "WETH", + "logoURI": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png", + "priceUSD": "3136", + "icon": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png" + } + } + }, + "bridgeId": "lifi", + "bridges": ["across"], + "steps": [ + { + "action": "bridge", + "srcChainId": 10, + "destChainId": 42161, + "protocol": { + "name": "across", + "displayName": "Across", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png" + }, + "srcAsset": { + "address": "0x4200000000000000000000000000000000000006", + "chainId": 10, + "symbol": "WETH", + "decimals": 18, + "name": "Wrapped ETH", + "coinKey": "WETH", + "logoURI": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png", + "priceUSD": "3136", + "icon": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png" + }, + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 42161, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3135.46", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + }, + "srcAmount": "991250000000000000", + "destAmount": "991112862890876485" + } + ] + }, + "approval": { + "chainId": 10, + "to": "0x4200000000000000000000000000000000000006", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x00", + "data": "0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000de0b6b3a7640000", + "gasLimit": 29122 + }, + "trade": { + "chainId": 10, + "to": "0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x00", + "data": "0x3ce33bff000000000000000000000000000000000000000000000000000000000000008000000000000000000000000042000000000000000000000000000000000000060000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000000000000000000000000000000000000000a4b1000000000000000000000000420000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc1a09f859b20000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000001f161421c8e000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a518700000000000000000000000000000000000000000000000000000000000000902340ab8f6a57ef0c43231b98141d32a89a1e0a5ef360034a2f60a4b917c18838420000000000000000000000000000000000000600000000000000000dc1a09f859b20000000a4b100007dd39298f9ad673645ebffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffd00dfeeddeadbeef8932eb23bad9bddb5cf81426f78279a53c6c3b710000000000000000000000000000000088d06e7971021eee573a0ab6bc3e22039fc1c5ded5d12c4cf2b6311f47f909e06197aa8b2f647ae78ae33a6ea5d23f7c951c0e1686abecd01d7c796990d56f391c", + "gasLimit": 177423 + }, + "estimatedProcessingTimeInSeconds": 15 + }, + { + "quote": { + "requestId": "2c2ba7d8-3922-4081-9f27-63b7d5cc1986", + "srcChainId": 10, + "srcTokenAmount": "991250000000000000", + "srcAsset": { + "address": "0x4200000000000000000000000000000000000006", + "chainId": 10, + "symbol": "WETH", + "decimals": 18, + "name": "Wrapped ETH", + "coinKey": "WETH", + "logoURI": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png", + "priceUSD": "3136", + "icon": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png" + }, + "destChainId": 42161, + "destTokenAmount": "990221346602370184", + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 42161, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3135.46", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + }, + "feeData": { + "metabridge": { + "amount": "8750000000000000", + "asset": { + "address": "0x4200000000000000000000000000000000000006", + "chainId": 10, + "symbol": "WETH", + "decimals": 18, + "name": "Wrapped ETH", + "coinKey": "WETH", + "logoURI": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png", + "priceUSD": "3136", + "icon": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png" + } + } + }, + "bridgeId": "lifi", + "bridges": ["hop"], + "steps": [ + { + "action": "bridge", + "srcChainId": 10, + "destChainId": 42161, + "protocol": { + "name": "hop", + "displayName": "Hop", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/hop.png" + }, + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 10, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3136", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + }, + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 42161, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3135.46", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + }, + "srcAmount": "991250000000000000", + "destAmount": "990221346602370184" + } + ] + }, + "approval": { + "chainId": 10, + "to": "0x4200000000000000000000000000000000000006", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x00", + "data": "0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000de0b6b3a7640000", + "gasLimit": 29122 + }, + "trade": { + "chainId": 10, + "to": "0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x00", + "data": "0x3ce33bff000000000000000000000000000000000000000000000000000000000000008000000000000000000000000042000000000000000000000000000000000000060000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005e00000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae0000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae000000000000000000000000000000000000000000000000000000000000a4b1000000000000000000000000420000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc1a09f859b20000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000001f161421c8e000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a51870000000000000000000000000000000000000000000000000000000000000484ca360ae0000000000000000000000000000000000000000000000000000000000000016000000000000000000000000000000000000000000000000000000000000003200000000000000000000000000000000000000000000000000001168a464edd170000000000000000000000000000000000000000000000000dac6213fc70c84400000000000000000000000000000000000000000000000000000000673a3b080000000000000000000000000000000000000000000000000dac6213fc70c84400000000000000000000000000000000000000000000000000000000673a3b0800000000000000000000000086ca30bef97fb651b8d866d45503684b90cb3312000000000000000000000000710bda329b2a6224e4b44833de30f38e7f81d5640000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000067997b63db4b9059d22e50750707b46a6d48dfbb32e50d85fc3bff1170ed9ca30000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c188380000000000000000000000000000000000000000000000000dc1a09f859b2000000000000000000000000000000000000000000000000000000000000000a4b1000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003686f700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f6d6574616d61736b2d6272696467650000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000005215e9fd223bc909083fbdb2860213873046e45d0000000000000000000000005215e9fd223bc909083fbdb2860213873046e45d000000000000000000000000420000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc1a09f859b200000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000043ccfd60b000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000099d00cde1f22e8afd37d7f103ec3c6c1eb835ace46e502ec8c5ab51413e539461b89c0e26892efd1de1cbfe4222b5589e76231080252197507cce4fb72a30b031b", + "gasLimit": 547501 + }, + "estimatedProcessingTimeInSeconds": 24.159 + }, + { + "quote": { + "requestId": "a77bc7b2-e8c8-4463-89db-5dd239d6aacc", + "srcChainId": 10, + "srcAsset": { + "chainId": 10, + "address": "0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "name": "Wrapped Ether", + "decimals": 18, + "icon": "https://media.socket.tech/tokens/all/WETH", + "logoURI": "https://media.socket.tech/tokens/all/WETH", + "chainAgnosticId": "ETH" + }, + "srcTokenAmount": "991250000000000000", + "destChainId": 42161, + "destAsset": { + "chainId": 42161, + "address": "0x0000000000000000000000000000000000000000", + "symbol": "ETH", + "name": "Ethereum", + "decimals": 18, + "icon": "https://media.socket.tech/tokens/all/ETH", + "logoURI": "https://media.socket.tech/tokens/all/ETH", + "chainAgnosticId": null + }, + "destTokenAmount": "991147696728676903", + "feeData": { + "metabridge": { + "amount": "8750000000000000", + "asset": { + "chainId": 10, + "address": "0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "name": "Wrapped Ether", + "decimals": 18, + "icon": "https://media.socket.tech/tokens/all/WETH", + "logoURI": "https://media.socket.tech/tokens/all/WETH", + "chainAgnosticId": "ETH" + } + } + }, + "bridgeId": "socket", + "bridges": ["celer"], + "steps": [ + { + "action": "bridge", + "srcChainId": 10, + "destChainId": 42161, + "protocol": { + "name": "celer", + "displayName": "Celer", + "icon": "https://socketicons.s3.amazonaws.com/Celer+Light.png" + }, + "srcAsset": { + "chainId": 10, + "address": "0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "name": "Wrapped Ether", + "decimals": 18, + "icon": "https://media.socket.tech/tokens/all/WETH", + "logoURI": "https://media.socket.tech/tokens/all/WETH", + "chainAgnosticId": "ETH" + }, + "destAsset": { + "chainId": 42161, + "address": "0x0000000000000000000000000000000000000000", + "symbol": "ETH", + "name": "Ethereum", + "decimals": 18, + "icon": "https://media.socket.tech/tokens/all/ETH", + "logoURI": "https://media.socket.tech/tokens/all/ETH", + "chainAgnosticId": null + }, + "srcAmount": "991250000000000000", + "destAmount": "991147696728676903" + } + ] + }, + "approval": { + "chainId": 10, + "to": "0x4200000000000000000000000000000000000006", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x00", + "data": "0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000de0b6b3a7640000", + "gasLimit": 29122 + }, + "trade": { + "chainId": 10, + "to": "0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x00", + "data": "0x3ce33bff000000000000000000000000000000000000000000000000000000000000008000000000000000000000000042000000000000000000000000000000000000060000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000f736f636b6574416461707465725632000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001a00000000000000000000000003a23f943181408eac424116af7b7790c94cb97a50000000000000000000000003a23f943181408eac424116af7b7790c94cb97a5000000000000000000000000000000000000000000000000000000000000a4b1000000000000000000000000420000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc1a09f859b20000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000001f161421c8e000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a5187000000000000000000000000000000000000000000000000000000000000004c0000001252106ce9141d32a89a1e0a5ef360034a2f60a4b917c18838420000000000000000000000000000000000000600000000000000000dc1a09f859b20000000a4b1245fa5dd00002777000000000000000000000000000000000000000022be703a074ef6089a301c364c2bbf391d51067ea5cd91515c9ec5421cdaabb23451cd2086f3ebe3e19ff138f3a9be154dcae6033838cc5fabeeb0d260b075cb1c", + "gasLimit": 182048 + }, + "estimatedProcessingTimeInSeconds": 360 + }, + { + "quote": { + "requestId": "4f2154d9b330221b2ad461adf63acc2c", + "srcChainId": 10, + "srcTokenAmount": "991250000000000000", + "srcAsset": { + "id": "10_0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "address": "0x4200000000000000000000000000000000000006", + "chainId": 10, + "name": "Wrapped ETH", + "decimals": 18, + "usdPrice": 3135.9632118339764, + "coingeckoId": "weth", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/weth.svg", + "volatility": 2, + "axelarNetworkSymbol": "WETH", + "subGraphIds": [], + "enabled": true, + "subGraphOnly": false, + "active": true, + "icon": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/weth.svg" + }, + "destChainId": 42161, + "destTokenAmount": "989989428114299041", + "destAsset": { + "id": "42161_0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "symbol": "ETH", + "address": "0x0000000000000000000000000000000000000000", + "chainId": 42161, + "name": "ETH", + "decimals": 18, + "usdPrice": 3133.259355489038, + "coingeckoId": "ethereum", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/eth.svg", + "volatility": 2, + "axelarNetworkSymbol": "ETH", + "subGraphIds": ["chainflip-bridge"], + "enabled": true, + "subGraphOnly": false, + "active": true, + "icon": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/eth.svg" + }, + "feeData": { + "metabridge": { + "amount": "8750000000000000", + "asset": { + "id": "10_0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "address": "0x4200000000000000000000000000000000000006", + "chainId": 10, + "name": "Wrapped ETH", + "decimals": 18, + "usdPrice": 3135.9632118339764, + "coingeckoId": "weth", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/weth.svg", + "volatility": 2, + "axelarNetworkSymbol": "WETH", + "subGraphIds": [], + "enabled": true, + "subGraphOnly": false, + "active": true, + "icon": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/weth.svg" + } + } + }, + "bridgeId": "squid", + "bridges": ["axelar"], + "steps": [ + { + "action": "swap", + "srcChainId": 10, + "destChainId": 10, + "protocol": { + "name": "Uniswap V3", + "displayName": "Uniswap V3" + }, + "srcAsset": { + "id": "10_0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "address": "0x4200000000000000000000000000000000000006", + "chainId": 10, + "name": "Wrapped ETH", + "decimals": 18, + "usdPrice": 3135.9632118339764, + "coingeckoId": "weth", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/weth.svg", + "axelarNetworkSymbol": "WETH", + "subGraphIds": [], + "enabled": true, + "subGraphOnly": false, + "active": true, + "icon": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/weth.svg" + }, + "destAsset": { + "id": "10_0x0b2c639c533813f4aa9d7837caf62653d097ff85", + "symbol": "USDC", + "address": "0x0b2c639c533813f4aa9d7837caf62653d097ff85", + "chainId": 10, + "name": "USDC", + "decimals": 6, + "usdPrice": 1.0003003590332982, + "coingeckoId": "usd-coin", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg", + "axelarNetworkSymbol": "USDC", + "subGraphOnly": false, + "subGraphIds": ["uusdc", "cctp-uusdc-optimism-to-noble"], + "enabled": true, + "active": true, + "icon": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg" + }, + "srcAmount": "991250000000000000", + "destAmount": "3100880215" + }, + { + "action": "swap", + "srcChainId": 10, + "destChainId": 10, + "protocol": { + "name": "Uniswap V3", + "displayName": "Uniswap V3" + }, + "srcAsset": { + "id": "10_0x0b2c639c533813f4aa9d7837caf62653d097ff85", + "symbol": "USDC", + "address": "0x0b2c639c533813f4aa9d7837caf62653d097ff85", + "chainId": 10, + "name": "USDC", + "decimals": 6, + "usdPrice": 1.0003003590332982, + "coingeckoId": "usd-coin", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg", + "axelarNetworkSymbol": "USDC", + "subGraphOnly": false, + "subGraphIds": ["uusdc", "cctp-uusdc-optimism-to-noble"], + "enabled": true, + "active": true, + "icon": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg" + }, + "destAsset": { + "id": "10_0x7f5c764cbc14f9669b88837ca1490cca17c31607", + "symbol": "USDC.e", + "address": "0x7f5c764cbc14f9669b88837ca1490cca17c31607", + "chainId": 10, + "name": "USDC.e", + "decimals": 6, + "usdPrice": 1.0003003590332982, + "coingeckoId": "usd-coin", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg", + "axelarNetworkSymbol": "USDC.e", + "subGraphIds": [], + "enabled": true, + "subGraphOnly": false, + "active": true, + "icon": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg" + }, + "srcAmount": "3100880215", + "destAmount": "3101045779" + }, + { + "action": "swap", + "srcChainId": 10, + "destChainId": 10, + "protocol": { + "name": "Uniswap V3", + "displayName": "Uniswap V3" + }, + "srcAsset": { + "id": "10_0x7f5c764cbc14f9669b88837ca1490cca17c31607", + "symbol": "USDC.e", + "address": "0x7f5c764cbc14f9669b88837ca1490cca17c31607", + "chainId": 10, + "name": "USDC.e", + "decimals": 6, + "usdPrice": 1.0003003590332982, + "coingeckoId": "usd-coin", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg", + "axelarNetworkSymbol": "USDC.e", + "subGraphIds": [], + "enabled": true, + "subGraphOnly": false, + "active": true, + "icon": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg" + }, + "destAsset": { + "id": "10_0xeb466342c4d449bc9f53a865d5cb90586f405215", + "symbol": "USDC.axl", + "address": "0xeb466342c4d449bc9f53a865d5cb90586f405215", + "chainId": 10, + "name": " USDC (Axelar)", + "decimals": 6, + "usdPrice": 1.0003003590332982, + "interchainTokenId": null, + "coingeckoId": "usd-coin", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg", + "axelarNetworkSymbol": "axlUSDC", + "subGraphOnly": false, + "subGraphIds": ["uusdc"], + "enabled": true, + "active": true, + "icon": "https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg" + }, + "srcAmount": "3101045779", + "destAmount": "3101521947" + }, + { + "action": "bridge", + "srcChainId": 10, + "destChainId": 42161, + "protocol": { + "name": "axelar", + "displayName": "Axelar" + }, + "srcAsset": { + "id": "10_0xeb466342c4d449bc9f53a865d5cb90586f405215", + "symbol": "USDC.axl", + "address": "0xeb466342c4d449bc9f53a865d5cb90586f405215", + "chainId": 10, + "name": " USDC (Axelar)", + "decimals": 6, + "usdPrice": 1.0003003590332982, + "interchainTokenId": null, + "coingeckoId": "usd-coin", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg", + "axelarNetworkSymbol": "axlUSDC", + "subGraphOnly": false, + "subGraphIds": ["uusdc"], + "enabled": true, + "active": true, + "icon": "https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg" + }, + "destAsset": { + "id": "42161_0xeb466342c4d449bc9f53a865d5cb90586f405215", + "symbol": "USDC.axl", + "address": "0xeb466342c4d449bc9f53a865d5cb90586f405215", + "chainId": 42161, + "name": " USDC (Axelar)", + "decimals": 6, + "usdPrice": 1.0003003590332982, + "interchainTokenId": null, + "coingeckoId": "usd-coin", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg", + "axelarNetworkSymbol": "axlUSDC", + "subGraphOnly": false, + "subGraphIds": ["uusdc"], + "enabled": true, + "active": true, + "icon": "https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg" + }, + "srcAmount": "3101521947", + "destAmount": "3101521947" + }, + { + "action": "swap", + "srcChainId": 42161, + "destChainId": 42161, + "protocol": { + "name": "Pancakeswap V3", + "displayName": "Pancakeswap V3" + }, + "srcAsset": { + "id": "42161_0xeb466342c4d449bc9f53a865d5cb90586f405215", + "symbol": "USDC.axl", + "address": "0xeb466342c4d449bc9f53a865d5cb90586f405215", + "chainId": 42161, + "name": " USDC (Axelar)", + "decimals": 6, + "usdPrice": 1.0003003590332982, + "interchainTokenId": null, + "coingeckoId": "usd-coin", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg", + "axelarNetworkSymbol": "axlUSDC", + "subGraphOnly": false, + "subGraphIds": ["uusdc"], + "enabled": true, + "active": true, + "icon": "https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg" + }, + "destAsset": { + "id": "42161_0xaf88d065e77c8cc2239327c5edb3a432268e5831", + "symbol": "USDC", + "address": "0xaf88d065e77c8cc2239327c5edb3a432268e5831", + "chainId": 42161, + "name": "USDC", + "decimals": 6, + "usdPrice": 1.0003003590332982, + "coingeckoId": "usd-coin", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg", + "axelarNetworkSymbol": "USDC", + "subGraphOnly": false, + "subGraphIds": [ + "uusdc", + "cctp-uusdc-arbitrum-to-noble", + "chainflip-bridge" + ], + "enabled": true, + "active": true, + "icon": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg" + }, + "srcAmount": "3101521947", + "destAmount": "3100543869" + }, + { + "action": "swap", + "srcChainId": 42161, + "destChainId": 42161, + "protocol": { + "name": "Uniswap V3", + "displayName": "Uniswap V3" + }, + "srcAsset": { + "id": "42161_0xaf88d065e77c8cc2239327c5edb3a432268e5831", + "symbol": "USDC", + "address": "0xaf88d065e77c8cc2239327c5edb3a432268e5831", + "chainId": 42161, + "name": "USDC", + "decimals": 6, + "usdPrice": 1.0003003590332982, + "coingeckoId": "usd-coin", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg", + "axelarNetworkSymbol": "USDC", + "subGraphOnly": false, + "subGraphIds": [ + "uusdc", + "cctp-uusdc-arbitrum-to-noble", + "chainflip-bridge" + ], + "enabled": true, + "active": true, + "icon": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg" + }, + "destAsset": { + "id": "42161_0x82af49447d8a07e3bd95bd0d56f35241523fbab1", + "symbol": "WETH", + "address": "0x82af49447d8a07e3bd95bd0d56f35241523fbab1", + "chainId": 42161, + "name": "Wrapped ETH", + "decimals": 18, + "usdPrice": 3135.9632118339764, + "interchainTokenId": null, + "coingeckoId": "weth", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/weth.svg", + "axelarNetworkSymbol": "WETH", + "subGraphOnly": false, + "subGraphIds": ["arbitrum-weth-wei"], + "enabled": true, + "active": true, + "icon": "https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/weth.svg" + }, + "srcAmount": "3100543869", + "destAmount": "989989428114299041" + } + ] + }, + "approval": { + "chainId": 10, + "to": "0x4200000000000000000000000000000000000006", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x00", + "data": "0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000de0b6b3a7640000", + "gasLimit": 29122 + }, + "trade": { + "chainId": 10, + "to": "0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x4653ce53e6b1", + "data": "0x3ce33bff000000000000000000000000000000000000000000000000000000000000008000000000000000000000000042000000000000000000000000000000000000060000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000e73717569644164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001b60000000000000000000000000ce16f69375520ab01377ce7b88f5ba8c48f8d666000000000000000000000000ce16f69375520ab01377ce7b88f5ba8c48f8d666000000000000000000000000000000000000000000000000000000000000a4b1000000000000000000000000420000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc1a09f859b20000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000001f161421c8e000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a51870000000000000000000000000000000000000000000000000000000000001a14846a1bc600000000000000000000000042000000000000000000000000000000000000060000000000000000000000000000000000000000000000000dc1a09f859b200000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000ce00000000000000000000000000000000000000000000000000000000000000d200000000000000000000000000000000000000000000000000000000000000d600000000000000000000000000000000000000000000000000000000000000dc0000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c188380000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000046000000000000000000000000000000000000000000000000000000000000005e00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000098000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004200000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000044095ea7b300000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000042000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000e404e45aaf00000000000000000000000042000000000000000000000000000000000000060000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff8500000000000000000000000000000000000000000000000000000000000001f4000000000000000000000000ea749fd6ba492dbc14c24fe8a3d08769229b896c0000000000000000000000000000000000000000000000000dc1a09f859b200000000000000000000000000000000000000000000000000000000000b8833d8e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000004200000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000044095ea7b300000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000e404e45aaf0000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000007f5c764cbc14f9669b88837ca1490cca17c316070000000000000000000000000000000000000000000000000000000000000064000000000000000000000000ea749fd6ba492dbc14c24fe8a3d08769229b896c00000000000000000000000000000000000000000000000000000000b8d3ad5700000000000000000000000000000000000000000000000000000000b8c346b000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000007f5c764cbc14f9669b88837ca1490cca17c31607000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000044095ea7b300000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000007f5c764cbc14f9669b88837ca1490cca17c316070000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000e404e45aaf0000000000000000000000007f5c764cbc14f9669b88837ca1490cca17c31607000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f4052150000000000000000000000000000000000000000000000000000000000000064000000000000000000000000ce16f69375520ab01377ce7b88f5ba8c48f8d66600000000000000000000000000000000000000000000000000000000b8d6341300000000000000000000000000000000000000000000000000000000b8ca89fa00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000007f5c764cbc14f9669b88837ca1490cca17c316070000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000761786c55534443000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008417262697472756d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002a307863653136463639333735353230616230313337376365374238386635424138433438463844363636000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c100000000000000000000000000000000000000000000000000000000000000040000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c18838000000000000000000000000000000000000000000000000000000000000000700000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000001e000000000000000000000000000000000000000000000000000000000000003600000000000000000000000000000000000000000000000000000000000000580000000000000000000000000000000000000000000000000000000000000070000000000000000000000000000000000000000000000000000000000000009200000000000000000000000000000000000000000000000000000000000000a8000000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f4052150000000000000000000000000000000000000000000000000000000000000000000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f405215000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000044095ea7b300000000000000000000000032226588378236fd0c7c4053999f88ac0e5cac77ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f4052150000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000032226588378236fd0c7c4053999f88ac0e5cac77000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000e404e45aaf000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f405215000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000000064000000000000000000000000ea749fd6ba492dbc14c24fe8a3d08769229b896c00000000000000000000000000000000000000000000000000000000b8dd781b00000000000000000000000000000000000000000000000000000000b8bb9ee30000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f40521500000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e5831000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000044095ea7b300000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000e404e45aaf000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e583100000000000000000000000082af49447d8a07e3bd95bd0d56f35241523fbab100000000000000000000000000000000000000000000000000000000000001f4000000000000000000000000ea749fd6ba492dbc14c24fe8a3d08769229b896c00000000000000000000000000000000000000000000000000000000b8ce8b7d0000000000000000000000000000000000000000000000000db72b79f837011c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000100000000000000000000000082af49447d8a07e3bd95bd0d56f35241523fbab1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000242e1a7d4d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000082af49447d8a07e3bd95bd0d56f35241523fbab100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c18838000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee00000000000000000000000000000000000000000000000000000000000000004f2154d9b330221b2ad461adf63acc2c000000000000000000000000000000004f2154d9b330221b2ad461adf63acc2c0000000000000000000000003c17c95cdb5887c334bfae85750ce00e1a720a76eff35e60db6c9f3b8384a6d63db3c56f1ce6545b50ba2f250429055ca77e7e6203ddd65a7a4d89ae1af3d61b1c", + "gasLimit": 710342 + }, + "estimatedProcessingTimeInSeconds": 20 + } +] diff --git a/test/data/bridge/mock-quotes-native-erc20-eth.json b/test/data/bridge/mock-quotes-native-erc20-eth.json new file mode 100644 index 000000000000..0afd77760e75 --- /dev/null +++ b/test/data/bridge/mock-quotes-native-erc20-eth.json @@ -0,0 +1,258 @@ +[ + { + "quote": { + "requestId": "34c4136d-8558-4d87-bdea-eef8d2d30d6d", + "srcChainId": 1, + "srcTokenAmount": "991250000000000000", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 1, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3145.41", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + }, + "destChainId": 42161, + "destTokenAmount": "3104367033", + "destAsset": { + "address": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "chainId": 42161, + "symbol": "USDC", + "decimals": 6, + "name": "USD Coin", + "coinKey": "USDC", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "priceUSD": "0.9998000399920016", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png" + }, + "feeData": { + "metabridge": { + "amount": "8750000000000000", + "asset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 1, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3145.41", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + } + } + }, + "bridgeId": "lifi", + "bridges": ["across"], + "steps": [ + { + "action": "swap", + "srcChainId": 1, + "destChainId": 1, + "protocol": { + "name": "0x", + "displayName": "0x", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/exchanges/zerox.png" + }, + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 1, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3145.41", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + }, + "destAsset": { + "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "chainId": 1, + "symbol": "USDC", + "decimals": 6, + "name": "USD Coin", + "coinKey": "USDC", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "priceUSD": "0.9997000899730081", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png" + }, + "srcAmount": "991250000000000000", + "destAmount": "3104701473" + }, + { + "action": "bridge", + "srcChainId": 1, + "destChainId": 42161, + "protocol": { + "name": "across", + "displayName": "Across", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png" + }, + "srcAsset": { + "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "chainId": 1, + "symbol": "USDC", + "decimals": 6, + "name": "USD Coin", + "coinKey": "USDC", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "priceUSD": "0.9997000899730081", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png" + }, + "destAsset": { + "address": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "chainId": 42161, + "symbol": "USDC", + "decimals": 6, + "name": "USD Coin", + "coinKey": "USDC", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "priceUSD": "0.9998000399920016", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png" + }, + "srcAmount": "3104701473", + "destAmount": "3104367033" + } + ] + }, + "trade": { + "chainId": 1, + "to": "0x0439e60F02a8900a951603950d8D4527f400C3f1", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x0de0b6b3a7640000", + "data": "0x3ce33bff000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c696669416461707465725632000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000b400000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae0000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae000000000000000000000000000000000000000000000000000000000000a4b10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000dc1a09f859b20000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000001f161421c8e000000000000000000000000000e6b738da243e8fa2a0ed5915645789add5de51520000000000000000000000000000000000000000000000000000000000000a003a3f733200000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000220000000000000000000000000000000000000000000000000000000000000094027363a1fac5600d1f7e8a4c50087ff1f32a09359512d2379d46b331c6033cc7b000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000001800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c1883800000000000000000000000000000000000000000000000000000000b8211d6e000000000000000000000000000000000000000000000000000000000000a4b10000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000066163726f73730000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f6d6574616d61736b2d6272696467650000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000001ff3684f28c67538d4d072c227340000000000000000000000000000000000001ff3684f28c67538d4d072c227340000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000dc1a09f859b200000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000005c42213bc0b00000000000000000000000070bf6634ee8cb27d04478f184b9b8bb13e5f471000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc1a09f859b200000000000000000000000000070bf6634ee8cb27d04478f184b9b8bb13e5f471000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000004e41fff991f0000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000000000000b909399a00000000000000000000000000000000000000000000000000000000000000a094cc69295a8f2a3016ede239627ab300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000001a000000000000000000000000000000000000000000000000000000000000002c0000000000000000000000000000000000000000000000000000000000000010438c9c147000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000000000000000000000000000000000000002710000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000024d0e30db00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e48d68a15600000000000000000000000070bf6634ee8cb27d04478f184b9b8bb13e5f4710000000000000000000000000000000000000000000000000000000000000271000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002cc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2010001f4a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000012438c9c147000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000000005000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000000000000000002400000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000044a9059cbb000000000000000000000000ad01c20d5886137e056775af56915de824c8fce50000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000620541d325b000000000000000000000000000000000000000000000000000000000673656d70000000000000000000000000000000000000000000000000000000000000080ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000000000000000d00dfeeddeadbeef8932eb23bad9bddb5cf81426f78279a53c6c3b71dcbfe555f9a744b18195d9b52032871d6f3c5a558275c08a71c2b6214801f5161be976f49181b854a3ebcbe1f2b896133b03314a5ff2746e6494c43e59d0c9ee1c", + "gasLimit": 540076 + }, + "estimatedProcessingTimeInSeconds": 45 + }, + { + "quote": { + "requestId": "5bf0f2f0-655c-4e13-a545-1ebad6f9d2bc", + "srcChainId": 1, + "srcTokenAmount": "991250000000000000", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 1, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3145.41", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + }, + "destChainId": 42161, + "destTokenAmount": "3104601473", + "destAsset": { + "address": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "chainId": 42161, + "symbol": "USDC", + "decimals": 6, + "name": "USD Coin", + "coinKey": "USDC", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "priceUSD": "0.9998000399920016", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png" + }, + "feeData": { + "metabridge": { + "amount": "8750000000000000", + "asset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 1, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3145.41", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + } + } + }, + "bridgeId": "lifi", + "bridges": ["celercircle"], + "steps": [ + { + "action": "swap", + "srcChainId": 1, + "destChainId": 1, + "protocol": { + "name": "0x", + "displayName": "0x", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/exchanges/zerox.png" + }, + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 1, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3145.41", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + }, + "destAsset": { + "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "chainId": 1, + "symbol": "USDC", + "decimals": 6, + "name": "USD Coin", + "coinKey": "USDC", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "priceUSD": "0.9997000899730081", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png" + }, + "srcAmount": "991250000000000000", + "destAmount": "3104701473" + }, + { + "action": "bridge", + "srcChainId": 1, + "destChainId": 42161, + "protocol": { + "name": "celercircle", + "displayName": "Circle CCTP", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/circle.png" + }, + "srcAsset": { + "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "chainId": 1, + "symbol": "USDC", + "decimals": 6, + "name": "USD Coin", + "coinKey": "USDC", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "priceUSD": "0.9997000899730081", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png" + }, + "destAsset": { + "address": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "chainId": 42161, + "symbol": "USDC", + "decimals": 6, + "name": "USD Coin", + "coinKey": "USDC", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "priceUSD": "0.9998000399920016", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png" + }, + "srcAmount": "3104701473", + "destAmount": "3104601473" + } + ] + }, + "trade": { + "chainId": 1, + "to": "0x0439e60F02a8900a951603950d8D4527f400C3f1", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x0de0b6b3a7640000", + "data": "0x3ce33bff000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c696669416461707465725632000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a800000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae0000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae000000000000000000000000000000000000000000000000000000000000a4b10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000dc1a09f859b20000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000001f161421c8e000000000000000000000000000e6b738da243e8fa2a0ed5915645789add5de515200000000000000000000000000000000000000000000000000000000000009248fab066300000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000200b431adcab44c6fe13ade53dbd3b714f57922ab5b776924a913685ad0fe680f6c000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000001800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c1883800000000000000000000000000000000000000000000000000000000b8211d6e000000000000000000000000000000000000000000000000000000000000a4b100000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000b63656c6572636972636c65000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f6d6574616d61736b2d6272696467650000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000001ff3684f28c67538d4d072c227340000000000000000000000000000000000001ff3684f28c67538d4d072c227340000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000dc1a09f859b200000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000005c42213bc0b00000000000000000000000070bf6634ee8cb27d04478f184b9b8bb13e5f471000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc1a09f859b200000000000000000000000000070bf6634ee8cb27d04478f184b9b8bb13e5f471000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000004e41fff991f0000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000000000000b909399a00000000000000000000000000000000000000000000000000000000000000a0c0452b52ecb7cf70409b16cd627ab300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000001a000000000000000000000000000000000000000000000000000000000000002c0000000000000000000000000000000000000000000000000000000000000010438c9c147000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000000000000000000000000000000000000002710000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000024d0e30db00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e48d68a15600000000000000000000000070bf6634ee8cb27d04478f184b9b8bb13e5f4710000000000000000000000000000000000000000000000000000000000000271000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002cc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2010001f4a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000012438c9c147000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000000005000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000000000000000002400000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000044a9059cbb000000000000000000000000ad01c20d5886137e056775af56915de824c8fce50000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000047896dca097909ba9db4c9631bce0e53090bce14a9b7d203e21fa80cee7a16fa049aa1ef7d663c2ec3148e698e01774b62ddedc9c2dcd21994e549cd6f318f971b", + "gasLimit": 682910 + }, + "estimatedProcessingTimeInSeconds": 1029.717 + } +] diff --git a/ui/ducks/bridge/selectors.ts b/ui/ducks/bridge/selectors.ts index 187e9a0a8230..3aea2fcbd5df 100644 --- a/ui/ducks/bridge/selectors.ts +++ b/ui/ducks/bridge/selectors.ts @@ -35,6 +35,7 @@ import { calcTokenAmount } from '../../../shared/lib/transactions-controller-uti // eslint-disable-next-line import/no-restricted-paths import { RequestStatus } from '../../../app/scripts/controllers/bridge/constants'; import { + L1GasFees, QuoteMetadata, QuoteResponse, SortOrder, diff --git a/ui/pages/bridge/types.ts b/ui/pages/bridge/types.ts index b9bf9bf54e7c..4be72f200650 100644 --- a/ui/pages/bridge/types.ts +++ b/ui/pages/bridge/types.ts @@ -1,5 +1,9 @@ import { BigNumber } from 'bignumber.js'; +export type L1GasFees = { + l1GasFeesInHexWei?: string; // l1 fees for approval and trade in hex wei, appended by controller +}; + // Values derived from the quote response export type QuoteMetadata = { totalNetworkFee: { raw: BigNumber; fiat: BigNumber | null }; // gasFees + relayerFees diff --git a/ui/pages/bridge/utils/quote.test.ts b/ui/pages/bridge/utils/quote.test.ts index 6d03177d1fe7..6afc148385f2 100644 --- a/ui/pages/bridge/utils/quote.test.ts +++ b/ui/pages/bridge/utils/quote.test.ts @@ -1,5 +1,5 @@ import { BigNumber } from 'bignumber.js'; -import { zeroAddress } from '../../../__mocks__/ethereumjs-util'; +import { zeroAddress } from 'ethereumjs-util'; import { calcAdjustedReturn, calcSentAmount, @@ -195,6 +195,77 @@ describe('Bridge quote utils', () => { }, ); + // @ts-expect-error This is missing from the Mocha type definitions + it.each([ + [ + 'native', + NATIVE_TOKEN, + '1000000000000000000', + '0x0de0b6b3a7640000', + { raw: '0.000002832228395508', fiat: '0.00712990840741974936' }, + undefined, + ], + [ + 'erc20', + ERC20_TOKEN, + '100000000', + '0x00', + { raw: '0.000002832228395508', fiat: '0.00712990840741974936' }, + undefined, + ], + [ + 'erc20 with approval', + ERC20_TOKEN, + '100000000', + '0x00', + { raw: '0.000003055746402628', fiat: '0.00769259710890377976' }, + 1092677, + ], + [ + 'erc20 with relayer fee', + ERC20_TOKEN, + '100000000', + '0x0de0b6b3a7640000', + { raw: '1.000002832228395508', fiat: '2517.42712990840741974936' }, + undefined, + ], + [ + 'native with relayer fee', + NATIVE_TOKEN, + '1000000000000000000', + '0x0de1b6b3a7640000', + { raw: '0.000284307205106164', fiat: '0.71572064427835937688' }, + undefined, + ], + ])( + 'calcTotalNetworkFee: fromToken is %s with l1GasFee', + ( + _: string, + srcAsset: { decimals: number; address: string }, + srcTokenAmount: string, + value: string, + { raw, fiat }: { raw: string; fiat: string }, + approvalGasLimit?: number, + ) => { + const feeData = { metabridge: { amount: 0 } }; + const result = calcTotalNetworkFee( + { + trade: { value, gasLimit: 1092677 }, + approval: approvalGasLimit + ? { gasLimit: approvalGasLimit } + : undefined, + quote: { srcAsset, srcTokenAmount, feeData }, + l1GasFeesInHexWei: '0x25F63418AA4', + } as never, + '0.00010456', + '0.0001', + 2517.42, + ); + expect(result.raw?.toString()).toStrictEqual(raw); + expect(result.fiat?.toString()).toStrictEqual(fiat); + }, + ); + // @ts-expect-error This is missing from the Mocha type definitions it.each([ [ diff --git a/ui/pages/bridge/utils/quote.ts b/ui/pages/bridge/utils/quote.ts index 98018fb11bd6..cff8e489a510 100644 --- a/ui/pages/bridge/utils/quote.ts +++ b/ui/pages/bridge/utils/quote.ts @@ -1,11 +1,18 @@ import { zeroAddress } from 'ethereumjs-util'; import { BigNumber } from 'bignumber.js'; import { calcTokenAmount } from '../../../../shared/lib/transactions-controller-utils'; -import { QuoteResponse, QuoteRequest, Quote } from '../types'; +import { QuoteResponse, QuoteRequest, Quote, L1GasFees } from '../types'; import { hexToDecimal, sumDecimals, } from '../../../../shared/modules/conversion.utils'; +<<<<<<< HEAD +======= +import { formatCurrency } from '../../../helpers/utils/confirm-tx.util'; +import { DEFAULT_PRECISION } from '../../../hooks/useCurrencyDisplay'; +import { Numeric } from '../../../../shared/modules/Numeric'; +import { EtherDenomination } from '../../../../shared/constants/common'; +>>>>>>> 61f029ee7a (chore: add L1 fees for Base and Optimism) export const isNativeAddress = (address?: string) => address === zeroAddress(); @@ -96,13 +103,12 @@ const calcRelayerFee = ( }; const calcTotalGasFee = ( - bridgeQuote: QuoteResponse, + bridgeQuote: QuoteResponse & L1GasFees, estimatedBaseFeeInDecGwei: string, maxPriorityFeePerGasInDecGwei: string, nativeExchangeRate?: number, - l1GasInDecGwei?: BigNumber, ) => { - const { approval, trade } = bridgeQuote; + const { approval, trade, l1GasFeesInHexWei } = bridgeQuote; const totalGasLimitInDec = sumDecimals( trade.gasLimit?.toString() ?? '0', approval?.gasLimit?.toString() ?? '0', @@ -111,11 +117,16 @@ const calcTotalGasFee = ( estimatedBaseFeeInDecGwei, maxPriorityFeePerGasInDecGwei, ); - const gasFeesInDecGwei = totalGasLimitInDec.times(feePerGasInDecGwei); - if (l1GasInDecGwei) { - gasFeesInDecGwei.add(l1GasInDecGwei); - } + const l1GasFeesInDecGWei = Numeric.from( + l1GasFeesInHexWei ?? '0', + 16, + EtherDenomination.WEI, + ).toDenomination(EtherDenomination.GWEI); + + const gasFeesInDecGwei = totalGasLimitInDec + .times(feePerGasInDecGwei) + .add(l1GasFeesInDecGWei); const gasFeesInDecEth = new BigNumber( gasFeesInDecGwei.shiftedBy(9).toString(), @@ -131,7 +142,7 @@ const calcTotalGasFee = ( }; export const calcTotalNetworkFee = ( - bridgeQuote: QuoteResponse, + bridgeQuote: QuoteResponse & L1GasFees, estimatedBaseFeeInDecGwei: string, maxPriorityFeePerGasInDecGwei: string, nativeExchangeRate?: number, From 612c33b77f8c798fe0784d8fc9735bce0c3d4674 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Fri, 25 Oct 2024 13:43:08 -0700 Subject: [PATCH 05/25] chore: display quote metadata --- ui/ducks/bridge/selectors.test.ts | 10 ------- ui/ducks/bridge/selectors.ts | 11 -------- ui/pages/bridge/prepare/bridge-cta-button.tsx | 10 +------ .../bridge/prepare/prepare-bridge-page.tsx | 10 ++++--- .../bridge-quote-card.test.tsx.snap | 12 ++++----- .../bridge-quotes-modal.test.tsx.snap | 6 +++++ ui/pages/bridge/quotes/bridge-quote-card.tsx | 26 ++++++++++++------- .../bridge/quotes/bridge-quotes-modal.tsx | 6 +++-- 8 files changed, 39 insertions(+), 52 deletions(-) diff --git a/ui/ducks/bridge/selectors.test.ts b/ui/ducks/bridge/selectors.test.ts index 416959b34d66..e0ef95890969 100644 --- a/ui/ducks/bridge/selectors.test.ts +++ b/ui/ducks/bridge/selectors.test.ts @@ -22,7 +22,6 @@ import { getFromTokens, getFromTopAssets, getIsBridgeTx, - getToAmount, getToChain, getToChains, getToToken, @@ -397,15 +396,6 @@ describe('Bridge selectors', () => { }); }); - describe('getToAmount', () => { - it('returns hardcoded 0', () => { - const state = createBridgeMockStore(); - const result = getToAmount(state as never); - - expect(result).toStrictEqual(undefined); - }); - }); - describe('getToTokens', () => { it('returns dest tokens from controller state when toChainId is defined', () => { const state = createBridgeMockStore( diff --git a/ui/ducks/bridge/selectors.ts b/ui/ducks/bridge/selectors.ts index 3aea2fcbd5df..20c35541ef89 100644 --- a/ui/ducks/bridge/selectors.ts +++ b/ui/ducks/bridge/selectors.ts @@ -30,12 +30,10 @@ import { } from '../../../shared/modules/selectors/networks'; import { SwapsTokenObject } from '../../../shared/constants/swaps'; import { getConversionRate, getGasFeeEstimates } from '../metamask/metamask'; -import { calcTokenAmount } from '../../../shared/lib/transactions-controller-utils'; // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths import { RequestStatus } from '../../../app/scripts/controllers/bridge/constants'; import { - L1GasFees, QuoteMetadata, QuoteResponse, SortOrder, @@ -335,15 +333,6 @@ export const getBridgeQuotes = createSelector( }), ); -export const getToAmount = createSelector(getBridgeQuotes, ({ activeQuote }) => - activeQuote - ? calcTokenAmount( - activeQuote.quote.destTokenAmount, - activeQuote.quote.destAsset.decimals, - ) - : undefined, -); - export const getIsBridgeTx = createDeepEqualSelector( getFromChain, getToChain, diff --git a/ui/pages/bridge/prepare/bridge-cta-button.tsx b/ui/pages/bridge/prepare/bridge-cta-button.tsx index 81889c037dbe..7355e6579dfa 100644 --- a/ui/pages/bridge/prepare/bridge-cta-button.tsx +++ b/ui/pages/bridge/prepare/bridge-cta-button.tsx @@ -5,7 +5,6 @@ import { getFromAmount, getFromChain, getFromToken, - getToAmount, getToChain, getToToken, getBridgeQuotes, @@ -24,20 +23,13 @@ export const BridgeCTAButton = () => { const toChain = useSelector(getToChain); const fromAmount = useSelector(getFromAmount); - const toAmount = useSelector(getToAmount); const { isLoading, activeQuote } = useSelector(getBridgeQuotes); const { submitBridgeTransaction } = useSubmitBridgeTransaction(); const isTxSubmittable = - fromToken && - toToken && - fromChain && - toChain && - fromAmount && - toAmount && - activeQuote; + fromToken && toToken && fromChain && toChain && fromAmount && activeQuote; const label = useMemo(() => { if (isLoading && !isTxSubmittable) { diff --git a/ui/pages/bridge/prepare/prepare-bridge-page.tsx b/ui/pages/bridge/prepare/prepare-bridge-page.tsx index 1044872c504f..8fde7817af9a 100644 --- a/ui/pages/bridge/prepare/prepare-bridge-page.tsx +++ b/ui/pages/bridge/prepare/prepare-bridge-page.tsx @@ -16,6 +16,7 @@ import { updateQuoteRequestParams, } from '../../../ducks/bridge/actions'; import { + getBridgeQuotes, getFromAmount, getFromChain, getFromChains, @@ -23,7 +24,6 @@ import { getFromTokens, getFromTopAssets, getQuoteRequest, - getToAmount, getToChain, getToChains, getToToken, @@ -71,11 +71,11 @@ const PrepareBridgePage = () => { const toChain = useSelector(getToChain); const fromAmount = useSelector(getFromAmount); - const toAmount = useSelector(getToAmount); const providerConfig = useSelector(getProviderConfig); const quoteRequest = useSelector(getQuoteRequest); + const { activeQuote } = useSelector(getBridgeQuotes); const fromTokenListGenerator = useTokensWithFiltering( fromTokens, @@ -261,8 +261,10 @@ const PrepareBridgePage = () => { testId: 'to-amount', readOnly: true, disabled: true, - value: toAmount?.toString() ?? '0', - className: toAmount ? 'amount-input defined' : 'amount-input', + value: activeQuote?.toTokenAmount?.raw.toFixed(2) ?? '0', + className: activeQuote?.toTokenAmount.raw + ? 'amount-input defined' + : 'amount-input', }} /> diff --git a/ui/pages/bridge/quotes/__snapshots__/bridge-quote-card.test.tsx.snap b/ui/pages/bridge/quotes/__snapshots__/bridge-quote-card.test.tsx.snap index fea4026e216f..38c305ea7ece 100644 --- a/ui/pages/bridge/quotes/__snapshots__/bridge-quote-card.test.tsx.snap +++ b/ui/pages/bridge/quotes/__snapshots__/bridge-quote-card.test.tsx.snap @@ -90,7 +90,7 @@ exports[`BridgeQuoteCard should render the recommended quote 1`] = `

- 0.998877142857142857142857142857142857 + 1 USDC = 1.00

@@ -131,13 +131,13 @@ Fees are based on network traffic and transaction complexity. MetaMask does not

- 0.00100007141025952 + 0.001000

- 2.52443025734759336 + 2.52

@@ -263,7 +263,7 @@ exports[`BridgeQuoteCard should render the recommended quote while loading new q

- 2443.8902 + 1 ETH = 2443.89

@@ -304,13 +304,13 @@ Fees are based on network traffic and transaction complexity. MetaMask does not

- 0.00100012486628784 + 0.001000

- 2.52456519372708012 + 2.52

diff --git a/ui/pages/bridge/quotes/__snapshots__/bridge-quotes-modal.test.tsx.snap b/ui/pages/bridge/quotes/__snapshots__/bridge-quotes-modal.test.tsx.snap index 8d55f0abce94..350e287a1017 100644 --- a/ui/pages/bridge/quotes/__snapshots__/bridge-quotes-modal.test.tsx.snap +++ b/ui/pages/bridge/quotes/__snapshots__/bridge-quotes-modal.test.tsx.snap @@ -105,6 +105,9 @@ exports[`BridgeQuotesModal should render the modal 1`] = ` > 2.52443025734759336

+

@@ -119,6 +122,9 @@ exports[`BridgeQuotesModal should render the modal 1`] = ` > 2.52445909711870752

+

diff --git a/ui/pages/bridge/quotes/bridge-quote-card.tsx b/ui/pages/bridge/quotes/bridge-quote-card.tsx index 6ac7ee51c411..3ec2074a6adc 100644 --- a/ui/pages/bridge/quotes/bridge-quote-card.tsx +++ b/ui/pages/bridge/quotes/bridge-quote-card.tsx @@ -51,16 +51,22 @@ export const BridgeQuoteCard = () => { formatEtaInMinutes(activeQuote.estimatedProcessingTimeInSeconds), ])} /> - - + {activeQuote.swapRate && ( + + )} + {activeQuote.totalNetworkFee && ( + + )} diff --git a/ui/pages/bridge/quotes/bridge-quotes-modal.tsx b/ui/pages/bridge/quotes/bridge-quotes-modal.tsx index ebe2db59838b..68e8a539b203 100644 --- a/ui/pages/bridge/quotes/bridge-quotes-modal.tsx +++ b/ui/pages/bridge/quotes/bridge-quotes-modal.tsx @@ -51,10 +51,12 @@ export const BridgeQuotesModal = ({ {sortedQuotes.map((quote, index) => { - const { totalNetworkFee, estimatedProcessingTimeInSeconds } = quote; + const { totalNetworkFee, estimatedProcessingTimeInSeconds, cost } = + quote; return ( - {totalNetworkFee?.fiat?.toString()} + {totalNetworkFee.fiat?.toString()} + {cost.fiat ? cost.fiat.toFixed(2) : ''} {t('bridgeTimingMinutes', [ formatEtaInMinutes(estimatedProcessingTimeInSeconds), From 25b7602e1c4ac9f4f7b40e522f8cd5d40ba1d6a1 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Tue, 22 Oct 2024 17:05:09 -0700 Subject: [PATCH 06/25] chore: update bridge quote card --- app/_locales/en/messages.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index f51c9708fc20..3d0e91a0a1a0 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -873,7 +873,7 @@ "message": "Select token and amount" }, "bridgeTimingMinutes": { - "message": "$1 minutes", + "message": "$1 min", "description": "$1 is the ticker symbol of a an asset the user is being prompted to purchase" }, "bridgeTimingTooltipText": { From 102a229509f3dab88467fefcae6eee4cd11b820a Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Fri, 25 Oct 2024 13:58:46 -0700 Subject: [PATCH 07/25] chore: format token amounts --- .../bridge-quote-card.test.tsx.snap | 16 +++++------ .../bridge-quotes-modal.test.tsx.snap | 24 +++++++++++++--- ui/pages/bridge/quotes/bridge-quote-card.tsx | 26 ++++++++++++++--- .../bridge/quotes/bridge-quotes-modal.tsx | 28 +++++++++++++++---- ui/pages/bridge/utils/quote.ts | 13 ++++++--- 5 files changed, 81 insertions(+), 26 deletions(-) diff --git a/ui/pages/bridge/quotes/__snapshots__/bridge-quote-card.test.tsx.snap b/ui/pages/bridge/quotes/__snapshots__/bridge-quote-card.test.tsx.snap index 38c305ea7ece..6b69b8ec9a6c 100644 --- a/ui/pages/bridge/quotes/__snapshots__/bridge-quote-card.test.tsx.snap +++ b/ui/pages/bridge/quotes/__snapshots__/bridge-quote-card.test.tsx.snap @@ -61,7 +61,7 @@ exports[`BridgeQuoteCard should render the recommended quote 1`] = `

- 1 minutes + 1 min

@@ -90,7 +90,7 @@ exports[`BridgeQuoteCard should render the recommended quote 1`] = `

- 1 USDC = 1.00 + 1 USDC = 1.00 USDC

@@ -131,13 +131,13 @@ Fees are based on network traffic and transaction complexity. MetaMask does not

- 0.001000 + 0.001000 ETH

- 2.52 + $2.52

@@ -234,7 +234,7 @@ exports[`BridgeQuoteCard should render the recommended quote while loading new q

- 1 minutes + 1 min

@@ -263,7 +263,7 @@ exports[`BridgeQuoteCard should render the recommended quote while loading new q

- 1 ETH = 2443.89 + 1 ETH = 2443.89 USDC

@@ -304,13 +304,13 @@ Fees are based on network traffic and transaction complexity. MetaMask does not

- 0.001000 + 0.001000 ETH

- 2.52 + $2.52

diff --git a/ui/pages/bridge/quotes/__snapshots__/bridge-quotes-modal.test.tsx.snap b/ui/pages/bridge/quotes/__snapshots__/bridge-quotes-modal.test.tsx.snap index 350e287a1017..9a1ab5dc9878 100644 --- a/ui/pages/bridge/quotes/__snapshots__/bridge-quotes-modal.test.tsx.snap +++ b/ui/pages/bridge/quotes/__snapshots__/bridge-quotes-modal.test.tsx.snap @@ -100,10 +100,13 @@ exports[`BridgeQuotesModal should render the modal 1`] = `
+

- 2.52443025734759336 + $2.52

- 1 minutes + across +

+

+ 1 min

+

- 2.52445909711870752 + $2.52

- 26 minutes + celercircle +

+

+ 26 min

diff --git a/ui/pages/bridge/quotes/bridge-quote-card.tsx b/ui/pages/bridge/quotes/bridge-quote-card.tsx index 3ec2074a6adc..767d44d60032 100644 --- a/ui/pages/bridge/quotes/bridge-quote-card.tsx +++ b/ui/pages/bridge/quotes/bridge-quote-card.tsx @@ -8,9 +8,15 @@ import { } from '../../../components/component-library'; import { getBridgeQuotes } from '../../../ducks/bridge/selectors'; import { useI18nContext } from '../../../hooks/useI18nContext'; +import { + formatFiatAmount, + formatTokenAmount, + formatEtaInMinutes, +} from '../utils/quote'; import { useCountdownTimer } from '../../../hooks/bridge/useCountdownTimer'; import MascotBackgroundAnimation from '../../swaps/mascot-background-animation/mascot-background-animation'; -import { formatEtaInMinutes } from '../utils/quote'; +import { getCurrentCurrency } from '../../../selectors'; +import { getNativeCurrency } from '../../../ducks/metamask/metamask'; import { QuoteInfoRow } from './quote-info-row'; import { BridgeQuotesModal } from './bridge-quotes-modal'; @@ -18,6 +24,8 @@ export const BridgeQuoteCard = () => { const t = useI18nContext(); const { isLoading, isQuoteGoingToRefresh, activeQuote } = useSelector(getBridgeQuotes); + const currency = useSelector(getCurrentCurrency); + const ticker = useSelector(getNativeCurrency); const secondsUntilNextRefresh = useCountdownTimer(); @@ -56,15 +64,25 @@ export const BridgeQuoteCard = () => { label={t('quoteRate')} description={`1 ${ activeQuote.quote.srcAsset.symbol - } = ${activeQuote.swapRate.toFixed(2)}`} + } = ${formatTokenAmount( + activeQuote.swapRate, + activeQuote.quote.destAsset.symbol, + )}`} /> )} {activeQuote.totalNetworkFee && ( )} diff --git a/ui/pages/bridge/quotes/bridge-quotes-modal.tsx b/ui/pages/bridge/quotes/bridge-quotes-modal.tsx index 68e8a539b203..fe072e0a72ca 100644 --- a/ui/pages/bridge/quotes/bridge-quotes-modal.tsx +++ b/ui/pages/bridge/quotes/bridge-quotes-modal.tsx @@ -17,9 +17,10 @@ import { TextAlign, TextVariant, } from '../../../helpers/constants/design-system'; -import { useI18nContext } from '../../../hooks/useI18nContext'; +import { formatEtaInMinutes, formatFiatAmount } from '../utils/quote'; import { getBridgeQuotes } from '../../../ducks/bridge/selectors'; -import { formatEtaInMinutes } from '../utils/quote'; +import { useI18nContext } from '../../../hooks/useI18nContext'; +import { getCurrentCurrency } from '../../../selectors'; export const BridgeQuotesModal = ({ onClose, @@ -28,6 +29,7 @@ export const BridgeQuotesModal = ({ const t = useI18nContext(); const { sortedQuotes } = useSelector(getBridgeQuotes); + const currency = useSelector(getCurrentCurrency); return ( @@ -51,12 +53,26 @@ export const BridgeQuotesModal = ({ {sortedQuotes.map((quote, index) => { - const { totalNetworkFee, estimatedProcessingTimeInSeconds, cost } = - quote; + const { + totalNetworkFee, + sentAmount, + adjustedReturn, + estimatedProcessingTimeInSeconds, + quote: { bridges }, + } = quote; return ( - {totalNetworkFee.fiat?.toString()} - {cost.fiat ? cost.fiat.toFixed(2) : ''} + {formatFiatAmount(adjustedReturn.fiat, currency)} + {formatFiatAmount(totalNetworkFee?.fiat, currency)} + + {adjustedReturn.fiat && sentAmount.fiat + ? `-${formatFiatAmount( + sentAmount.fiat.minus(adjustedReturn.fiat), + currency, + )}` + : ''} + + {bridges[0]} {t('bridgeTimingMinutes', [ formatEtaInMinutes(estimatedProcessingTimeInSeconds), diff --git a/ui/pages/bridge/utils/quote.ts b/ui/pages/bridge/utils/quote.ts index cff8e489a510..27e7edc8d1c8 100644 --- a/ui/pages/bridge/utils/quote.ts +++ b/ui/pages/bridge/utils/quote.ts @@ -6,13 +6,9 @@ import { hexToDecimal, sumDecimals, } from '../../../../shared/modules/conversion.utils'; -<<<<<<< HEAD -======= import { formatCurrency } from '../../../helpers/utils/confirm-tx.util'; -import { DEFAULT_PRECISION } from '../../../hooks/useCurrencyDisplay'; import { Numeric } from '../../../../shared/modules/Numeric'; import { EtherDenomination } from '../../../../shared/constants/common'; ->>>>>>> 61f029ee7a (chore: add L1 fees for Base and Optimism) export const isNativeAddress = (address?: string) => address === zeroAddress(); @@ -187,3 +183,12 @@ export const calcCost = ( export const formatEtaInMinutes = (estimatedProcessingTimeInSeconds: number) => (estimatedProcessingTimeInSeconds / 60).toFixed(); + +export const formatTokenAmount = ( + amount: BigNumber, + symbol: string, + precision: number = 2, +) => `${amount.toFixed(precision)} ${symbol}`; + +export const formatFiatAmount = (amount: BigNumber | null, currency: string) => + amount ? formatCurrency(amount.toString(), currency) : undefined; From 93d41cfe7c43a159722f0e48d818584d36060ce0 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Fri, 25 Oct 2024 14:02:04 -0700 Subject: [PATCH 08/25] chore: sort quotes on header click --- test/jest/mock-store.js | 1 + ui/ducks/bridge/actions.ts | 2 ++ .../bridge/quotes/bridge-quotes-modal.tsx | 29 +++++++++++++------ 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/test/jest/mock-store.js b/test/jest/mock-store.js index f75b0df8bd09..41706fc77937 100644 --- a/test/jest/mock-store.js +++ b/test/jest/mock-store.js @@ -711,6 +711,7 @@ export const createBridgeMockStore = ( ...swapsStore, bridge: { toChainId: null, + sortOrder: 0, ...bridgeSliceOverrides, }, metamask: { diff --git a/ui/ducks/bridge/actions.ts b/ui/ducks/bridge/actions.ts index 5c2c9aa71f88..76ac75bb7b75 100644 --- a/ui/ducks/bridge/actions.ts +++ b/ui/ducks/bridge/actions.ts @@ -23,6 +23,7 @@ const { setToToken, setFromTokenInputValue, resetInputFields, + setSortOrder, } = bridgeSlice.actions; export { @@ -33,6 +34,7 @@ export { setFromTokenInputValue, setDestTokenExchangeRates, setSrcTokenExchangeRates, + setSortOrder, }; const callBridgeControllerMethod = ( diff --git a/ui/pages/bridge/quotes/bridge-quotes-modal.tsx b/ui/pages/bridge/quotes/bridge-quotes-modal.tsx index fe072e0a72ca..0a9de383e7f2 100644 --- a/ui/pages/bridge/quotes/bridge-quotes-modal.tsx +++ b/ui/pages/bridge/quotes/bridge-quotes-modal.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { useSelector } from 'react-redux'; import { IconName } from '@metamask/snaps-sdk/jsx'; +import { useDispatch, useSelector } from 'react-redux'; import { Box, Button, @@ -21,12 +21,15 @@ import { formatEtaInMinutes, formatFiatAmount } from '../utils/quote'; import { getBridgeQuotes } from '../../../ducks/bridge/selectors'; import { useI18nContext } from '../../../hooks/useI18nContext'; import { getCurrentCurrency } from '../../../selectors'; +import { setSortOrder } from '../../../ducks/bridge/actions'; +import { SortOrder } from '../types'; export const BridgeQuotesModal = ({ onClose, ...modalProps }: Omit, 'children'>) => { const t = useI18nContext(); + const dispatch = useDispatch(); const { sortedQuotes } = useSelector(getBridgeQuotes); const currency = useSelector(getCurrentCurrency); @@ -42,14 +45,22 @@ export const BridgeQuotesModal = ({ - {[t('bridgeOverallCost'), t('time')].map((label) => { - return ( - - ); - })} + + {sortedQuotes.map((quote, index) => { From 4760e4490157879db6578b9cdbe16e4e6ec67a8a Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Thu, 31 Oct 2024 16:11:51 -0700 Subject: [PATCH 09/25] chore: reusable layout wrappers for Bridge page --- ui/pages/bridge/layout/column.tsx | 23 +++++++++ ui/pages/bridge/layout/index.tsx | 5 ++ ui/pages/bridge/layout/row.tsx | 27 ++++++++++ ui/pages/bridge/layout/tooltip.tsx | 83 ++++++++++++++++++++++++++++++ 4 files changed, 138 insertions(+) create mode 100644 ui/pages/bridge/layout/column.tsx create mode 100644 ui/pages/bridge/layout/index.tsx create mode 100644 ui/pages/bridge/layout/row.tsx create mode 100644 ui/pages/bridge/layout/tooltip.tsx diff --git a/ui/pages/bridge/layout/column.tsx b/ui/pages/bridge/layout/column.tsx new file mode 100644 index 000000000000..6f5b2847b5e5 --- /dev/null +++ b/ui/pages/bridge/layout/column.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { + Container, + ContainerProps, +} from '../../../components/component-library'; +import { + BlockSize, + Display, + FlexDirection, +} from '../../../helpers/constants/design-system'; + +const Column = (props: ContainerProps<'div'>) => { + return ( + + ); +}; + +export default Column; diff --git a/ui/pages/bridge/layout/index.tsx b/ui/pages/bridge/layout/index.tsx new file mode 100644 index 000000000000..d519d211f500 --- /dev/null +++ b/ui/pages/bridge/layout/index.tsx @@ -0,0 +1,5 @@ +import Column from './column'; +import Row from './row'; +import Tooltip from './tooltip'; + +export { Column, Row, Tooltip }; diff --git a/ui/pages/bridge/layout/row.tsx b/ui/pages/bridge/layout/row.tsx new file mode 100644 index 000000000000..eeb94a7e06f7 --- /dev/null +++ b/ui/pages/bridge/layout/row.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { + Container, + ContainerProps, +} from '../../../components/component-library'; +import { + AlignItems, + Display, + FlexDirection, + FlexWrap, + JustifyContent, +} from '../../../helpers/constants/design-system'; + +const Row = (props: ContainerProps<'div'>) => { + return ( + + ); +}; + +export default Row; diff --git a/ui/pages/bridge/layout/tooltip.tsx b/ui/pages/bridge/layout/tooltip.tsx new file mode 100644 index 000000000000..b6781c9bf480 --- /dev/null +++ b/ui/pages/bridge/layout/tooltip.tsx @@ -0,0 +1,83 @@ +import React, { useState } from 'react'; +import { + Box, + Popover, + PopoverHeader, + PopoverPosition, + PopoverProps, + Text, +} from '../../../components/component-library'; +import { + JustifyContent, + TextAlign, + TextColor, +} from '../../../helpers/constants/design-system'; + +const Tooltip = React.forwardRef( + ({ + children, + title, + triggerElement, + disabled = false, + ...props + }: PopoverProps<'div'> & { + triggerElement: React.ReactElement; + disabled?: boolean; + }) => { + const [isOpen, setIsOpen] = useState(false); + const [referenceElement, setReferenceElement] = + useState(null); + + const handleMouseEnter = () => setIsOpen(true); + const handleMouseLeave = () => setIsOpen(false); + const setBoxRef = (ref: HTMLSpanElement | null) => setReferenceElement(ref); + + return ( + <> + + {triggerElement} + + {!disabled && ( + + + {title} + + + {children} + + + )} + + ); + }, +); + +export default Tooltip; From e71513799522069c9f601269347c3adab2b14926 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Tue, 5 Nov 2024 18:40:13 -0800 Subject: [PATCH 10/25] chore: set fromToken when navigating from asset --- ui/hooks/bridge/useBridging.test.ts | 8 ++-- ui/hooks/bridge/useBridging.ts | 11 +++-- ui/hooks/useTokensWithFiltering.ts | 16 +++++++ .../bridge/prepare/prepare-bridge-page.tsx | 43 +++++++++++++++++++ 4 files changed, 68 insertions(+), 10 deletions(-) diff --git a/ui/hooks/bridge/useBridging.test.ts b/ui/hooks/bridge/useBridging.test.ts index 6e3f3b534e35..9fe02c439048 100644 --- a/ui/hooks/bridge/useBridging.test.ts +++ b/ui/hooks/bridge/useBridging.test.ts @@ -123,19 +123,19 @@ describe('useBridging', () => { // @ts-expect-error This is missing from the Mocha type definitions it.each([ [ - '/cross-chain/swaps/prepare-swap-page', + '/cross-chain/swaps/prepare-swap-page?token=0x0000000000000000000000000000000000000000', ETH_SWAPS_TOKEN_OBJECT, 'Home', undefined, ], [ - '/cross-chain/swaps/prepare-swap-page', + '/cross-chain/swaps/prepare-swap-page?token=0x0000000000000000000000000000000000000000', ETH_SWAPS_TOKEN_OBJECT, MetaMetricsSwapsEventSource.TokenView, '&token=native', ], [ - '/cross-chain/swaps/prepare-swap-page', + '/cross-chain/swaps/prepare-swap-page?token=0x00232f2jksdauo', { iconUrl: 'https://icon.url', symbol: 'TEST', @@ -174,7 +174,7 @@ describe('useBridging', () => { result.current.openBridgeExperience(location, token, urlSuffix); - expect(mockDispatch.mock.calls).toHaveLength(2); + expect(mockDispatch.mock.calls).toHaveLength(1); expect(mockHistoryPush.mock.calls).toHaveLength(1); expect(mockHistoryPush).toHaveBeenCalledWith(expectedUrl); expect(openTabSpy).not.toHaveBeenCalled(); diff --git a/ui/hooks/bridge/useBridging.ts b/ui/hooks/bridge/useBridging.ts index c4ae1cca57a3..327951fcccc1 100644 --- a/ui/hooks/bridge/useBridging.ts +++ b/ui/hooks/bridge/useBridging.ts @@ -28,7 +28,6 @@ import { ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) import { isHardwareKeyring } from '../../helpers/utils/hardware'; import { getPortfolioUrl } from '../../helpers/utils/portfolio'; -import { setSwapsFromToken } from '../../ducks/swaps/swaps'; import { SwapsTokenObject } from '../../../shared/constants/swaps'; import { getProviderConfig } from '../../../shared/modules/selectors/networks'; ///: END:ONLY_INCLUDE_IF @@ -74,9 +73,6 @@ const useBridging = () => { chain_id: providerConfig.chainId, }, }); - dispatch( - setSwapsFromToken({ ...token, address: token.address.toLowerCase() }), - ); if (usingHardwareWallet && global.platform.openExtensionInBrowser) { global.platform.openExtensionInBrowser( PREPARE_SWAP_ROUTE, @@ -84,7 +80,11 @@ const useBridging = () => { false, ); } else { - history.push(CROSS_CHAIN_SWAP_ROUTE + PREPARE_SWAP_ROUTE); + history.push( + `${ + CROSS_CHAIN_SWAP_ROUTE + PREPARE_SWAP_ROUTE + }?token=${token.address.toLowerCase()}`, + ); } } else { const portfolioUrl = getPortfolioUrl( @@ -115,7 +115,6 @@ const useBridging = () => { [ isBridgeSupported, isBridgeChain, - setSwapsFromToken, dispatch, usingHardwareWallet, history, diff --git a/ui/hooks/useTokensWithFiltering.ts b/ui/hooks/useTokensWithFiltering.ts index a7ff3f2513ac..d729ce3c1fdc 100644 --- a/ui/hooks/useTokensWithFiltering.ts +++ b/ui/hooks/useTokensWithFiltering.ts @@ -3,6 +3,7 @@ import { useSelector } from 'react-redux'; import { isEqual } from 'lodash'; import { ChainId, hexToBN } from '@metamask/controller-utils'; import { Hex } from '@metamask/utils'; +import { useParams } from 'react-router-dom'; import { getAllTokens, getCurrentCurrency, @@ -39,6 +40,8 @@ export const useTokensWithFiltering = ( sortOrder: TokenBucketPriority = TokenBucketPriority.owned, chainId?: ChainId | Hex, ) => { + const { token: tokenAddressFromUrl } = useParams(); + // Only includes non-native tokens const allDetectedTokens = useSelector(getAllTokens); const { address: selectedAddress, balance: balanceOnActiveChain } = @@ -123,6 +126,18 @@ export const useTokensWithFiltering = ( yield nativeToken; } + if (tokenAddressFromUrl) { + const tokenListItem = + tokenList?.[tokenAddressFromUrl] ?? + tokenList?.[tokenAddressFromUrl.toLowerCase()]; + if (tokenListItem) { + const tokenWithTokenListData = buildTokenData(tokenListItem); + if (tokenWithTokenListData) { + yield tokenWithTokenListData; + } + } + } + if (sortOrder === TokenBucketPriority.owned) { for (const tokenWithBalance of sortedErc20TokensWithBalances) { const cachedTokenData = @@ -171,6 +186,7 @@ export const useTokensWithFiltering = ( currentCurrency, chainId, tokenList, + tokenAddressFromUrl, ], ); diff --git a/ui/pages/bridge/prepare/prepare-bridge-page.tsx b/ui/pages/bridge/prepare/prepare-bridge-page.tsx index 8fde7817af9a..f6f02edb95c3 100644 --- a/ui/pages/bridge/prepare/prepare-bridge-page.tsx +++ b/ui/pages/bridge/prepare/prepare-bridge-page.tsx @@ -4,6 +4,7 @@ import classnames from 'classnames'; import { debounce } from 'lodash'; import { Hex } from '@metamask/utils'; import { zeroAddress } from 'ethereumjs-util'; +import { useHistory, useLocation } from 'react-router-dom'; import { setDestTokenExchangeRates, setFromChain, @@ -147,6 +148,48 @@ const PrepareBridgePage = () => { SECOND, ); + const { search } = useLocation(); + const history = useHistory(); + + useEffect(() => { + if (!fromChain?.chainId || Object.keys(fromTokens).length === 0) { + return; + } + + const searchParams = new URLSearchParams(search); + const tokenAddressFromUrl = searchParams.get('token'); + if (!tokenAddressFromUrl) { + return; + } + + const removeTokenFromUrl = () => { + const newParams = new URLSearchParams(searchParams); + newParams.delete('token'); + history.replace({ + search: newParams.toString(), + }); + }; + + switch (tokenAddressFromUrl) { + case fromToken?.address?.toLowerCase(): + // If the token is already set, remove the query param + removeTokenFromUrl(); + break; + case fromTokens[tokenAddressFromUrl]?.address?.toLowerCase(): { + // If there is a matching fromToken, set it as the fromToken + const matchedToken = fromTokens[tokenAddressFromUrl]; + dispatch(setFromToken(matchedToken)); + debouncedFetchFromExchangeRate(fromChain.chainId, matchedToken.address); + removeTokenFromUrl(); + break; + } + default: + // Otherwise remove query param + removeTokenFromUrl(); + break; + } + }, [fromChain, fromToken, fromTokens, search]); + return (
From 04faf4d19a502b9f16c21c06fb7fe36cea9d42fa Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Tue, 22 Oct 2024 18:32:38 -0700 Subject: [PATCH 11/25] chore: show all quotes in a modal --- app/_locales/en/messages.json | 3 + ui/ducks/bridge/selectors.ts | 7 +- .../quotes/bridge-quotes-modal.stories.tsx | 102 ++++++++ .../bridge/quotes/bridge-quotes-modal.tsx | 218 +++++++++++++----- ui/pages/bridge/quotes/index.scss | 81 +++++++ ui/pages/bridge/utils/quote.ts | 2 +- 6 files changed, 347 insertions(+), 66 deletions(-) create mode 100644 ui/pages/bridge/quotes/bridge-quotes-modal.stories.tsx diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 3d0e91a0a1a0..8abefa79fdde 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -863,6 +863,9 @@ "bridgeFrom": { "message": "Bridge from" }, + "bridgeLowest": { + "message": "Lowest" + }, "bridgeOverallCost": { "message": "Overall cost" }, diff --git a/ui/ducks/bridge/selectors.ts b/ui/ducks/bridge/selectors.ts index 20c35541ef89..15104d0aaa69 100644 --- a/ui/ducks/bridge/selectors.ts +++ b/ui/ducks/bridge/selectors.ts @@ -182,7 +182,8 @@ const _getBridgeFeesPerGas = createSelector( }), ); -const _getBridgeSortOrder = (state: BridgeAppState) => state.bridge.sortOrder; +export const getBridgeSortOrder = (state: BridgeAppState) => + state.bridge.sortOrder; // A dest network can be selected before it's imported // The cached exchange rate won't be available so the rate from the bridge state is used @@ -248,7 +249,7 @@ const _getQuotesWithMetadata = createDeepEqualSelector( const _getSortedQuotesWithMetadata = createDeepEqualSelector( _getQuotesWithMetadata, - _getBridgeSortOrder, + getBridgeSortOrder, (quotesWithMetadata, sortOrder) => { switch (sortOrder) { case SortOrder.ETA_ASC: @@ -266,7 +267,7 @@ const _getSortedQuotesWithMetadata = createDeepEqualSelector( const _getRecommendedQuote = createDeepEqualSelector( _getSortedQuotesWithMetadata, - _getBridgeSortOrder, + getBridgeSortOrder, (sortedQuotesWithMetadata, sortOrder) => { if (!sortedQuotesWithMetadata.length) { return undefined; diff --git a/ui/pages/bridge/quotes/bridge-quotes-modal.stories.tsx b/ui/pages/bridge/quotes/bridge-quotes-modal.stories.tsx new file mode 100644 index 000000000000..2badd6f989e7 --- /dev/null +++ b/ui/pages/bridge/quotes/bridge-quotes-modal.stories.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import configureStore from '../../../store/store'; +import { BridgeQuotesModal } from './bridge-quotes-modal'; +import { createBridgeMockStore } from '../../../../test/jest/mock-store'; +import mockBridgeQuotesErc20Erc20 from '../../../../test/data/bridge/mock-quotes-erc20-erc20.json'; + +const storybook = { + title: 'Pages/Bridge/BridgeQuotesModal', + component: BridgeQuotesModal, +}; + +export const NoTokenPricesAvailableStory = () => { + return {}} isOpen={true} />; +}; +NoTokenPricesAvailableStory.storyName = 'Token Prices Not Available'; +NoTokenPricesAvailableStory.decorators = [ + (story) => ( + + {story()} + + ), +]; + +export const DefaultStory = () => { + return {}} isOpen={true} />; +}; +DefaultStory.storyName = 'Default'; +DefaultStory.decorators = [ + (story) => ( + + {story()} + + ), +]; + +export const PositiveArbitrage = () => { + return {}} isOpen={true} />; +}; +PositiveArbitrage.decorators = [ + (story) => ( + + {story()} + + ), +]; + +export default storybook; diff --git a/ui/pages/bridge/quotes/bridge-quotes-modal.tsx b/ui/pages/bridge/quotes/bridge-quotes-modal.tsx index 0a9de383e7f2..f053c2be36fc 100644 --- a/ui/pages/bridge/quotes/bridge-quotes-modal.tsx +++ b/ui/pages/bridge/quotes/bridge-quotes-modal.tsx @@ -1,10 +1,9 @@ -import React from 'react'; +import React, { useState } from 'react'; import { IconName } from '@metamask/snaps-sdk/jsx'; import { useDispatch, useSelector } from 'react-redux'; import { Box, Button, - ButtonVariant, Icon, IconSize, Modal, @@ -17,12 +16,21 @@ import { TextAlign, TextVariant, } from '../../../helpers/constants/design-system'; -import { formatEtaInMinutes, formatFiatAmount } from '../utils/quote'; -import { getBridgeQuotes } from '../../../ducks/bridge/selectors'; +import { + formatEtaInMinutes, + formatFiatAmount, + formatTokenAmount, +} from '../utils/quote'; import { useI18nContext } from '../../../hooks/useI18nContext'; import { getCurrentCurrency } from '../../../selectors'; import { setSortOrder } from '../../../ducks/bridge/actions'; -import { SortOrder } from '../types'; +import { SortOrder, QuoteMetadata, QuoteResponse } from '../types'; +import { Footer } from '../../../components/multichain/pages/page'; +import { useCountdownTimer } from '../../../hooks/bridge/useCountdownTimer'; +import { + getBridgeQuotes, + getBridgeSortOrder, +} from '../../../ducks/bridge/selectors'; export const BridgeQuotesModal = ({ onClose, @@ -31,69 +39,155 @@ export const BridgeQuotesModal = ({ const t = useI18nContext(); const dispatch = useDispatch(); - const { sortedQuotes } = useSelector(getBridgeQuotes); + const { sortedQuotes, activeQuote, recommendedQuote } = + useSelector(getBridgeQuotes); + const sortOrder = useSelector(getBridgeSortOrder); const currency = useSelector(getCurrentCurrency); + const { isLoading } = useSelector(getBridgeQuotes); + + const secondsUntilNextRefresh = useCountdownTimer(); + + const [expandedQuote, setExpandedQuote] = useState< + (QuoteResponse & QuoteMetadata) | undefined + >(undefined); return ( - - - - {t('swapSelectAQuote')} - - - - - - - - {sortedQuotes.map((quote, index) => { - const { - totalNetworkFee, - sentAmount, - adjustedReturn, - estimatedProcessingTimeInSeconds, - quote: { bridges }, - } = quote; - return ( - - {formatFiatAmount(adjustedReturn.fiat, currency)} - {formatFiatAmount(totalNetworkFee?.fiat, currency)} - - {adjustedReturn.fiat && sentAmount.fiat - ? `-${formatFiatAmount( - sentAmount.fiat.minus(adjustedReturn.fiat), - currency, - )}` - : ''} - - {bridges[0]} - - {t('bridgeTimingMinutes', [ - formatEtaInMinutes(estimatedProcessingTimeInSeconds), - ])} - - - ); - })} - - + {expandedQuote ? ( + + setExpandedQuote(undefined)}> + + {t('swapQuoteDetails')} + + + + {JSON.stringify(expandedQuote)} + +
+ +
+
+ ) : ( + + + + {t('swapSelectAQuote')} + + + + {/* HEADERS */} + + + dispatch(setSortOrder(SortOrder.ADJUSTED_RETURN_DESC)) + } + className={ + sortOrder === SortOrder.ADJUSTED_RETURN_DESC + ? 'active-sort' + : '' + } + > + + {t('bridgeOverallCost')} + + dispatch(setSortOrder(SortOrder.ETA_ASC))} + className={sortOrder === SortOrder.ETA_ASC ? 'active-sort' : ''} + > + + {t('time')} + + + {/* QUOTE LIST */} + + {sortedQuotes.map((quote, index) => { + const { + totalNetworkFee, + estimatedProcessingTimeInSeconds, + toTokenAmount, + cost, + quote: { destAsset, bridges, requestId }, + } = quote; + const isQuoteActive = requestId === activeQuote?.quote.requestId; + const isQuoteRecommended = + requestId === recommendedQuote?.quote.requestId; + + return ( + setExpandedQuote(quote)} + > + {isQuoteActive && ( + + )} + + {cost.fiat && ( + + {isQuoteRecommended && ( + + {t( + sortOrder === SortOrder.ADJUSTED_RETURN_DESC + ? 'bridgeLowest' + : 'bridgeFastest', + )} + + )} + {formatFiatAmount(cost.fiat, currency)} + + )} + + + {formatFiatAmount(toTokenAmount.fiat, currency) ?? + formatTokenAmount( + toTokenAmount.raw, + destAsset.symbol, + )} + + + + + {formatFiatAmount(totalNetworkFee?.fiat, currency)} + + + + + + {bridges[0]} + + {t('bridgeTimingMinutes', [ + formatEtaInMinutes(estimatedProcessingTimeInSeconds), + ])} + + + + + ); + })} + + + {!isLoading && ( + {t('swapNewQuoteIn', [secondsUntilNextRefresh])} + )} + + + )}
); }; diff --git a/ui/pages/bridge/quotes/index.scss b/ui/pages/bridge/quotes/index.scss index 6d52c9e1e753..ba0d8ac62e16 100644 --- a/ui/pages/bridge/quotes/index.scss +++ b/ui/pages/bridge/quotes/index.scss @@ -64,20 +64,101 @@ } .quotes-modal { + .mm-modal-content__dialog { + display: flex; + position: relative; + height: 100%; + } + &__column-header { display: flex; flex-direction: row; justify-content: space-between; + padding: 12px 16px 4px 16px; + color: var(--color-text-alternative); + + .active-sort { + color: var(--color-text-default); + } + + span, p { + display: flex; + align-items: center; + gap: 5px; + padding-right: 26px; + color: inherit; + } } &__quotes { display: flex; flex-direction: column; + .active-quote { + background: var(--color-primary-muted); + } + &__row { display: flex; flex-direction: row; justify-content: space-between; + height: 66px; + align-items: center; + flex-shrink: 0; + align-self: stretch; + padding: 12px 16px; + position: relative; + + &-bar { + position: absolute; + left: 4px; + top: 4px; + height: 58px; + width: 4px; + border-radius: 8px; + background: var(--color-primary-default); + } + + span { + display: flex; + align-items: flex-start; + } + + &-left { + flex-direction: column; + gap: 4px; + + span { + flex-direction: row; + gap: 10px; + + .description { + color: var(--color-primary-default); + } + + & > span { + align-items: center; + gap: 4px; + } + } + } + + &-right { + flex-direction: row; + gap: 10px; + } } } + + &__timer { + display: flex; + position: absolute; + width: 100%; + height: 70px; + justify-content: center; + align-items: center; + left: 4px; + bottom: 4px; + color: var(--text-alternative-soft); + } } diff --git a/ui/pages/bridge/utils/quote.ts b/ui/pages/bridge/utils/quote.ts index 27e7edc8d1c8..73db9fa818bc 100644 --- a/ui/pages/bridge/utils/quote.ts +++ b/ui/pages/bridge/utils/quote.ts @@ -177,7 +177,7 @@ export const calcCost = ( ) => ({ fiat: adjustedReturnInFiat && sentAmountInFiat - ? adjustedReturnInFiat.minus(sentAmountInFiat) + ? sentAmountInFiat.minus(adjustedReturnInFiat) : null, }); From 7c24aecd9cd627343e4b531e73c3c9483ad348c5 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Thu, 24 Oct 2024 14:54:22 -0700 Subject: [PATCH 12/25] chore: enable selecting alternative quote --- ui/ducks/bridge/actions.ts | 2 ++ ui/ducks/bridge/bridge.ts | 11 +++++++- ui/ducks/bridge/selectors.ts | 28 +++++++++++++++++-- .../bridge/prepare/prepare-bridge-page.tsx | 9 +++--- .../bridge/quotes/bridge-quotes-modal.tsx | 5 ++-- 5 files changed, 46 insertions(+), 9 deletions(-) diff --git a/ui/ducks/bridge/actions.ts b/ui/ducks/bridge/actions.ts index 76ac75bb7b75..766689cb8cda 100644 --- a/ui/ducks/bridge/actions.ts +++ b/ui/ducks/bridge/actions.ts @@ -24,6 +24,7 @@ const { setFromTokenInputValue, resetInputFields, setSortOrder, + setSelectedQuote, } = bridgeSlice.actions; export { @@ -35,6 +36,7 @@ export { setDestTokenExchangeRates, setSrcTokenExchangeRates, setSortOrder, + setSelectedQuote, }; const callBridgeControllerMethod = ( diff --git a/ui/ducks/bridge/bridge.ts b/ui/ducks/bridge/bridge.ts index 22a780081499..91d790ad83a2 100644 --- a/ui/ducks/bridge/bridge.ts +++ b/ui/ducks/bridge/bridge.ts @@ -5,7 +5,11 @@ import { swapsSlice } from '../swaps/swaps'; import { SwapsTokenObject } from '../../../shared/constants/swaps'; import { SwapsEthToken } from '../../selectors'; import { fetchTokenExchangeRates } from '../../helpers/utils/util'; -import { SortOrder } from '../../pages/bridge/types'; +import { + QuoteMetadata, + QuoteResponse, + SortOrder, +} from '../../pages/bridge/types'; export type BridgeState = { toChainId: Hex | null; @@ -15,6 +19,7 @@ export type BridgeState = { fromTokenExchangeRate: number | null; toTokenExchangeRate: number | null; sortOrder: SortOrder; + selectedQuote: (QuoteResponse & QuoteMetadata) | null; // Alternate quote selected by user. When quotes refresh, the best match will be activated. }; const initialState: BridgeState = { @@ -25,6 +30,7 @@ const initialState: BridgeState = { fromTokenExchangeRate: null, toTokenExchangeRate: null, sortOrder: SortOrder.COST_ASC, + selectedQuote: null, }; export const setSrcTokenExchangeRates = createAsyncThunk( @@ -78,6 +84,9 @@ const bridgeSlice = createSlice({ setSortOrder: (state, action) => { state.sortOrder = action.payload; }, + setSelectedQuote: (state, action) => { + state.selectedQuote = action.payload; + }, }, extraReducers: (builder) => { builder.addCase(setDestTokenExchangeRates.fulfilled, (state, action) => { diff --git a/ui/ducks/bridge/selectors.ts b/ui/ducks/bridge/selectors.ts index 15104d0aaa69..07f6f5c54c62 100644 --- a/ui/ducks/bridge/selectors.ts +++ b/ui/ducks/bridge/selectors.ts @@ -34,6 +34,7 @@ import { getConversionRate, getGasFeeEstimates } from '../metamask/metamask'; // eslint-disable-next-line import/no-restricted-paths import { RequestStatus } from '../../../app/scripts/controllers/bridge/constants'; import { + L1GasFees, QuoteMetadata, QuoteResponse, SortOrder, @@ -215,7 +216,7 @@ const _getQuotesWithMetadata = createDeepEqualSelector( nativeExchangeRate, { estimatedBaseFeeInDecGwei, maxPriorityFeePerGasInDecGwei }, ): (QuoteResponse & QuoteMetadata)[] => { - return quotes.map((quote: QuoteResponse) => { + const newQuotes = quotes.map((quote: QuoteResponse) => { const toTokenAmount = calcToAmount(quote.quote, toTokenExchangeRate); const totalNetworkFee = calcTotalNetworkFee( quote, @@ -244,6 +245,8 @@ const _getQuotesWithMetadata = createDeepEqualSelector( cost: calcCost(adjustedReturn.fiat, sentAmount.fiat), }; }); + + return newQuotes; }, ); @@ -304,9 +307,29 @@ const _getRecommendedQuote = createDeepEqualSelector( }, ); +// Identifies each quote by aggregator, bridge, steps and value +const getDedupeString = ({ quote }: QuoteResponse & L1GasFees) => + `${quote.bridgeId}-${quote.bridges[0]}-${quote.steps.length}`; + +const _getSelectedQuote = createSelector( + (state: BridgeAppState) => state.metamask.bridgeState.quotesRefreshCount, + (state: BridgeAppState) => state.bridge.selectedQuote, + _getSortedQuotesWithMetadata, + (quotesRefreshCount, selectedQuote, sortedQuotesWithMetadata) => + quotesRefreshCount <= 1 + ? selectedQuote + : // Find match for selectedQuote in new quotes + sortedQuotesWithMetadata.find((quote) => + selectedQuote + ? getDedupeString(quote) === getDedupeString(selectedQuote) + : false, + ), +); + export const getBridgeQuotes = createSelector( _getSortedQuotesWithMetadata, _getRecommendedQuote, + _getSelectedQuote, (state) => state.metamask.bridgeState.quotesLastFetched, (state) => state.metamask.bridgeState.quotesLoadingStatus === RequestStatus.LOADING, @@ -316,6 +339,7 @@ export const getBridgeQuotes = createSelector( ( sortedQuotesWithMetadata, recommendedQuote, + selectedQuote, quotesLastFetchedMs, isLoading, quotesRefreshCount, @@ -324,7 +348,7 @@ export const getBridgeQuotes = createSelector( ) => ({ sortedQuotes: sortedQuotesWithMetadata, recommendedQuote, - activeQuote: recommendedQuote, + activeQuote: selectedQuote ?? recommendedQuote, quotesLastFetchedMs, isLoading, quotesRefreshCount, diff --git a/ui/pages/bridge/prepare/prepare-bridge-page.tsx b/ui/pages/bridge/prepare/prepare-bridge-page.tsx index f6f02edb95c3..13525f35c372 100644 --- a/ui/pages/bridge/prepare/prepare-bridge-page.tsx +++ b/ui/pages/bridge/prepare/prepare-bridge-page.tsx @@ -11,6 +11,7 @@ import { setFromToken, setFromTokenInputValue, setSrcTokenExchangeRates, + setSelectedQuote, setToChain, setToChainId, setToToken, @@ -123,10 +124,10 @@ const PrepareBridgePage = () => { ); const debouncedUpdateQuoteRequestInController = useCallback( - debounce( - (p: Partial) => dispatch(updateQuoteRequestParams(p)), - 300, - ), + debounce((p: Partial) => { + dispatch(updateQuoteRequestParams(p)); + dispatch(setSelectedQuote(null)); + }, 300), [], ); diff --git a/ui/pages/bridge/quotes/bridge-quotes-modal.tsx b/ui/pages/bridge/quotes/bridge-quotes-modal.tsx index f053c2be36fc..f8d65f015b95 100644 --- a/ui/pages/bridge/quotes/bridge-quotes-modal.tsx +++ b/ui/pages/bridge/quotes/bridge-quotes-modal.tsx @@ -23,7 +23,7 @@ import { } from '../utils/quote'; import { useI18nContext } from '../../../hooks/useI18nContext'; import { getCurrentCurrency } from '../../../selectors'; -import { setSortOrder } from '../../../ducks/bridge/actions'; +import { setSelectedQuote, setSortOrder } from '../../../ducks/bridge/actions'; import { SortOrder, QuoteMetadata, QuoteResponse } from '../types'; import { Footer } from '../../../components/multichain/pages/page'; import { useCountdownTimer } from '../../../hooks/bridge/useCountdownTimer'; @@ -69,7 +69,8 @@ export const BridgeQuotesModal = ({ - - - ) : ( - - - - {t('swapSelectAQuote')} - - + + + + {t('swapSelectAQuote')} + + - {/* HEADERS */} - - - dispatch(setSortOrder(SortOrder.ADJUSTED_RETURN_DESC)) - } - className={ - sortOrder === SortOrder.ADJUSTED_RETURN_DESC - ? 'active-sort' - : '' + {/* HEADERS */} + + {[ + [SortOrder.COST_ASC, t('bridgeNetCost'), IconName.Arrow2Up], + [SortOrder.ETA_ASC, t('time'), IconName.Arrow2Down], + ].map(([sortOrderOption, label, icon]) => ( + dispatch(setSortOrder(sortOrderOption))} + startIconName={sortOrder === sortOrderOption ? icon : undefined} + startIconProps={{ + size: IconSize.Xs, + }} + color={ + sortOrder === sortOrderOption + ? TextColor.primaryDefault + : TextColor.textAlternative } > - - {t('bridgeOverallCost')} - - dispatch(setSortOrder(SortOrder.ETA_ASC))} - className={sortOrder === SortOrder.ETA_ASC ? 'active-sort' : ''} - > - - {t('time')} - - - {/* QUOTE LIST */} - - {sortedQuotes.map((quote, index) => { - const { - totalNetworkFee, - estimatedProcessingTimeInSeconds, - toTokenAmount, - cost, - quote: { destAsset, bridges, requestId }, - } = quote; - const isQuoteActive = requestId === activeQuote?.quote.requestId; - const isQuoteRecommended = - requestId === recommendedQuote?.quote.requestId; + + {label} + + + ))} + + {/* QUOTE LIST */} + + {sortedQuotes.map((quote, index) => { + const { + totalNetworkFee, + estimatedProcessingTimeInSeconds, + toTokenAmount, + cost, + quote: { destAsset, bridges, requestId }, + } = quote; + const isQuoteActive = requestId === activeQuote?.quote.requestId; - return ( - setExpandedQuote(quote)} - > - {isQuoteActive && ( - - )} - - {cost.fiat && ( - - {isQuoteRecommended && ( - - {t( - sortOrder === SortOrder.ADJUSTED_RETURN_DESC - ? 'bridgeLowest' - : 'bridgeFastest', - )} - - )} - {formatFiatAmount(cost.fiat, currency)} - - )} - - - {formatFiatAmount(toTokenAmount.fiat, currency) ?? + return ( + { + dispatch(setSelectedQuote(quote)); + onClose(); + }} + paddingInline={4} + paddingTop={3} + paddingBottom={3} + style={{ position: 'relative', height: 78 }} + > + {isQuoteActive && ( + + )} + + + {cost.fiat && formatFiatAmount(cost.fiat, currency, 0)} + + {[ + totalNetworkFee?.fiat + ? t('quotedNetworkFee', [ + formatFiatAmount(totalNetworkFee.fiat, currency, 0), + ]) + : t('quotedNetworkFee', [ + formatTokenAmount( + totalNetworkFee.raw, + nativeCurrency, + ), + ]), + t( + sortOrder === SortOrder.ETA_ASC + ? 'quotedReceivingAmount' + : 'quotedReceiveAmount', + [ + formatFiatAmount(toTokenAmount.fiat, currency, 0) ?? formatTokenAmount( toTokenAmount.raw, destAsset.symbol, - )} + 0, + ), + ], + ), + ] + [sortOrder === SortOrder.ETA_ASC ? 'reverse' : 'slice']() + .map((content) => ( + + {content} - - - - {formatFiatAmount(totalNetworkFee?.fiat, currency)} - - - - - - {bridges[0]} - - {t('bridgeTimingMinutes', [ - formatEtaInMinutes(estimatedProcessingTimeInSeconds), - ])} - - - - - ); - })} - - - {!isLoading && ( - {t('swapNewQuoteIn', [secondsUntilNextRefresh])} - )} - - - )} + ))} + + + + {t('bridgeTimingMinutes', [ + formatEtaInMinutes(estimatedProcessingTimeInSeconds), + ])} + + + {startCase(bridges[0])} + + + + ); + })} + + ); }; diff --git a/ui/pages/bridge/quotes/index.scss b/ui/pages/bridge/quotes/index.scss index ba0d8ac62e16..15c6caf1d884 100644 --- a/ui/pages/bridge/quotes/index.scss +++ b/ui/pages/bridge/quotes/index.scss @@ -63,102 +63,7 @@ } } -.quotes-modal { - .mm-modal-content__dialog { - display: flex; - position: relative; - height: 100%; - } - - &__column-header { - display: flex; - flex-direction: row; - justify-content: space-between; - padding: 12px 16px 4px 16px; - color: var(--color-text-alternative); - - .active-sort { - color: var(--color-text-default); - } - - span, p { - display: flex; - align-items: center; - gap: 5px; - padding-right: 26px; - color: inherit; - } - } - - &__quotes { - display: flex; - flex-direction: column; - - .active-quote { - background: var(--color-primary-muted); - } - - &__row { - display: flex; - flex-direction: row; - justify-content: space-between; - height: 66px; - align-items: center; - flex-shrink: 0; - align-self: stretch; - padding: 12px 16px; - position: relative; - - &-bar { - position: absolute; - left: 4px; - top: 4px; - height: 58px; - width: 4px; - border-radius: 8px; - background: var(--color-primary-default); - } - - span { - display: flex; - align-items: flex-start; - } - - &-left { - flex-direction: column; - gap: 4px; - - span { - flex-direction: row; - gap: 10px; - - .description { - color: var(--color-primary-default); - } - - & > span { - align-items: center; - gap: 4px; - } - } - } - - &-right { - flex-direction: row; - gap: 10px; - } - } - } - - &__timer { - display: flex; - position: absolute; - width: 100%; - height: 70px; - justify-content: center; - align-items: center; - left: 4px; - bottom: 4px; - color: var(--text-alternative-soft); - } -} +.mm-modal-content__dialog { + display: flex; + height: 100%; +} \ No newline at end of file diff --git a/ui/pages/bridge/utils/quote.ts b/ui/pages/bridge/utils/quote.ts index 73db9fa818bc..1e8379417538 100644 --- a/ui/pages/bridge/utils/quote.ts +++ b/ui/pages/bridge/utils/quote.ts @@ -9,6 +9,7 @@ import { import { formatCurrency } from '../../../helpers/utils/confirm-tx.util'; import { Numeric } from '../../../../shared/modules/Numeric'; import { EtherDenomination } from '../../../../shared/constants/common'; +import { DEFAULT_PRECISION } from '../../../hooks/useCurrencyDisplay'; export const isNativeAddress = (address?: string) => address === zeroAddress(); @@ -190,5 +191,21 @@ export const formatTokenAmount = ( precision: number = 2, ) => `${amount.toFixed(precision)} ${symbol}`; -export const formatFiatAmount = (amount: BigNumber | null, currency: string) => - amount ? formatCurrency(amount.toString(), currency) : undefined; +export const formatFiatAmount = ( + amount: BigNumber | null, + currency: string, + precision: number = DEFAULT_PRECISION, +) => { + if (!amount) { + return undefined; + } + if (precision === 0) { + if (amount.lt(0.01)) { + return `<${formatCurrency('0', currency, precision)}`; + } + if (amount.lt(1)) { + return formatCurrency(amount.toString(), currency, 2); + } + } + return formatCurrency(amount.toString(), currency, precision); +}; From 6ba7918eb07baa5746aede52e63b818b0fffac69 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Tue, 12 Nov 2024 18:04:17 -0800 Subject: [PATCH 14/25] fix: lint errors --- ui/ducks/bridge/bridge.test.ts | 4 ++-- ui/pages/bridge/quotes/index.scss | 2 +- ui/pages/bridge/utils/quote.test.ts | 12 ++++-------- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/ui/ducks/bridge/bridge.test.ts b/ui/ducks/bridge/bridge.test.ts index a972d074f512..311f076a2e05 100644 --- a/ui/ducks/bridge/bridge.test.ts +++ b/ui/ducks/bridge/bridge.test.ts @@ -219,7 +219,7 @@ describe('Ducks - Bridge', () => { it('fetches token prices and updates dest exchange rates in state, native dest token', async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const mockStore = configureMockStore(middleware)( - createBridgeMockStore({}, {}), + createBridgeMockStore(), ); const state = mockStore.getState().bridge; const fetchTokenExchangeRatesSpy = jest @@ -261,7 +261,7 @@ describe('Ducks - Bridge', () => { it('fetches token prices and updates dest exchange rates in state, erc20 dest token', async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const mockStore = configureMockStore(middleware)( - createBridgeMockStore({}, {}), + createBridgeMockStore(), ); const state = mockStore.getState().bridge; const fetchTokenExchangeRatesSpy = jest diff --git a/ui/pages/bridge/quotes/index.scss b/ui/pages/bridge/quotes/index.scss index 15c6caf1d884..6407309220c2 100644 --- a/ui/pages/bridge/quotes/index.scss +++ b/ui/pages/bridge/quotes/index.scss @@ -66,4 +66,4 @@ .mm-modal-content__dialog { display: flex; height: 100%; -} \ No newline at end of file +} diff --git a/ui/pages/bridge/utils/quote.test.ts b/ui/pages/bridge/utils/quote.test.ts index 6afc148385f2..d3b0ceaf8137 100644 --- a/ui/pages/bridge/utils/quote.test.ts +++ b/ui/pages/bridge/utils/quote.test.ts @@ -70,9 +70,7 @@ describe('Bridge quote utils', () => { 'native', NATIVE_TOKEN, '1009000000000000000', - { - '0x0000000000000000000000000000000000000000': 1.0000825923770915, - }, + 1.0000825923770915, 2515.02, { raw: '1.143217728', @@ -83,9 +81,7 @@ describe('Bridge quote utils', () => { 'erc20', ERC20_TOKEN, '100000000', - { - '0x0b2c639c533813f4aa9d7837caf62653d097ff85': 0.999781, - }, + 0.999781, 2517.14, { raw: '100.512', fiat: '100.489987872' }, ], @@ -103,7 +99,7 @@ describe('Bridge quote utils', () => { _: string, srcAsset: { decimals: number; address: string }, srcTokenAmount: string, - fromTokenExchangeRates: Record, + fromTokenExchangeRate: number, fromNativeExchangeRate: number, { raw, fiat }: { raw: string; fiat: string }, ) => { @@ -117,7 +113,7 @@ describe('Bridge quote utils', () => { }, }, } as never, - fromTokenExchangeRates, + fromTokenExchangeRate, fromNativeExchangeRate, ); expect(result.raw?.toString()).toStrictEqual(raw); From a77ab55c53f81e8c050db4e08138d3b97d51c5c0 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Tue, 12 Nov 2024 18:15:36 -0800 Subject: [PATCH 15/25] fix: unit tests --- test/jest/mock-store.js | 6 +- ui/ducks/bridge/bridge.test.ts | 11 +- ui/ducks/bridge/selectors.test.ts | 162 +++++++++++++++++- ui/hooks/bridge/useCountdownTimer.test.ts | 5 +- .../prepare/prepare-bridge-page.test.tsx | 7 + .../bridge-quotes-modal.test.tsx.snap | 154 ++++++++++------- .../bridge/quotes/bridge-quote-card.test.tsx | 4 +- .../quotes/bridge-quotes-modal.stories.tsx | 6 +- ui/pages/bridge/utils/quote.test.ts | 21 +-- 9 files changed, 271 insertions(+), 105 deletions(-) diff --git a/test/jest/mock-store.js b/test/jest/mock-store.js index 41706fc77937..a3543e485bb7 100644 --- a/test/jest/mock-store.js +++ b/test/jest/mock-store.js @@ -4,6 +4,7 @@ import { KeyringType } from '../../shared/constants/keyring'; import { ETH_EOA_METHODS } from '../../shared/constants/eth-methods'; import { mockNetworkState } from '../stub/networks'; import { DEFAULT_BRIDGE_CONTROLLER_STATE } from '../../app/scripts/controllers/bridge/constants'; +import { BRIDGE_PREFERRED_GAS_ESTIMATE } from '../../shared/constants/bridge'; export const createGetSmartTransactionFeesApiResponse = () => { return { @@ -721,8 +722,9 @@ export const createBridgeMockStore = ( { chainId: CHAIN_IDS.LINEA_MAINNET }, ), gasFeeEstimates: { - high: { - suggestedMaxFeePerGas: '0.00010456', + estimatedBaseFee: '0.00010456', + [BRIDGE_PREFERRED_GAS_ESTIMATE]: { + suggestedMaxFeePerGas: '0.00018456', suggestedMaxPriorityFeePerGas: '0.0001', }, }, diff --git a/ui/ducks/bridge/bridge.test.ts b/ui/ducks/bridge/bridge.test.ts index 311f076a2e05..5a395fa23036 100644 --- a/ui/ducks/bridge/bridge.test.ts +++ b/ui/ducks/bridge/bridge.test.ts @@ -145,6 +145,7 @@ describe('Ducks - Bridge', () => { expect(actions[0].type).toStrictEqual('bridge/resetInputFields'); const newState = bridgeReducer(state, actions[0]); expect(newState).toStrictEqual({ + selectedQuote: null, toChainId: null, fromToken: null, toToken: null, @@ -206,12 +207,14 @@ describe('Ducks - Bridge', () => { expect(actions[0].type).toStrictEqual('bridge/resetInputFields'); const newState = bridgeReducer(state, actions[0]); expect(newState).toStrictEqual({ - toChainId: null, fromToken: null, - toToken: null, + fromTokenExchangeRate: null, fromTokenInputValue: null, + selectedQuote: null, + sortOrder: 0, + toChainId: null, + toToken: null, toTokenExchangeRate: null, - fromTokenExchangeRate: null, }); }); }); @@ -255,6 +258,7 @@ describe('Ducks - Bridge', () => { expect(newState).toStrictEqual({ toChainId: null, toTokenExchangeRate: 0.356628, + sortOrder: 0, }); }); @@ -299,6 +303,7 @@ describe('Ducks - Bridge', () => { expect(newState).toStrictEqual({ toChainId: null, toTokenExchangeRate: 0.999881, + sortOrder: 0, }); }); }); diff --git a/ui/ducks/bridge/selectors.test.ts b/ui/ducks/bridge/selectors.test.ts index e0ef95890969..ebd6aa789c5f 100644 --- a/ui/ducks/bridge/selectors.test.ts +++ b/ui/ducks/bridge/selectors.test.ts @@ -1,3 +1,4 @@ +import { BigNumber } from 'bignumber.js'; import { createBridgeMockStore } from '../../../test/jest/mock-store'; import { BUILT_IN_NETWORKS, @@ -493,7 +494,12 @@ describe('Bridge selectors', () => { it('returns quote list and fetch data, insufficientBal=false,quotesRefreshCount=5', () => { const state = createBridgeMockStore( { extensionConfig: { maxRefreshCount: 5 } }, - { toChainId: '0x1' }, + { + toChainId: '0x1', + fromTokenExchangeRate: 1, + toTokenExchangeRate: 0.99, + toNativeExchangeRate: 0.354073, + }, { quoteRequest: { insufficientBal: false }, quotes: mockErc20Erc20Quotes, @@ -503,11 +509,47 @@ describe('Bridge selectors', () => { srcTokens: { '0x00': { address: '0x00', symbol: 'TEST' } }, srcTopAssets: [{ address: '0x00', symbol: 'TEST' }], }, + { + currencyRates: { + ETH: { + conversionRate: 1, + }, + }, + }, ); - const result = getBridgeQuotes(state as never); + const recommendedQuoteMetadata = { + adjustedReturn: { + fiat: expect.any(Object), + }, + cost: { fiat: new BigNumber('0.15656287141025952') }, + sentAmount: { + fiat: new BigNumber('14'), + raw: new BigNumber('14'), + }, + swapRate: new BigNumber('0.998877142857142857142857142857142857'), + toTokenAmount: { + fiat: new BigNumber('13.8444372'), + raw: new BigNumber('13.98428'), + }, + totalNetworkFee: { + fiat: new BigNumber('0.00100007141025952'), + raw: new BigNumber('0.00100007141025952'), + }, + }; + + const result = getBridgeQuotes(state as never); + expect(result.sortedQuotes).toHaveLength(2); expect(result).toStrictEqual({ - quotes: mockErc20Erc20Quotes, + sortedQuotes: expect.any(Array), + recommendedQuote: { + ...mockErc20Erc20Quotes[0], + ...recommendedQuoteMetadata, + }, + activeQuote: { + ...mockErc20Erc20Quotes[0], + ...recommendedQuoteMetadata, + }, quotesLastFetchedMs: 100, isLoading: false, quotesRefreshCount: 5, @@ -518,7 +560,12 @@ describe('Bridge selectors', () => { it('returns quote list and fetch data, insufficientBal=false,quotesRefreshCount=2', () => { const state = createBridgeMockStore( { extensionConfig: { maxRefreshCount: 5 } }, - { toChainId: '0x1' }, + { + toChainId: '0x1', + fromTokenExchangeRate: 1, + toTokenExchangeRate: 0.99, + toNativeExchangeRate: 0.354073, + }, { quoteRequest: { insufficientBal: false }, quotes: mockErc20Erc20Quotes, @@ -528,11 +575,53 @@ describe('Bridge selectors', () => { srcTokens: { '0x00': { address: '0x00', symbol: 'TEST' } }, srcTopAssets: [{ address: '0x00', symbol: 'TEST' }], }, + { + currencyRates: { + ETH: { + conversionRate: 1, + }, + }, + }, ); const result = getBridgeQuotes(state as never); + const recommendedQuoteMetadata = { + adjustedReturn: { + fiat: new BigNumber('13.84343712858974048'), + }, + cost: { fiat: new BigNumber('0.15656287141025952') }, + sentAmount: { + fiat: new BigNumber('14'), + raw: new BigNumber('14'), + }, + swapRate: new BigNumber('0.998877142857142857142857142857142857'), + toTokenAmount: { + fiat: new BigNumber('13.8444372'), + raw: new BigNumber('13.98428'), + }, + totalNetworkFee: { + fiat: new BigNumber('0.00100007141025952'), + raw: new BigNumber('0.00100007141025952'), + }, + }; + expect(result.sortedQuotes).toHaveLength(2); + const EXPECTED_SORTED_COSTS = [ + { fiat: new BigNumber('0.15656287141025952') }, + { fiat: new BigNumber('0.33900008283534464') }, + ]; + result.sortedQuotes.forEach((quote, idx) => { + expect(quote.cost).toStrictEqual(EXPECTED_SORTED_COSTS[idx]); + }); expect(result).toStrictEqual({ - quotes: mockErc20Erc20Quotes, + sortedQuotes: expect.any(Array), + recommendedQuote: { + ...mockErc20Erc20Quotes[0], + ...recommendedQuoteMetadata, + }, + activeQuote: { + ...mockErc20Erc20Quotes[0], + ...recommendedQuoteMetadata, + }, quotesLastFetchedMs: 100, isLoading: false, quotesRefreshCount: 2, @@ -543,7 +632,12 @@ describe('Bridge selectors', () => { it('returns quote list and fetch data, insufficientBal=true', () => { const state = createBridgeMockStore( { extensionConfig: { maxRefreshCount: 5 } }, - { toChainId: '0x1' }, + { + toChainId: '0x1', + fromTokenExchangeRate: 1, + toTokenExchangeRate: 0.99, + toNativeExchangeRate: 0.354073, + }, { quoteRequest: { insufficientBal: true }, quotes: mockErc20Erc20Quotes, @@ -553,11 +647,54 @@ describe('Bridge selectors', () => { srcTokens: { '0x00': { address: '0x00', symbol: 'TEST' } }, srcTopAssets: [{ address: '0x00', symbol: 'TEST' }], }, + { + currencyRates: { + ETH: { + conversionRate: 1, + }, + }, + }, ); const result = getBridgeQuotes(state as never); + const recommendedQuoteMetadata = { + adjustedReturn: { + fiat: new BigNumber('13.84343712858974048'), + }, + cost: { fiat: new BigNumber('0.15656287141025952') }, + sentAmount: { + fiat: new BigNumber('14'), + raw: new BigNumber('14'), + }, + swapRate: new BigNumber('0.998877142857142857142857142857142857'), + toTokenAmount: { + fiat: new BigNumber('13.8444372'), + raw: new BigNumber('13.98428'), + }, + totalNetworkFee: { + fiat: new BigNumber('0.00100007141025952'), + raw: new BigNumber('0.00100007141025952'), + }, + }; + expect(result.sortedQuotes).toHaveLength(2); + const EXPECTED_SORTED_COSTS = [ + { fiat: new BigNumber('0.15656287141025952') }, + { fiat: new BigNumber('0.33900008283534464') }, + ]; + result.sortedQuotes.forEach((quote, idx) => { + expect(quote.cost).toStrictEqual(EXPECTED_SORTED_COSTS[idx]); + }); + expect(result).toStrictEqual({ - quotes: mockErc20Erc20Quotes, + sortedQuotes: expect.any(Array), + recommendedQuote: { + ...mockErc20Erc20Quotes[0], + ...recommendedQuoteMetadata, + }, + activeQuote: { + ...mockErc20Erc20Quotes[0], + ...recommendedQuoteMetadata, + }, quotesLastFetchedMs: 100, isLoading: false, quotesRefreshCount: 1, @@ -575,7 +712,9 @@ describe('Bridge selectors', () => { expect(result).toStrictEqual({ activeQuote: undefined, isLoading: false, + isQuoteGoingToRefresh: false, quotesLastFetchedMs: undefined, + quotesRefreshCount: undefined, recommendedQuote: undefined, sortedQuotes: [], }); @@ -712,6 +851,13 @@ describe('Bridge selectors', () => { }, ], }, + metamaskStateOverrides: { + currencyRates: { + ETH: { + conversionRate: 2524.25, + }, + }, + }, }); const { activeQuote, recommendedQuote, sortedQuotes } = getBridgeQuotes( @@ -739,7 +885,7 @@ describe('Bridge selectors', () => { expect(adjustedReturn?.fiat?.toString()).toStrictEqual( '21.70206159987361438', ); - expect(cost?.fiat?.toString()).toStrictEqual('-3.54043840012638562'); + expect(cost?.fiat?.toString()).toStrictEqual('3.54043840012638562'); expect(sortedQuotes).toHaveLength(3); expect(sortedQuotes[0]?.quote.requestId).toStrictEqual('fastestQuote'); expect(sortedQuotes[1]?.quote.requestId).toStrictEqual( diff --git a/ui/hooks/bridge/useCountdownTimer.test.ts b/ui/hooks/bridge/useCountdownTimer.test.ts index f2cd1190b1ba..293fe1ac679b 100644 --- a/ui/hooks/bridge/useCountdownTimer.test.ts +++ b/ui/hooks/bridge/useCountdownTimer.test.ts @@ -17,14 +17,11 @@ describe('useCountdownTimer', () => { const quotesLastFetched = Date.now(); const { result } = renderUseCountdownTimer( createBridgeMockStore( - {}, + { extensionConfig: { maxRefreshCount: 5, refreshRate: 40000 } }, {}, { quotesLastFetched, quotesRefreshCount: 0, - bridgeFeatureFlags: { - extensionConfig: { maxRefreshCount: 5, refreshRate: 40000 }, - }, }, ), ); diff --git a/ui/pages/bridge/prepare/prepare-bridge-page.test.tsx b/ui/pages/bridge/prepare/prepare-bridge-page.test.tsx index aba35d5b89be..95248bdbb0bc 100644 --- a/ui/pages/bridge/prepare/prepare-bridge-page.test.tsx +++ b/ui/pages/bridge/prepare/prepare-bridge-page.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { act } from '@testing-library/react'; +import * as reactRouterUtils from 'react-router-dom-v5-compat'; import { fireEvent, renderWithProvider } from '../../../../test/jest'; import configureStore from '../../../store/store'; import { createBridgeMockStore } from '../../../../test/jest/mock-store'; @@ -23,6 +24,9 @@ describe('PrepareBridgePage', () => { }); it('should render the component, with initial state', async () => { + jest + .spyOn(reactRouterUtils, 'useSearchParams') + .mockReturnValue([{ get: () => null }] as never); const mockStore = createBridgeMockStore( { srcNetworkAllowlist: [CHAIN_IDS.MAINNET, CHAIN_IDS.OPTIMISM], @@ -54,6 +58,9 @@ describe('PrepareBridgePage', () => { }); it('should render the component, with inputs set', async () => { + jest + .spyOn(reactRouterUtils, 'useSearchParams') + .mockReturnValue([{ get: () => '0x3103910' }, jest.fn()] as never); const mockStore = createBridgeMockStore( { srcNetworkAllowlist: [CHAIN_IDS.MAINNET, CHAIN_IDS.LINEA_MAINNET], diff --git a/ui/pages/bridge/quotes/__snapshots__/bridge-quotes-modal.test.tsx.snap b/ui/pages/bridge/quotes/__snapshots__/bridge-quotes-modal.test.tsx.snap index 9a1ab5dc9878..09c78928f82f 100644 --- a/ui/pages/bridge/quotes/__snapshots__/bridge-quotes-modal.test.tsx.snap +++ b/ui/pages/bridge/quotes/__snapshots__/bridge-quotes-modal.test.tsx.snap @@ -33,61 +33,57 @@ exports[`BridgeQuotesModal should render the modal 1`] = ` class="mm-box mm-header-base mm-modal-header mm-box--padding-right-4 mm-box--padding-bottom-4 mm-box--padding-left-4 mm-box--display-flex mm-box--justify-content-space-between" >
-

- Select a quote -

-
-
+
+

+ Select a quote +

+
-

-

- $2.52 -

-

-

- across -

-

+

+ $3 network fee +

+

+ 14 USDC receive amount +

+
+
- 1 min -

+

+ 1 min +

+

+ Across +

+
-

-

- $2.52 -

-

-

- celercircle -

-

+

+ $3 network fee +

+

+ 14 USDC receive amount +

+
+
- 26 min -

+

+ 26 min +

+

+ Celercircle +

+
diff --git a/ui/pages/bridge/quotes/bridge-quote-card.test.tsx b/ui/pages/bridge/quotes/bridge-quote-card.test.tsx index 274ade65a4d1..7de52fef1d58 100644 --- a/ui/pages/bridge/quotes/bridge-quote-card.test.tsx +++ b/ui/pages/bridge/quotes/bridge-quote-card.test.tsx @@ -20,6 +20,7 @@ describe('BridgeQuoteCard', () => { { srcNetworkAllowlist: [CHAIN_IDS.MAINNET, CHAIN_IDS.OPTIMISM], destNetworkAllowlist: [CHAIN_IDS.OPTIMISM], + extensionConfig: { maxRefreshCount: 5, refreshRate: 30000 }, }, { fromTokenInputValue: 1 }, { @@ -28,9 +29,6 @@ describe('BridgeQuoteCard', () => { quotes: mockBridgeQuotesErc20Erc20, getQuotesLastFetched: Date.now(), quotesLoadingStatus: RequestStatus.FETCHED, - bridgeFeatureFlags: { - extensionConfig: { maxRefreshCount: 5, refreshRate: 30000 }, - }, }, ); const { container } = renderWithProvider( diff --git a/ui/pages/bridge/quotes/bridge-quotes-modal.stories.tsx b/ui/pages/bridge/quotes/bridge-quotes-modal.stories.tsx index 24f2e40231c5..bbdf9b47fa47 100644 --- a/ui/pages/bridge/quotes/bridge-quotes-modal.stories.tsx +++ b/ui/pages/bridge/quotes/bridge-quotes-modal.stories.tsx @@ -39,16 +39,15 @@ DefaultStory.decorators = [ store={configureStore( createBridgeMockStore({ bridgeSliceOverrides: { - fromNativeExchangeRate: 1, fromTokenExchangeRate: 0.99, toNativeExchangeRate: 1, toTokenExchangeRate: 0.99, - sortOrder: SortOrder.ADJUSTED_RETURN_DESC, + sortOrder: SortOrder.COST_ASC, }, bridgeStateOverrides: { quotes: mockBridgeQuotesErc20Erc20 }, metamaskStateOverrides: { currencyRates: { - ETH: { conversionRate: 2514.5 }, + ETH: { conversionRate: 2514.5 }, //1 }, marketData: { '0x1': { @@ -77,7 +76,6 @@ PositiveArbitrage.decorators = [ store={configureStore( createBridgeMockStore({ bridgeSliceOverrides: { - fromNativeExchangeRate: 1, fromTokenExchangeRate: 0.99, toNativeExchangeRate: 1, toTokenExchangeRate: 2.1, diff --git a/ui/pages/bridge/utils/quote.test.ts b/ui/pages/bridge/utils/quote.test.ts index d3b0ceaf8137..883f2423b1f5 100644 --- a/ui/pages/bridge/utils/quote.test.ts +++ b/ui/pages/bridge/utils/quote.test.ts @@ -22,21 +22,21 @@ describe('Bridge quote utils', () => { 'native', NATIVE_TOKEN, '1009000000000000000', - { toTokenExchangeRate: 1, toNativeExchangeRate: 2521.73 }, + 2521.73, { raw: '1.009', fiat: '2544.42557' }, ], [ 'erc20', ERC20_TOKEN, '2543140000', - { toTokenExchangeRate: 0.999781, toNativeExchangeRate: 0.352999 }, + 0.999781, { raw: '2543.14', fiat: '2542.58305234' }, ], [ 'erc20 with null exchange rates', ERC20_TOKEN, '2543140000', - { toTokenExchangeRate: null, toNativeExchangeRate: null }, + null, { raw: '2543.14', fiat: undefined }, ], ])( @@ -45,10 +45,7 @@ describe('Bridge quote utils', () => { _: string, destAsset: { decimals: number; address: string }, destTokenAmount: string, - { - toTokenExchangeRate, - toNativeExchangeRate, - }: { toTokenExchangeRate: number; toNativeExchangeRate: number }, + toTokenExchangeRate: number, { raw, fiat }: { raw: string; fiat: string }, ) => { const result = calcToAmount( @@ -57,7 +54,6 @@ describe('Bridge quote utils', () => { destTokenAmount, } as never, toTokenExchangeRate, - toNativeExchangeRate, ); expect(result.raw?.toString()).toStrictEqual(raw); expect(result.fiat?.toString()).toStrictEqual(fiat); @@ -70,7 +66,6 @@ describe('Bridge quote utils', () => { 'native', NATIVE_TOKEN, '1009000000000000000', - 1.0000825923770915, 2515.02, { raw: '1.143217728', @@ -82,14 +77,12 @@ describe('Bridge quote utils', () => { ERC20_TOKEN, '100000000', 0.999781, - 2517.14, { raw: '100.512', fiat: '100.489987872' }, ], [ 'erc20 with null exchange rates', ERC20_TOKEN, '2543140000', - {}, null, { raw: '2543.652', fiat: undefined }, ], @@ -100,7 +93,6 @@ describe('Bridge quote utils', () => { srcAsset: { decimals: number; address: string }, srcTokenAmount: string, fromTokenExchangeRate: number, - fromNativeExchangeRate: number, { raw, fiat }: { raw: string; fiat: string }, ) => { const result = calcSentAmount( @@ -114,7 +106,6 @@ describe('Bridge quote utils', () => { }, } as never, fromTokenExchangeRate, - fromNativeExchangeRate, ); expect(result.raw?.toString()).toStrictEqual(raw); expect(result.fiat?.toString()).toStrictEqual(fiat); @@ -182,8 +173,8 @@ describe('Bridge quote utils', () => { : undefined, quote: { srcAsset, srcTokenAmount, feeData }, } as never, - '0x19870', - '0x186a0', + '0.00010456', + '0.0001', 2517.42, ); expect(result.raw?.toString()).toStrictEqual(raw); From bff17b7c88f80e9fd3bfa3910f493cecbbfbd360 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Tue, 19 Nov 2024 14:16:02 -0800 Subject: [PATCH 16/25] fix: stringify exchange rates before multiplying --- ui/pages/bridge/utils/quote.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/ui/pages/bridge/utils/quote.ts b/ui/pages/bridge/utils/quote.ts index 1e8379417538..3bcb418cc9b8 100644 --- a/ui/pages/bridge/utils/quote.ts +++ b/ui/pages/bridge/utils/quote.ts @@ -55,7 +55,9 @@ export const calcToAmount = ( ); return { raw: normalizedDestAmount, - fiat: exchangeRate ? normalizedDestAmount.mul(exchangeRate) : null, + fiat: exchangeRate + ? normalizedDestAmount.mul(exchangeRate.toString()) + : null, }; }; @@ -94,7 +96,7 @@ const calcRelayerFee = ( return { raw: relayerFeeInNative, fiat: nativeExchangeRate - ? relayerFeeInNative.mul(nativeExchangeRate) + ? relayerFeeInNative.mul(nativeExchangeRate.toString()) : null, }; }; @@ -129,7 +131,7 @@ const calcTotalGasFee = ( gasFeesInDecGwei.shiftedBy(9).toString(), ); const gasFeesInUSD = nativeExchangeRate - ? gasFeesInDecEth.times(nativeExchangeRate) + ? gasFeesInDecEth.times(nativeExchangeRate.toString()) : null; return { From 067af93f09d0176fc95efc1aa13fa384d442689d Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Tue, 19 Nov 2024 16:28:15 -0800 Subject: [PATCH 17/25] chore: move header icon to end --- .../bridge-quotes-modal.test.tsx.snap | 8 ++++---- ui/pages/bridge/quotes/bridge-quotes-modal.tsx | 15 ++++++++++++++- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/ui/pages/bridge/quotes/__snapshots__/bridge-quotes-modal.test.tsx.snap b/ui/pages/bridge/quotes/__snapshots__/bridge-quotes-modal.test.tsx.snap index 09c78928f82f..0f01617702ca 100644 --- a/ui/pages/bridge/quotes/__snapshots__/bridge-quotes-modal.test.tsx.snap +++ b/ui/pages/bridge/quotes/__snapshots__/bridge-quotes-modal.test.tsx.snap @@ -62,10 +62,6 @@ exports[`BridgeQuotesModal should render the modal 1`] = `