1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-11-22 01:47:00 +01:00

Quotes prefetching in Swaps (#11915)

This commit is contained in:
Daniel 2021-09-15 15:13:18 +02:00 committed by GitHub
parent 9e4b43defd
commit 28fc2d471f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 302 additions and 362 deletions

View File

@ -2155,10 +2155,6 @@
"message": "No tokens available matching $1", "message": "No tokens available matching $1",
"description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text" "description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text"
}, },
"swapCheckingQuote": {
"message": "Checking $1",
"description": "Shown to the user during quote loading. $1 is the name of an aggregator. The message indicates that metamask is currently checking if that aggregator has a trade/quote for their requested swap."
},
"swapConfirmWithHwWallet": { "swapConfirmWithHwWallet": {
"message": "Confirm with your hardware wallet" "message": "Confirm with your hardware wallet"
}, },
@ -2204,6 +2200,9 @@
"swapFailedErrorTitle": { "swapFailedErrorTitle": {
"message": "Swap failed" "message": "Swap failed"
}, },
"swapFetchingQuotes": {
"message": "Fetching quotes"
},
"swapFetchingQuotesErrorDescription": { "swapFetchingQuotesErrorDescription": {
"message": "Hmmm... something went wrong. Try again, or if errors persist, contact customer support." "message": "Hmmm... something went wrong. Try again, or if errors persist, contact customer support."
}, },
@ -2213,9 +2212,6 @@
"swapFetchingTokens": { "swapFetchingTokens": {
"message": "Fetching tokens..." "message": "Fetching tokens..."
}, },
"swapFinalizing": {
"message": "Finalizing..."
},
"swapFromTo": { "swapFromTo": {
"message": "The swap of $1 to $2", "message": "The swap of $1 to $2",
"description": "Tells a user that they need to confirm on their hardware wallet a swap of 2 tokens. $1 is a source token and $2 is a destination token" "description": "Tells a user that they need to confirm on their hardware wallet a swap of 2 tokens. $1 is a source token and $2 is a destination token"

View File

@ -1870,10 +1870,6 @@
"message": "No hay tokens disponibles que coincidan con $1", "message": "No hay tokens disponibles que coincidan con $1",
"description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text" "description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text"
}, },
"swapCheckingQuote": {
"message": "Comprobando $1",
"description": "Shown to the user during quote loading. $1 is the name of an aggregator. The message indicates that metamask is currently checking if that aggregator has a trade/quote for their requested swap."
},
"swapConfirmWithHwWallet": { "swapConfirmWithHwWallet": {
"message": "Confirmar con la cartera de hardware" "message": "Confirmar con la cartera de hardware"
}, },
@ -1925,9 +1921,6 @@
"swapFetchingTokens": { "swapFetchingTokens": {
"message": "Capturando tokens…" "message": "Capturando tokens…"
}, },
"swapFinalizing": {
"message": "Finalizando…"
},
"swapFromTo": { "swapFromTo": {
"message": "El canje de $1 por $2", "message": "El canje de $1 por $2",
"description": "Tells a user that they need to confirm on their hardware wallet a swap of 2 tokens. $1 is a source token and $2 is a destination token" "description": "Tells a user that they need to confirm on their hardware wallet a swap of 2 tokens. $1 is a source token and $2 is a destination token"

View File

@ -1870,10 +1870,6 @@
"message": "No hay tokens disponibles que coincidan con $1", "message": "No hay tokens disponibles que coincidan con $1",
"description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text" "description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text"
}, },
"swapCheckingQuote": {
"message": "Comprobando $1",
"description": "Shown to the user during quote loading. $1 is the name of an aggregator. The message indicates that metamask is currently checking if that aggregator has a trade/quote for their requested swap."
},
"swapConfirmWithHwWallet": { "swapConfirmWithHwWallet": {
"message": "Confirmar con la cartera de hardware" "message": "Confirmar con la cartera de hardware"
}, },
@ -1925,9 +1921,6 @@
"swapFetchingTokens": { "swapFetchingTokens": {
"message": "Capturando tokens…" "message": "Capturando tokens…"
}, },
"swapFinalizing": {
"message": "Finalizando…"
},
"swapFromTo": { "swapFromTo": {
"message": "El canje de $1 por $2", "message": "El canje de $1 por $2",
"description": "Tells a user that they need to confirm on their hardware wallet a swap of 2 tokens. $1 is a source token and $2 is a destination token" "description": "Tells a user that they need to confirm on their hardware wallet a swap of 2 tokens. $1 is a source token and $2 is a destination token"

View File

@ -1870,10 +1870,6 @@
"message": "$1 के मिलान वाले कोई भी टोकन उपलब्ध नहीं हैं", "message": "$1 के मिलान वाले कोई भी टोकन उपलब्ध नहीं हैं",
"description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text" "description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text"
}, },
"swapCheckingQuote": {
"message": "$1 की जाँच की जा रही है",
"description": "Shown to the user during quote loading. $1 is the name of an aggregator. The message indicates that metamask is currently checking if that aggregator has a trade/quote for their requested swap."
},
"swapConfirmWithHwWallet": { "swapConfirmWithHwWallet": {
"message": "अपने हार्डवेयर वॉलेट से पुष्टि करें" "message": "अपने हार्डवेयर वॉलेट से पुष्टि करें"
}, },
@ -1925,9 +1921,6 @@
"swapFetchingTokens": { "swapFetchingTokens": {
"message": "टोकन प्राप्त किए जा रहे हैं..." "message": "टोकन प्राप्त किए जा रहे हैं..."
}, },
"swapFinalizing": {
"message": "अंतिम रूप दिया जा रहा है..."
},
"swapFromTo": { "swapFromTo": {
"message": "$1 से $2 का स्वैप", "message": "$1 से $2 का स्वैप",
"description": "Tells a user that they need to confirm on their hardware wallet a swap of 2 tokens. $1 is a source token and $2 is a destination token" "description": "Tells a user that they need to confirm on their hardware wallet a swap of 2 tokens. $1 is a source token and $2 is a destination token"

View File

@ -1870,10 +1870,6 @@
"message": "Tidak ada token yang cocok yang tersedia $1", "message": "Tidak ada token yang cocok yang tersedia $1",
"description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text" "description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text"
}, },
"swapCheckingQuote": {
"message": "Memeriksa $1",
"description": "Shown to the user during quote loading. $1 is the name of an aggregator. The message indicates that metamask is currently checking if that aggregator has a trade/quote for their requested swap."
},
"swapConfirmWithHwWallet": { "swapConfirmWithHwWallet": {
"message": "Konfirmasikan dengan dompet perangkat keras Anda" "message": "Konfirmasikan dengan dompet perangkat keras Anda"
}, },
@ -1925,9 +1921,6 @@
"swapFetchingTokens": { "swapFetchingTokens": {
"message": "Mengambil token..." "message": "Mengambil token..."
}, },
"swapFinalizing": {
"message": "Menyelesaikan..."
},
"swapFromTo": { "swapFromTo": {
"message": "Penukaran dari $1 ke $2", "message": "Penukaran dari $1 ke $2",
"description": "Tells a user that they need to confirm on their hardware wallet a swap of 2 tokens. $1 is a source token and $2 is a destination token" "description": "Tells a user that they need to confirm on their hardware wallet a swap of 2 tokens. $1 is a source token and $2 is a destination token"

View File

@ -1522,10 +1522,6 @@
"message": "Non ci sono token disponibile con questo nome $1", "message": "Non ci sono token disponibile con questo nome $1",
"description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text" "description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text"
}, },
"swapCheckingQuote": {
"message": "Verificando $1",
"description": "Shown to the user during quote loading. $1 is the name of an aggregator. The message indicates that metamask is currently checking if that aggregator has a trade/quote for their requested swap."
},
"swapCustom": { "swapCustom": {
"message": "personalizza" "message": "personalizza"
}, },
@ -1564,9 +1560,6 @@
"swapFetchingTokens": { "swapFetchingTokens": {
"message": "Recuperando i token..." "message": "Recuperando i token..."
}, },
"swapFinalizing": {
"message": "Finalizzando..."
},
"swapLowSlippageError": { "swapLowSlippageError": {
"message": "La transazione può fallire, il massimo slippage è troppo basso." "message": "La transazione può fallire, il massimo slippage è troppo basso."
}, },

View File

@ -1870,10 +1870,6 @@
"message": "$1 と一致するトークンがありません", "message": "$1 と一致するトークンがありません",
"description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text" "description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text"
}, },
"swapCheckingQuote": {
"message": "$1 をチェック中",
"description": "Shown to the user during quote loading. $1 is the name of an aggregator. The message indicates that metamask is currently checking if that aggregator has a trade/quote for their requested swap."
},
"swapConfirmWithHwWallet": { "swapConfirmWithHwWallet": {
"message": "ハードウェア ウォレットで確認する" "message": "ハードウェア ウォレットで確認する"
}, },
@ -1925,9 +1921,6 @@
"swapFetchingTokens": { "swapFetchingTokens": {
"message": "トークンを取り出し中..." "message": "トークンを取り出し中..."
}, },
"swapFinalizing": {
"message": "終了中..."
},
"swapFromTo": { "swapFromTo": {
"message": "$1 から $2 のスワップ", "message": "$1 から $2 のスワップ",
"description": "Tells a user that they need to confirm on their hardware wallet a swap of 2 tokens. $1 is a source token and $2 is a destination token" "description": "Tells a user that they need to confirm on their hardware wallet a swap of 2 tokens. $1 is a source token and $2 is a destination token"

View File

@ -1870,10 +1870,6 @@
"message": "$1와(과) 일치하는 토큰이 없습니다.", "message": "$1와(과) 일치하는 토큰이 없습니다.",
"description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text" "description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text"
}, },
"swapCheckingQuote": {
"message": "$1 확인 중",
"description": "Shown to the user during quote loading. $1 is the name of an aggregator. The message indicates that metamask is currently checking if that aggregator has a trade/quote for their requested swap."
},
"swapConfirmWithHwWallet": { "swapConfirmWithHwWallet": {
"message": "하드웨어 지갑으로 확인합니다." "message": "하드웨어 지갑으로 확인합니다."
}, },
@ -1925,9 +1921,6 @@
"swapFetchingTokens": { "swapFetchingTokens": {
"message": "토큰 가져오는 중..." "message": "토큰 가져오는 중..."
}, },
"swapFinalizing": {
"message": "마무리 중..."
},
"swapFromTo": { "swapFromTo": {
"message": "$1을(를) $2(으)로 스왑", "message": "$1을(를) $2(으)로 스왑",
"description": "Tells a user that they need to confirm on their hardware wallet a swap of 2 tokens. $1 is a source token and $2 is a destination token" "description": "Tells a user that they need to confirm on their hardware wallet a swap of 2 tokens. $1 is a source token and $2 is a destination token"

View File

@ -1870,10 +1870,6 @@
"message": "Walang available na token na tumutugma sa $1", "message": "Walang available na token na tumutugma sa $1",
"description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text" "description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text"
}, },
"swapCheckingQuote": {
"message": "Sinusuri ang $1",
"description": "Shown to the user during quote loading. $1 is the name of an aggregator. The message indicates that metamask is currently checking if that aggregator has a trade/quote for their requested swap."
},
"swapConfirmWithHwWallet": { "swapConfirmWithHwWallet": {
"message": "Kumpirmahin ang iyong hardware wallet" "message": "Kumpirmahin ang iyong hardware wallet"
}, },
@ -1925,9 +1921,6 @@
"swapFetchingTokens": { "swapFetchingTokens": {
"message": "Kinukuha ang mga token..." "message": "Kinukuha ang mga token..."
}, },
"swapFinalizing": {
"message": "Isinasapinal..."
},
"swapFromTo": { "swapFromTo": {
"message": "Ang pag-swap ng $1 sa $2", "message": "Ang pag-swap ng $1 sa $2",
"description": "Tells a user that they need to confirm on their hardware wallet a swap of 2 tokens. $1 is a source token and $2 is a destination token" "description": "Tells a user that they need to confirm on their hardware wallet a swap of 2 tokens. $1 is a source token and $2 is a destination token"

View File

@ -1870,10 +1870,6 @@
"message": "Nenhum token disponível correspondente a $1", "message": "Nenhum token disponível correspondente a $1",
"description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text" "description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text"
}, },
"swapCheckingQuote": {
"message": "Verificando $1",
"description": "Shown to the user during quote loading. $1 is the name of an aggregator. The message indicates that metamask is currently checking if that aggregator has a trade/quote for their requested swap."
},
"swapConfirmWithHwWallet": { "swapConfirmWithHwWallet": {
"message": "Confirme com sua carteira de hardware" "message": "Confirme com sua carteira de hardware"
}, },
@ -1925,9 +1921,6 @@
"swapFetchingTokens": { "swapFetchingTokens": {
"message": "Fetch dos tokens..." "message": "Fetch dos tokens..."
}, },
"swapFinalizing": {
"message": "Finalizando..."
},
"swapFromTo": { "swapFromTo": {
"message": "O swap de $1 para $2", "message": "O swap de $1 para $2",
"description": "Tells a user that they need to confirm on their hardware wallet a swap of 2 tokens. $1 is a source token and $2 is a destination token" "description": "Tells a user that they need to confirm on their hardware wallet a swap of 2 tokens. $1 is a source token and $2 is a destination token"

View File

@ -1870,10 +1870,6 @@
"message": "Нет доступных токенов соответствующих $1", "message": "Нет доступных токенов соответствующих $1",
"description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text" "description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text"
}, },
"swapCheckingQuote": {
"message": "Проверка $1",
"description": "Shown to the user during quote loading. $1 is the name of an aggregator. The message indicates that metamask is currently checking if that aggregator has a trade/quote for their requested swap."
},
"swapConfirmWithHwWallet": { "swapConfirmWithHwWallet": {
"message": "Подтвердить с помощью аппаратного кошелька" "message": "Подтвердить с помощью аппаратного кошелька"
}, },
@ -1925,9 +1921,6 @@
"swapFetchingTokens": { "swapFetchingTokens": {
"message": "Получение токенов..." "message": "Получение токенов..."
}, },
"swapFinalizing": {
"message": "Завершение..."
},
"swapFromTo": { "swapFromTo": {
"message": "Своп $1 на $2", "message": "Своп $1 на $2",
"description": "Tells a user that they need to confirm on their hardware wallet a swap of 2 tokens. $1 is a source token and $2 is a destination token" "description": "Tells a user that they need to confirm on their hardware wallet a swap of 2 tokens. $1 is a source token and $2 is a destination token"

View File

@ -1510,10 +1510,6 @@
"message": "Walang available na token na tumutugma sa $1", "message": "Walang available na token na tumutugma sa $1",
"description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text" "description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text"
}, },
"swapCheckingQuote": {
"message": "Sinusuri ang $1",
"description": "Shown to the user during quote loading. $1 is the name of an aggregator. The message indicates that metamask is currently checking if that aggregator has a trade/quote for their requested swap."
},
"swapCustom": { "swapCustom": {
"message": "custom" "message": "custom"
}, },
@ -1552,9 +1548,6 @@
"swapFetchingTokens": { "swapFetchingTokens": {
"message": "Kinukuha ang mga token..." "message": "Kinukuha ang mga token..."
}, },
"swapFinalizing": {
"message": "Isinasapinal..."
},
"swapLowSlippageError": { "swapLowSlippageError": {
"message": "Maaaring hindi magtagumpay ang transaksyon, masyadong mababa ang max na slippage." "message": "Maaaring hindi magtagumpay ang transaksyon, masyadong mababa ang max na slippage."
}, },

View File

@ -1870,10 +1870,6 @@
"message": "Không có token nào khớp với $1", "message": "Không có token nào khớp với $1",
"description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text" "description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text"
}, },
"swapCheckingQuote": {
"message": "Đang kiểm tra $1",
"description": "Shown to the user during quote loading. $1 is the name of an aggregator. The message indicates that metamask is currently checking if that aggregator has a trade/quote for their requested swap."
},
"swapConfirmWithHwWallet": { "swapConfirmWithHwWallet": {
"message": "Xác nhận ví cứng của bạn" "message": "Xác nhận ví cứng của bạn"
}, },
@ -1925,9 +1921,6 @@
"swapFetchingTokens": { "swapFetchingTokens": {
"message": "Đang tìm nạp token..." "message": "Đang tìm nạp token..."
}, },
"swapFinalizing": {
"message": "Đang hoàn tất..."
},
"swapFromTo": { "swapFromTo": {
"message": "Giao dịch hoán đổi $1 sang $2", "message": "Giao dịch hoán đổi $1 sang $2",
"description": "Tells a user that they need to confirm on their hardware wallet a swap of 2 tokens. $1 is a source token and $2 is a destination token" "description": "Tells a user that they need to confirm on their hardware wallet a swap of 2 tokens. $1 is a source token and $2 is a destination token"

View File

@ -1516,10 +1516,6 @@
"message": "没有匹配的代币符合 $1", "message": "没有匹配的代币符合 $1",
"description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text" "description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text"
}, },
"swapCheckingQuote": {
"message": "正在检查 $1",
"description": "Shown to the user during quote loading. $1 is the name of an aggregator. The message indicates that metamask is currently checking if that aggregator has a trade/quote for their requested swap."
},
"swapCustom": { "swapCustom": {
"message": "自定义" "message": "自定义"
}, },
@ -1558,9 +1554,6 @@
"swapFetchingTokens": { "swapFetchingTokens": {
"message": "获取代币中……" "message": "获取代币中……"
}, },
"swapFinalizing": {
"message": "确定中……"
},
"swapLowSlippageError": { "swapLowSlippageError": {
"message": "交易可能失败,最大滑点过低。" "message": "交易可能失败,最大滑点过低。"
}, },

View File

@ -24,8 +24,9 @@ import { isSwapsDefaultTokenAddress } from '../../../shared/modules/swaps.utils'
import { import {
fetchTradesInfo as defaultFetchTradesInfo, fetchTradesInfo as defaultFetchTradesInfo,
fetchSwapsQuoteRefreshTime as defaultFetchSwapsQuoteRefreshTime, getBaseApi,
} from '../../../ui/pages/swaps/swaps.util'; } from '../../../ui/pages/swaps/swaps.util';
import fetchWithCache from '../../../ui/helpers/utils/fetch-with-cache';
import { MINUTE, SECOND } from '../../../shared/constants/time'; import { MINUTE, SECOND } from '../../../shared/constants/time';
import { NETWORK_EVENTS } from './network'; import { NETWORK_EVENTS } from './network';
@ -40,10 +41,6 @@ const POLL_COUNT_LIMIT = 3;
// provide a reasonable fallback to avoid further errors // provide a reasonable fallback to avoid further errors
const FALLBACK_QUOTE_REFRESH_TIME = MINUTE; const FALLBACK_QUOTE_REFRESH_TIME = MINUTE;
// This is the amount of time to wait, after successfully fetching quotes
// and their gas estimates, before fetching for new quotes
const QUOTE_POLLING_DIFFERENCE_INTERVAL = SECOND * 10;
function calculateGasEstimateWithRefund( function calculateGasEstimateWithRefund(
maxGas = MAX_GAS_LIMIT, maxGas = MAX_GAS_LIMIT,
estimatedRefund = 0, estimatedRefund = 0,
@ -64,6 +61,7 @@ function calculateGasEstimateWithRefund(
const initialState = { const initialState = {
swapsState: { swapsState: {
quotes: {}, quotes: {},
quotesPollingLimitEnabled: false,
fetchParams: null, fetchParams: null,
tokens: null, tokens: null,
tradeTxId: null, tradeTxId: null,
@ -82,6 +80,7 @@ const initialState = {
swapsFeatureIsLive: true, swapsFeatureIsLive: true,
useNewSwapsApi: false, useNewSwapsApi: false,
swapsQuoteRefreshTime: FALLBACK_QUOTE_REFRESH_TIME, swapsQuoteRefreshTime: FALLBACK_QUOTE_REFRESH_TIME,
swapsQuotePrefetchingRefreshTime: FALLBACK_QUOTE_REFRESH_TIME,
}, },
}; };
@ -93,7 +92,6 @@ export default class SwapsController {
getProviderConfig, getProviderConfig,
tokenRatesStore, tokenRatesStore,
fetchTradesInfo = defaultFetchTradesInfo, fetchTradesInfo = defaultFetchTradesInfo,
fetchSwapsQuoteRefreshTime = defaultFetchSwapsQuoteRefreshTime,
getCurrentChainId, getCurrentChainId,
getEIP1559GasFeeEstimates, getEIP1559GasFeeEstimates,
}) { }) {
@ -102,7 +100,6 @@ export default class SwapsController {
}); });
this._fetchTradesInfo = fetchTradesInfo; this._fetchTradesInfo = fetchTradesInfo;
this._fetchSwapsQuoteRefreshTime = fetchSwapsQuoteRefreshTime;
this._getCurrentChainId = getCurrentChainId; this._getCurrentChainId = getCurrentChainId;
this._getEIP1559GasFeeEstimates = getEIP1559GasFeeEstimates; this._getEIP1559GasFeeEstimates = getEIP1559GasFeeEstimates;
@ -124,38 +121,70 @@ export default class SwapsController {
}); });
} }
async fetchSwapsRefreshRates(chainId, useNewSwapsApi) {
const response = await fetchWithCache(
getBaseApi('network', chainId, useNewSwapsApi),
{ method: 'GET' },
{ cacheRefreshTime: 600000 },
);
const { refreshRates } = response || {};
if (
!refreshRates ||
typeof refreshRates.quotes !== 'number' ||
typeof refreshRates.quotesPrefetching !== 'number'
) {
throw new Error(
`MetaMask - invalid response for refreshRates: ${response}`,
);
}
// We presently use milliseconds in the UI.
return {
quotes: refreshRates.quotes * 1000,
quotesPrefetching: refreshRates.quotesPrefetching * 1000,
};
}
// Sets the refresh rate for quote updates from the MetaSwap API // Sets the refresh rate for quote updates from the MetaSwap API
async _setSwapsQuoteRefreshTime() { async _setSwapsRefreshRates() {
const chainId = this._getCurrentChainId(); const chainId = this._getCurrentChainId();
const { swapsState } = this.store.getState(); const { swapsState } = this.store.getState();
let swapsRefreshRates;
// Default to fallback time unless API returns valid response
let swapsQuoteRefreshTime = FALLBACK_QUOTE_REFRESH_TIME;
try { try {
swapsQuoteRefreshTime = await this._fetchSwapsQuoteRefreshTime( swapsRefreshRates = await this.fetchSwapsRefreshRates(
chainId, chainId,
swapsState.useNewSwapsApi, swapsState.useNewSwapsApi,
); );
} catch (e) { } catch (e) {
console.error('Request for swaps quote refresh time failed: ', e); console.error('Request for swaps quote refresh time failed: ', e);
} }
const { swapsState: latestSwapsState } = this.store.getState(); const { swapsState: latestSwapsState } = this.store.getState();
this.store.updateState({ this.store.updateState({
swapsState: { ...latestSwapsState, swapsQuoteRefreshTime }, swapsState: {
...latestSwapsState,
swapsQuoteRefreshTime:
swapsRefreshRates?.quotes || FALLBACK_QUOTE_REFRESH_TIME,
swapsQuotePrefetchingRefreshTime:
swapsRefreshRates?.quotesPrefetching || FALLBACK_QUOTE_REFRESH_TIME,
},
}); });
} }
// Once quotes are fetched, we poll for new ones to keep the quotes up to date. Market and aggregator contract conditions can change fast enough // Once quotes are fetched, we poll for new ones to keep the quotes up to date. Market and aggregator contract conditions can change fast enough
// that quotes will no longer be available after 1 or 2 minutes. When fetchAndSetQuotes is first called it, receives fetch that parameters are stored in // that quotes will no longer be available after 1 or 2 minutes. When fetchAndSetQuotes is first called, it receives fetch parameters that are stored in
// state. These stored parameters are used on subsequent calls made during polling. // state. These stored parameters are used on subsequent calls made during polling.
// Note: we stop polling after 3 requests, until new quotes are explicitly asked for. The logic that enforces that maximum is in the body of fetchAndSetQuotes // Note: we stop polling after 3 requests, until new quotes are explicitly asked for. The logic that enforces that maximum is in the body of fetchAndSetQuotes
pollForNewQuotes() { pollForNewQuotes() {
const { const {
swapsState: { swapsQuoteRefreshTime }, swapsState: {
swapsQuoteRefreshTime,
swapsQuotePrefetchingRefreshTime,
quotesPollingLimitEnabled,
},
} = this.store.getState(); } = this.store.getState();
// swapsQuoteRefreshTime is used on the View Quote page, swapsQuotePrefetchingRefreshTime is used on the Build Quote page.
const quotesRefreshRateInMs = quotesPollingLimitEnabled
? swapsQuoteRefreshTime
: swapsQuotePrefetchingRefreshTime;
this.pollingTimeout = setTimeout(() => { this.pollingTimeout = setTimeout(() => {
const { swapsState } = this.store.getState(); const { swapsState } = this.store.getState();
this.fetchAndSetQuotes( this.fetchAndSetQuotes(
@ -163,11 +192,13 @@ export default class SwapsController {
swapsState.fetchParams?.metaData, swapsState.fetchParams?.metaData,
true, true,
); );
}, swapsQuoteRefreshTime - QUOTE_POLLING_DIFFERENCE_INTERVAL); }, quotesRefreshRateInMs);
} }
stopPollingForQuotes() { stopPollingForQuotes() {
clearTimeout(this.pollingTimeout); if (this.pollingTimeout) {
clearTimeout(this.pollingTimeout);
}
} }
async fetchAndSetQuotes( async fetchAndSetQuotes(
@ -177,7 +208,7 @@ export default class SwapsController {
) { ) {
const { chainId } = fetchParamsMetaData; const { chainId } = fetchParamsMetaData;
const { const {
swapsState: { useNewSwapsApi }, swapsState: { useNewSwapsApi, quotesPollingLimitEnabled },
} = this.store.getState(); } = this.store.getState();
if (!fetchParams) { if (!fetchParams) {
@ -203,7 +234,7 @@ export default class SwapsController {
...fetchParamsMetaData, ...fetchParamsMetaData,
useNewSwapsApi, useNewSwapsApi,
}), }),
this._setSwapsQuoteRefreshTime(), this._setSwapsRefreshRates(),
]); ]);
newQuotes = mapValues(newQuotes, (quote) => ({ newQuotes = mapValues(newQuotes, (quote) => ({
@ -292,9 +323,13 @@ export default class SwapsController {
}, },
}); });
// We only want to do up to a maximum of three requests from polling. if (quotesPollingLimitEnabled) {
this.pollCount += 1; // We only want to do up to a maximum of three requests from polling if polling limit is enabled.
if (this.pollCount < POLL_COUNT_LIMIT + 1) { // Otherwise we won't increase pollCount, so polling will run without a limit.
this.pollCount += 1;
}
if (!quotesPollingLimitEnabled || this.pollCount < POLL_COUNT_LIMIT + 1) {
this.pollForNewQuotes(); this.pollForNewQuotes();
} else { } else {
this.resetPostFetchState(); this.resetPostFetchState();
@ -322,6 +357,11 @@ export default class SwapsController {
this.store.updateState({ swapsState: { ...swapsState, tokens } }); this.store.updateState({ swapsState: { ...swapsState, tokens } });
} }
clearSwapsQuotes() {
const { swapsState } = this.store.getState();
this.store.updateState({ swapsState: { ...swapsState, quotes: {} } });
}
setSwapsErrorKey(errorKey) { setSwapsErrorKey(errorKey) {
const { swapsState } = this.store.getState(); const { swapsState } = this.store.getState();
this.store.updateState({ swapsState: { ...swapsState, errorKey } }); this.store.updateState({ swapsState: { ...swapsState, errorKey } });
@ -464,6 +504,13 @@ export default class SwapsController {
}); });
} }
setSwapsQuotesPollingLimitEnabled(quotesPollingLimitEnabled) {
const { swapsState } = this.store.getState();
this.store.updateState({
swapsState: { ...swapsState, quotesPollingLimitEnabled },
});
}
setSwapsTxMaxFeePriorityPerGas(maxPriorityFeePerGas) { setSwapsTxMaxFeePriorityPerGas(maxPriorityFeePerGas) {
const { swapsState } = this.store.getState(); const { swapsState } = this.store.getState();
this.store.updateState({ this.store.updateState({
@ -511,6 +558,8 @@ export default class SwapsController {
swapsFeatureIsLive: swapsState.swapsFeatureIsLive, swapsFeatureIsLive: swapsState.swapsFeatureIsLive,
useNewSwapsApi: swapsState.useNewSwapsApi, useNewSwapsApi: swapsState.useNewSwapsApi,
swapsQuoteRefreshTime: swapsState.swapsQuoteRefreshTime, swapsQuoteRefreshTime: swapsState.swapsQuoteRefreshTime,
swapsQuotePrefetchingRefreshTime:
swapsState.swapsQuotePrefetchingRefreshTime,
}, },
}); });
clearTimeout(this.pollingTimeout); clearTimeout(this.pollingTimeout);
@ -523,6 +572,8 @@ export default class SwapsController {
...initialState.swapsState, ...initialState.swapsState,
tokens: swapsState.tokens, tokens: swapsState.tokens,
swapsQuoteRefreshTime: swapsState.swapsQuoteRefreshTime, swapsQuoteRefreshTime: swapsState.swapsQuoteRefreshTime,
swapsQuotePrefetchingRefreshTime:
swapsState.swapsQuotePrefetchingRefreshTime,
}, },
}); });
clearTimeout(this.pollingTimeout); clearTimeout(this.pollingTimeout);

View File

@ -116,6 +116,7 @@ function getMockNetworkController() {
const EMPTY_INIT_STATE = { const EMPTY_INIT_STATE = {
swapsState: { swapsState: {
quotes: {}, quotes: {},
quotesPollingLimitEnabled: false,
fetchParams: null, fetchParams: null,
tokens: null, tokens: null,
tradeTxId: null, tradeTxId: null,
@ -133,13 +134,13 @@ const EMPTY_INIT_STATE = {
swapsFeatureIsLive: true, swapsFeatureIsLive: true,
useNewSwapsApi: false, useNewSwapsApi: false,
swapsQuoteRefreshTime: 60000, swapsQuoteRefreshTime: 60000,
swapsQuotePrefetchingRefreshTime: 60000,
swapsUserFeeLevel: '', swapsUserFeeLevel: '',
}, },
}; };
const sandbox = sinon.createSandbox(); const sandbox = sinon.createSandbox();
const fetchTradesInfoStub = sandbox.stub(); const fetchTradesInfoStub = sandbox.stub();
const fetchSwapsQuoteRefreshTimeStub = sandbox.stub();
const getCurrentChainIdStub = sandbox.stub(); const getCurrentChainIdStub = sandbox.stub();
getCurrentChainIdStub.returns(MAINNET_CHAIN_ID); getCurrentChainIdStub.returns(MAINNET_CHAIN_ID);
const getEIP1559GasFeeEstimatesStub = sandbox.stub(() => { const getEIP1559GasFeeEstimatesStub = sandbox.stub(() => {
@ -162,7 +163,6 @@ describe('SwapsController', function () {
getProviderConfig: MOCK_GET_PROVIDER_CONFIG, getProviderConfig: MOCK_GET_PROVIDER_CONFIG,
tokenRatesStore: MOCK_TOKEN_RATES_STORE, tokenRatesStore: MOCK_TOKEN_RATES_STORE,
fetchTradesInfo: fetchTradesInfoStub, fetchTradesInfo: fetchTradesInfoStub,
fetchSwapsQuoteRefreshTime: fetchSwapsQuoteRefreshTimeStub,
getCurrentChainId: getCurrentChainIdStub, getCurrentChainId: getCurrentChainIdStub,
getEIP1559GasFeeEstimates: getEIP1559GasFeeEstimatesStub, getEIP1559GasFeeEstimates: getEIP1559GasFeeEstimatesStub,
}); });
@ -670,7 +670,6 @@ describe('SwapsController', function () {
it('calls fetchTradesInfo with the given fetchParams and returns the correct quotes', async function () { it('calls fetchTradesInfo with the given fetchParams and returns the correct quotes', async function () {
fetchTradesInfoStub.resolves(getMockQuotes()); fetchTradesInfoStub.resolves(getMockQuotes());
fetchSwapsQuoteRefreshTimeStub.resolves(getMockQuoteRefreshTime());
// Make it so approval is not required // Make it so approval is not required
sandbox sandbox
@ -716,7 +715,6 @@ describe('SwapsController', function () {
it('performs the allowance check', async function () { it('performs the allowance check', async function () {
fetchTradesInfoStub.resolves(getMockQuotes()); fetchTradesInfoStub.resolves(getMockQuotes());
fetchSwapsQuoteRefreshTimeStub.resolves(getMockQuoteRefreshTime());
// Make it so approval is not required // Make it so approval is not required
const allowanceStub = sandbox const allowanceStub = sandbox
@ -740,7 +738,6 @@ describe('SwapsController', function () {
it('gets the gas limit if approval is required', async function () { it('gets the gas limit if approval is required', async function () {
fetchTradesInfoStub.resolves(MOCK_QUOTES_APPROVAL_REQUIRED); fetchTradesInfoStub.resolves(MOCK_QUOTES_APPROVAL_REQUIRED);
fetchSwapsQuoteRefreshTimeStub.resolves(getMockQuoteRefreshTime());
// Ensure approval is required // Ensure approval is required
sandbox sandbox
@ -766,7 +763,6 @@ describe('SwapsController', function () {
it('marks the best quote', async function () { it('marks the best quote', async function () {
fetchTradesInfoStub.resolves(getMockQuotes()); fetchTradesInfoStub.resolves(getMockQuotes());
fetchSwapsQuoteRefreshTimeStub.resolves(getMockQuoteRefreshTime());
// Make it so approval is not required // Make it so approval is not required
sandbox sandbox
@ -797,7 +793,6 @@ describe('SwapsController', function () {
}; };
const quotes = { ...getMockQuotes(), [bestAggId]: bestQuote }; const quotes = { ...getMockQuotes(), [bestAggId]: bestQuote };
fetchTradesInfoStub.resolves(quotes); fetchTradesInfoStub.resolves(quotes);
fetchSwapsQuoteRefreshTimeStub.resolves(getMockQuoteRefreshTime());
// Make it so approval is not required // Make it so approval is not required
sandbox sandbox
@ -815,7 +810,6 @@ describe('SwapsController', function () {
it('does not mark as best quote if no conversion rate exists for destination token', async function () { it('does not mark as best quote if no conversion rate exists for destination token', async function () {
fetchTradesInfoStub.resolves(getMockQuotes()); fetchTradesInfoStub.resolves(getMockQuotes());
fetchSwapsQuoteRefreshTimeStub.resolves(getMockQuoteRefreshTime());
// Make it so approval is not required // Make it so approval is not required
sandbox sandbox
@ -843,6 +837,8 @@ describe('SwapsController', function () {
...EMPTY_INIT_STATE.swapsState, ...EMPTY_INIT_STATE.swapsState,
tokens: old.tokens, tokens: old.tokens,
swapsQuoteRefreshTime: old.swapsQuoteRefreshTime, swapsQuoteRefreshTime: old.swapsQuoteRefreshTime,
swapsQuotePrefetchingRefreshTime:
old.swapsQuotePrefetchingRefreshTime,
}); });
}); });
@ -890,6 +886,7 @@ describe('SwapsController', function () {
const swapsFeatureIsLive = false; const swapsFeatureIsLive = false;
const useNewSwapsApi = false; const useNewSwapsApi = false;
const swapsQuoteRefreshTime = 0; const swapsQuoteRefreshTime = 0;
const swapsQuotePrefetchingRefreshTime = 0;
swapsController.store.updateState({ swapsController.store.updateState({
swapsState: { swapsState: {
tokens, tokens,
@ -897,6 +894,7 @@ describe('SwapsController', function () {
swapsFeatureIsLive, swapsFeatureIsLive,
useNewSwapsApi, useNewSwapsApi,
swapsQuoteRefreshTime, swapsQuoteRefreshTime,
swapsQuotePrefetchingRefreshTime,
}, },
}); });
@ -909,6 +907,7 @@ describe('SwapsController', function () {
fetchParams, fetchParams,
swapsFeatureIsLive, swapsFeatureIsLive,
swapsQuoteRefreshTime, swapsQuoteRefreshTime,
swapsQuotePrefetchingRefreshTime,
}); });
}); });
}); });
@ -1387,7 +1386,3 @@ function getTopQuoteAndSavingsBaseExpectedResults() {
}, },
}; };
} }
function getMockQuoteRefreshTime() {
return 45000;
}

View File

@ -1073,6 +1073,10 @@ export default class MetamaskController extends EventEmitter {
swapsController, swapsController,
), ),
setSwapsTokens: nodeify(swapsController.setSwapsTokens, swapsController), setSwapsTokens: nodeify(swapsController.setSwapsTokens, swapsController),
clearSwapsQuotes: nodeify(
swapsController.clearSwapsQuotes,
swapsController,
),
setApproveTxId: nodeify(swapsController.setApproveTxId, swapsController), setApproveTxId: nodeify(swapsController.setApproveTxId, swapsController),
setTradeTxId: nodeify(swapsController.setTradeTxId, swapsController), setTradeTxId: nodeify(swapsController.setTradeTxId, swapsController),
setSwapsTxGasPrice: nodeify( setSwapsTxGasPrice: nodeify(
@ -1127,6 +1131,10 @@ export default class MetamaskController extends EventEmitter {
swapsController.setSwapsUserFeeLevel, swapsController.setSwapsUserFeeLevel,
swapsController, swapsController,
), ),
setSwapsQuotesPollingLimitEnabled: nodeify(
swapsController.setSwapsQuotesPollingLimitEnabled,
swapsController,
),
// MetaMetrics // MetaMetrics
trackMetaMetricsEvent: nodeify( trackMetaMetricsEvent: nodeify(

View File

@ -212,6 +212,7 @@ export const createSwapsMockStore = () => {
approveTxId: null, approveTxId: null,
quotesLastFetched: 1519211809934, quotesLastFetched: 1519211809934,
swapsQuoteRefreshTime: 60000, swapsQuoteRefreshTime: 60000,
swapsQuotePrefetchingRefreshTime: 60000,
customMaxGas: '', customMaxGas: '',
customGasPrice: null, customGasPrice: null,
selectedAggId: 'TEST_AGG_2', selectedAggId: 'TEST_AGG_2',

View File

@ -86,6 +86,7 @@ const initialState = {
fetchingQuotes: false, fetchingQuotes: false,
fromToken: null, fromToken: null,
quotesFetchStartTime: null, quotesFetchStartTime: null,
reviewSwapClickedTimestamp: null,
topAssets: {}, topAssets: {},
toToken: null, toToken: null,
customGas: { customGas: {
@ -130,6 +131,9 @@ const slice = createSlice({
setQuotesFetchStartTime: (state, action) => { setQuotesFetchStartTime: (state, action) => {
state.quotesFetchStartTime = action.payload; state.quotesFetchStartTime = action.payload;
}, },
setReviewSwapClickedTimestamp: (state, action) => {
state.reviewSwapClickedTimestamp = action.payload;
},
setTopAssets: (state, action) => { setTopAssets: (state, action) => {
state.topAssets = action.payload; state.topAssets = action.payload;
}, },
@ -183,6 +187,9 @@ export const getFetchingQuotes = (state) => state.swaps.fetchingQuotes;
export const getQuotesFetchStartTime = (state) => export const getQuotesFetchStartTime = (state) =>
state.swaps.quotesFetchStartTime; state.swaps.quotesFetchStartTime;
export const getReviewSwapClickedTimestamp = (state) =>
state.swaps.reviewSwapClickedTimestamp;
export const getSwapsCustomizationModalPrice = (state) => export const getSwapsCustomizationModalPrice = (state) =>
state.swaps.customGas.price; state.swaps.customGas.price;
@ -236,6 +243,9 @@ export const getUseNewSwapsApi = (state) =>
export const getSwapsQuoteRefreshTime = (state) => export const getSwapsQuoteRefreshTime = (state) =>
state.metamask.swapsState.swapsQuoteRefreshTime; state.metamask.swapsState.swapsQuoteRefreshTime;
export const getSwapsQuotePrefetchingRefreshTime = (state) =>
state.metamask.swapsState.swapsQuotePrefetchingRefreshTime;
export const getBackgroundSwapRouteState = (state) => export const getBackgroundSwapRouteState = (state) =>
state.metamask.swapsState.routeState; state.metamask.swapsState.routeState;
@ -323,6 +333,7 @@ const {
setFetchingQuotes, setFetchingQuotes,
setFromToken, setFromToken,
setQuotesFetchStartTime, setQuotesFetchStartTime,
setReviewSwapClickedTimestamp,
setTopAssets, setTopAssets,
setToToken, setToToken,
swapCustomGasModalPriceEdited, swapCustomGasModalPriceEdited,
@ -338,6 +349,7 @@ export {
setFetchingQuotes, setFetchingQuotes,
setFromToken as setSwapsFromToken, setFromToken as setSwapsFromToken,
setQuotesFetchStartTime as setSwapQuotesFetchStartTime, setQuotesFetchStartTime as setSwapQuotesFetchStartTime,
setReviewSwapClickedTimestamp,
setTopAssets, setTopAssets,
setToToken as setSwapToToken, setToToken as setSwapToToken,
swapCustomGasModalPriceEdited, swapCustomGasModalPriceEdited,
@ -412,6 +424,7 @@ export const fetchQuotesAndSetQuoteState = (
inputValue, inputValue,
maxSlippage, maxSlippage,
metaMetricsEvent, metaMetricsEvent,
pageRedirectionDisabled,
) => { ) => {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const state = getState(); const state = getState();
@ -461,8 +474,12 @@ export const fetchQuotesAndSetQuoteState = (
decimals: toTokenDecimals, decimals: toTokenDecimals,
iconUrl: toTokenIconUrl, iconUrl: toTokenIconUrl,
} = selectedToToken; } = selectedToToken;
await dispatch(setBackgroundSwapRouteState('loading')); // pageRedirectionDisabled is true if quotes prefetching is active (a user is on the Build Quote page).
history.push(LOADING_QUOTES_ROUTE); // In that case we just want to silently prefetch quotes without redirecting to the quotes loading page.
if (!pageRedirectionDisabled) {
await dispatch(setBackgroundSwapRouteState('loading'));
history.push(LOADING_QUOTES_ROUTE);
}
dispatch(setFetchingQuotes(true)); dispatch(setFetchingQuotes(true));
const contractExchangeRates = getTokenExchangeRates(state); const contractExchangeRates = getTokenExchangeRates(state);

View File

@ -1,5 +1,5 @@
import EventEmitter from 'events'; import EventEmitter from 'events';
import React, { useContext, useRef, useState } from 'react'; import React, { useContext, useRef, useState, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
@ -42,6 +42,7 @@ import { isSwapsDefaultTokenSymbol } from '../../../../shared/modules/swaps.util
import PulseLoader from '../../../components/ui/pulse-loader'; import PulseLoader from '../../../components/ui/pulse-loader';
import { ASSET_ROUTE, DEFAULT_ROUTE } from '../../../helpers/constants/routes'; import { ASSET_ROUTE, DEFAULT_ROUTE } from '../../../helpers/constants/routes';
import { stopPollingForQuotes } from '../../../store/actions';
import { getRenderableNetworkFeesForQuote } from '../swaps.util'; import { getRenderableNetworkFeesForQuote } from '../swaps.util';
import SwapsFooter from '../swaps-footer'; import SwapsFooter from '../swaps-footer';
@ -249,6 +250,13 @@ export default function AwaitingSwap({
); );
}; };
useEffect(() => {
if (errorKey) {
// If there was an error, stop polling for quotes.
dispatch(stopPollingForQuotes());
}
}, [dispatch, errorKey]);
return ( return (
<div className="awaiting-swap"> <div className="awaiting-swap">
<div className="awaiting-swap__content"> <div className="awaiting-swap__content">

View File

@ -19,6 +19,10 @@ import SlippageButtons from '../slippage-buttons';
import { getTokens, getConversionRate } from '../../../ducks/metamask/metamask'; import { getTokens, getConversionRate } from '../../../ducks/metamask/metamask';
import InfoTooltip from '../../../components/ui/info-tooltip'; import InfoTooltip from '../../../components/ui/info-tooltip';
import ActionableMessage from '../../../components/ui/actionable-message/actionable-message'; import ActionableMessage from '../../../components/ui/actionable-message/actionable-message';
import {
VIEW_QUOTE_ROUTE,
LOADING_QUOTES_ROUTE,
} from '../../../helpers/constants/routes';
import { import {
fetchQuotesAndSetQuoteState, fetchQuotesAndSetQuoteState,
@ -29,6 +33,8 @@ import {
getBalanceError, getBalanceError,
getTopAssets, getTopAssets,
getFetchParams, getFetchParams,
getQuotes,
setReviewSwapClickedTimestamp,
} from '../../../ducks/swaps/swaps'; } from '../../../ducks/swaps/swaps';
import { import {
getSwapsDefaultToken, getSwapsDefaultToken,
@ -60,7 +66,13 @@ import {
SWAPS_CHAINID_DEFAULT_TOKEN_MAP, SWAPS_CHAINID_DEFAULT_TOKEN_MAP,
} from '../../../../shared/constants/swaps'; } from '../../../../shared/constants/swaps';
import { resetSwapsPostFetchState, removeToken } from '../../../store/actions'; import {
resetSwapsPostFetchState,
removeToken,
setBackgroundSwapRouteState,
clearSwapsQuotes,
stopPollingForQuotes,
} from '../../../store/actions';
import { import {
fetchTokenPrice, fetchTokenPrice,
fetchTokenBalance, fetchTokenBalance,
@ -76,6 +88,8 @@ const fuseSearchKeys = [
const MAX_ALLOWED_SLIPPAGE = 15; const MAX_ALLOWED_SLIPPAGE = 15;
let timeoutIdForQuotesPrefetching;
export default function BuildQuote({ export default function BuildQuote({
inputValue, inputValue,
onInputChange, onInputChange,
@ -110,6 +124,8 @@ export default function BuildQuote({
const rpcPrefs = useSelector(getRpcPrefsForCurrentProvider); const rpcPrefs = useSelector(getRpcPrefsForCurrentProvider);
const tokenList = useSelector(getTokenList); const tokenList = useSelector(getTokenList);
const useTokenDetection = useSelector(getUseTokenDetection); const useTokenDetection = useSelector(getUseTokenDetection);
const quotes = useSelector(getQuotes, isEqual);
const areQuotesPresent = Object.keys(quotes).length > 0;
const tokenConversionRates = useSelector(getTokenExchangeRates, isEqual); const tokenConversionRates = useSelector(getTokenExchangeRates, isEqual);
const conversionRate = useSelector(getConversionRate); const conversionRate = useSelector(getConversionRate);
@ -168,6 +184,7 @@ export default function BuildQuote({
decimals: fromTokenDecimals, decimals: fromTokenDecimals,
balance: rawFromTokenBalance, balance: rawFromTokenBalance,
} = selectedFromToken || {}; } = selectedFromToken || {};
const { address: toTokenAddress } = selectedToToken || {};
const fromTokenBalance = const fromTokenBalance =
rawFromTokenBalance && rawFromTokenBalance &&
@ -348,6 +365,7 @@ export default function BuildQuote({
useEffect(() => { useEffect(() => {
dispatch(resetSwapsPostFetchState()); dispatch(resetSwapsPostFetchState());
dispatch(setReviewSwapClickedTimestamp());
}, [dispatch]); }, [dispatch]);
const BlockExplorerLink = () => { const BlockExplorerLink = () => {
@ -392,6 +410,51 @@ export default function BuildQuote({
fromTokenAddress, fromTokenAddress,
selectedToToken.address, selectedToToken.address,
); );
const isReviewSwapButtonDisabled =
tokenFromError ||
!isFeatureFlagLoaded ||
!Number(inputValue) ||
!selectedToToken?.address ||
Number(maxSlippage) < 0 ||
Number(maxSlippage) > MAX_ALLOWED_SLIPPAGE ||
(toTokenIsNotDefault && occurrences < 2 && !verificationClicked);
// It's triggered every time there is a change in form values (token from, token to, amount and slippage).
useEffect(() => {
dispatch(clearSwapsQuotes());
dispatch(stopPollingForQuotes());
const prefetchQuotesWithoutRedirecting = async () => {
const pageRedirectionDisabled = true;
await dispatch(
fetchQuotesAndSetQuoteState(
history,
inputValue,
maxSlippage,
metaMetricsEvent,
pageRedirectionDisabled,
),
);
};
// Delay fetching quotes until a user is done typing an input value. If they type a new char in less than a second,
// we will cancel previous setTimeout call and start running a new one.
timeoutIdForQuotesPrefetching = setTimeout(() => {
timeoutIdForQuotesPrefetching = null;
if (!isReviewSwapButtonDisabled) {
// Only do quotes prefetching if the Review Swap button is enabled.
prefetchQuotesWithoutRedirecting();
}
}, 1000);
return () => clearTimeout(timeoutIdForQuotesPrefetching);
}, [
dispatch,
history,
maxSlippage,
metaMetricsEvent,
isReviewSwapButtonDisabled,
inputValue,
fromTokenAddress,
toTokenAddress,
]);
return ( return (
<div className="build-quote"> <div className="build-quote">
@ -401,6 +464,7 @@ export default function BuildQuote({
{!isSwapsDefaultTokenSymbol(fromTokenSymbol, chainId) && ( {!isSwapsDefaultTokenSymbol(fromTokenSymbol, chainId) && (
<div <div
className="build-quote__max-button" className="build-quote__max-button"
data-testid="build-quote__max-button"
onClick={() => onClick={() =>
onInputChange(fromTokenBalance || '0', fromTokenBalance) onInputChange(fromTokenBalance || '0', fromTokenBalance)
} }
@ -583,26 +647,32 @@ export default function BuildQuote({
)} )}
</div> </div>
<SwapsFooter <SwapsFooter
onSubmit={() => { onSubmit={async () => {
dispatch( // We need this to know how long it took to go from clicking on the Review Swap button to rendered View Quote page.
fetchQuotesAndSetQuoteState( dispatch(setReviewSwapClickedTimestamp(Date.now()));
history, // In case that quotes prefetching is waiting to be executed, but hasn't started yet,
inputValue, // we want to cancel it and fetch quotes from here.
maxSlippage, if (timeoutIdForQuotesPrefetching) {
metaMetricsEvent, clearTimeout(timeoutIdForQuotesPrefetching);
), dispatch(
); fetchQuotesAndSetQuoteState(
history,
inputValue,
maxSlippage,
metaMetricsEvent,
),
);
} else if (areQuotesPresent) {
// If there are prefetched quotes already, go directly to the View Quote page.
history.push(VIEW_QUOTE_ROUTE);
} else {
// If the "Review Swap" button was clicked while quotes are being fetched, go to the Loading Quotes page.
await dispatch(setBackgroundSwapRouteState('loading'));
history.push(LOADING_QUOTES_ROUTE);
}
}} }}
submitText={t('swapReviewSwap')} submitText={t('swapReviewSwap')}
disabled={ disabled={isReviewSwapButtonDisabled}
tokenFromError ||
!isFeatureFlagLoaded ||
!Number(inputValue) ||
!selectedToToken?.address ||
Number(maxSlippage) < 0 ||
Number(maxSlippage) > MAX_ALLOWED_SLIPPAGE ||
(toTokenIsNotDefault && occurrences < 2 && !verificationClicked)
}
hideCancel hideCancel
showTermsOfService showTermsOfService
/> />

View File

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import configureMockStore from 'redux-mock-store'; import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk'; import thunk from 'redux-thunk';
import { fireEvent } from '@testing-library/react';
import { import {
renderWithProvider, renderWithProvider,
@ -14,7 +15,7 @@ const createProps = (customProps = {}) => {
return { return {
inputValue: '5', inputValue: '5',
onInputChange: jest.fn(), onInputChange: jest.fn(),
ethBalance: '6 ETH', ethBalance: '0x8',
setMaxSlippage: jest.fn(), setMaxSlippage: jest.fn(),
maxSlippage: 15, maxSlippage: 15,
selectedAccountAddress: 'selectedAccountAddress', selectedAccountAddress: 'selectedAccountAddress',
@ -26,6 +27,10 @@ const createProps = (customProps = {}) => {
setBackgroundConnection({ setBackgroundConnection({
resetPostFetchState: jest.fn(), resetPostFetchState: jest.fn(),
removeToken: jest.fn(),
setBackgroundSwapRouteState: jest.fn(),
clearSwapsQuotes: jest.fn(),
stopPollingForQuotes: jest.fn(),
}); });
describe('BuildQuote', () => { describe('BuildQuote', () => {
@ -44,4 +49,28 @@ describe('BuildQuote', () => {
document.querySelector('.slippage-buttons__button-group'), document.querySelector('.slippage-buttons__button-group'),
).toMatchSnapshot(); ).toMatchSnapshot();
}); });
it('clicks on the max button', () => {
const store = configureMockStore(middleware)(createSwapsMockStore());
const props = createProps();
const { getByTestId } = renderWithProvider(
<BuildQuote {...props} />,
store,
);
fireEvent.click(getByTestId('build-quote__max-button'));
expect(props.onInputChange).toHaveBeenCalled();
});
it('types a number inside the input field', () => {
const store = configureMockStore(middleware)(createSwapsMockStore());
const props = createProps();
const { getByDisplayValue } = renderWithProvider(
<BuildQuote {...props} />,
store,
);
fireEvent.change(getByDisplayValue('5'), {
target: { value: '8' },
});
expect(props.onInputChange).toHaveBeenCalled();
});
}); });

View File

@ -37,6 +37,7 @@ import {
fetchSwapsLiveness, fetchSwapsLiveness,
getUseNewSwapsApi, getUseNewSwapsApi,
getFromToken, getFromToken,
getReviewSwapClickedTimestamp,
} from '../../ducks/swaps/swaps'; } from '../../ducks/swaps/swaps';
import { import {
checkNetworkAndAccountSupports1559, checkNetworkAndAccountSupports1559,
@ -123,6 +124,8 @@ export default function Swap() {
const fromToken = useSelector(getFromToken); const fromToken = useSelector(getFromToken);
const tokenList = useSelector(getTokenList); const tokenList = useSelector(getTokenList);
const listTokenValues = shuffle(Object.values(tokenList)); const listTokenValues = shuffle(Object.values(tokenList));
const reviewSwapClickedTimestamp = useSelector(getReviewSwapClickedTimestamp);
const reviewSwapClicked = Boolean(reviewSwapClickedTimestamp);
if (networkAndAccountSupports1559) { if (networkAndAccountSupports1559) {
// This will pre-load gas fees before going to the View Quote page. // This will pre-load gas fees before going to the View Quote page.
@ -255,10 +258,12 @@ export default function Swap() {
}, [dispatch]); }, [dispatch]);
useEffect(() => { useEffect(() => {
if (swapsErrorKey && !isSwapsErrorRoute) { // If there is a swapsErrorKey and reviewSwapClicked is false, there was an error in silent quotes prefetching
// and we don't want to show the error page in that case, because another API call for quotes can be successful.
if (swapsErrorKey && !isSwapsErrorRoute && reviewSwapClicked) {
history.push(SWAPS_ERROR_ROUTE); history.push(SWAPS_ERROR_ROUTE);
} }
}, [history, swapsErrorKey, isSwapsErrorRoute]); }, [history, swapsErrorKey, isSwapsErrorRoute, reviewSwapClicked]);
const beforeUnloadEventAddedRef = useRef(); const beforeUnloadEventAddedRef = useRef();
useEffect(() => { useEffect(() => {
@ -393,7 +398,6 @@ export default function Swap() {
} }
onDone={async () => { onDone={async () => {
await dispatch(setBackgroundSwapRouteState('')); await dispatch(setBackgroundSwapRouteState(''));
if ( if (
swapsErrorKey === ERROR_FETCHING_QUOTES || swapsErrorKey === ERROR_FETCHING_QUOTES ||
swapsErrorKey === QUOTES_NOT_AVAILABLE_ERROR swapsErrorKey === QUOTES_NOT_AVAILABLE_ERROR

View File

@ -1,18 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AggregatorLogo renders the component with initial props 1`] = `
<div>
<div
class="loading-swaps-quotes__logo"
>
<div
style="background: rgb(0, 0, 0); box-shadow: 0px 4px 20px rgba(0, 0, NaN, 0.25);"
>
<img
alt=""
src="data:image/png;base64,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"
/>
</div>
</div>
</div>
`;

View File

@ -1,40 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
// Inspired by https://stackoverflow.com/a/28056903/4727685
function hexToRGB(hex, alpha) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
export default function AggregatorLogo({ name, icon, color }) {
return (
<div className="loading-swaps-quotes__logo">
{icon && color ? (
<div
style={{
background: color,
boxShadow: `0px 4px 20px ${hexToRGB(color, 0.25)}`,
}}
>
<img src={icon} alt="" />
</div>
) : (
name && (
<div>
<span>{name}</span>
</div>
)
)}
</div>
);
}
AggregatorLogo.propTypes = {
name: PropTypes.string,
icon: PropTypes.string,
color: PropTypes.string,
};

View File

@ -1,22 +0,0 @@
import React from 'react';
import { renderWithProvider } from '../../../../test/jest';
import AggregatorLogo from './aggregator-logo';
const createProps = (customProps = {}) => {
return {
color: '#000',
icon:
'data:image/png;base64,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',
...customProps,
};
};
describe('AggregatorLogo', () => {
it('renders the component with initial props', () => {
const { container } = renderWithProvider(
<AggregatorLogo {...createProps()} />,
);
expect(container).toMatchSnapshot();
});
});

View File

@ -4,7 +4,6 @@ import { useDispatch, useSelector } from 'react-redux';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { shuffle } from 'lodash'; import { shuffle } from 'lodash';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import classnames from 'classnames';
import { import {
navigateBackToBuildQuote, navigateBackToBuildQuote,
getFetchParams, getFetchParams,
@ -19,44 +18,6 @@ import { MetaMetricsContext } from '../../../contexts/metametrics.new';
import Mascot from '../../../components/ui/mascot'; import Mascot from '../../../components/ui/mascot';
import SwapsFooter from '../swaps-footer'; import SwapsFooter from '../swaps-footer';
import BackgroundAnimation from './background-animation'; import BackgroundAnimation from './background-animation';
import AggregatorLogo from './aggregator-logo';
// These locations reference where we want the top-left corner of the logo div to appear in relation to the
// centre point of the fox
const AGGREGATOR_LOCATIONS = [
{ x: -125, y: -75 },
{ x: 30, y: -75 },
{ x: -145, y: 0 },
{ x: 50, y: 0 },
{ x: -135, y: 46 },
{ x: 40, y: 46 },
];
function getRandomLocations(numberOfLocations) {
const randomLocations = shuffle(AGGREGATOR_LOCATIONS);
if (numberOfLocations <= AGGREGATOR_LOCATIONS.length) {
return randomLocations.slice(0, numberOfLocations);
}
const numberOfExtraLocations =
numberOfLocations - AGGREGATOR_LOCATIONS.length;
return [...randomLocations, ...getRandomLocations(numberOfExtraLocations)];
}
function getMascotTarget(aggregatorName, centerPoint, aggregatorLocationMap) {
const location = aggregatorLocationMap[aggregatorName];
if (!location || !centerPoint) {
return centerPoint ?? {};
}
// The aggregator logos are 94px x 40px. For the fox to look at the center of each logo, the target needs to be
// the coordinates for the centre point of the fox + the desired top and left coordinates of the logo + half
// the height and width of the logo.
return {
x: location.x + centerPoint.x + 47,
y: location.y + centerPoint.y + 20,
};
}
export default function LoadingSwapsQuotes({ export default function LoadingSwapsQuotes({
aggregatorMetadata, aggregatorMetadata,
@ -97,20 +58,6 @@ export default function LoadingSwapsQuotes({
const currentMascotContainer = mascotContainer.current; const currentMascotContainer = mascotContainer.current;
const [quoteCount, updateQuoteCount] = useState(0); const [quoteCount, updateQuoteCount] = useState(0);
// is an array of randomized items from AGGREGATOR_LOCATIONS, containing
// numberOfQuotes number of items it is randomized so that the order in
// which the fox looks at locations is random
const [aggregatorLocations] = useState(() =>
getRandomLocations(numberOfQuotes),
);
const _aggregatorLocationMap = aggregatorNames.reduce(
(nameLocationMap, name, index) => ({
...nameLocationMap,
[name]: aggregatorLocations[index],
}),
{},
);
const [aggregatorLocationMap] = useState(_aggregatorLocationMap);
const [midPointTarget, setMidpointTarget] = useState(null); const [midPointTarget, setMidpointTarget] = useState(null);
useEffect(() => { useEffect(() => {
@ -122,7 +69,7 @@ export default function LoadingSwapsQuotes({
if (loadingComplete) { if (loadingComplete) {
// If loading is complete, but the quoteCount is not, we quickly display the remaining logos/names/fox looks. 0.2s each // If loading is complete, but the quoteCount is not, we quickly display the remaining logos/names/fox looks. 0.2s each
timeoutLength = 200; timeoutLength = 20;
} else { } else {
// If loading is not complete, we display remaining logos/names/fox looks at random intervals between 0.5s and 2s, to simulate the // If loading is not complete, we display remaining logos/names/fox looks at random intervals between 0.5s and 2s, to simulate the
// sort of loading a user would experience in most async scenarios // sort of loading a user would experience in most async scenarios
@ -167,13 +114,7 @@ export default function LoadingSwapsQuotes({
</span> </span>
</div> </div>
<div className="loading-swaps-quotes__quote-name-check"> <div className="loading-swaps-quotes__quote-name-check">
<span> <span>{t('swapFetchingQuotes')}</span>
{quoteCount === numberOfQuotes
? t('swapFinalizing')
: t('swapCheckingQuote', [
aggregatorMetadata[aggregatorNames[quoteCount]].title,
])}
</span>
</div> </div>
<div className="loading-swaps-quotes__loading-bar-container"> <div className="loading-swaps-quotes__loading-bar-container">
<div <div
@ -195,37 +136,9 @@ export default function LoadingSwapsQuotes({
width="90" width="90"
height="90" height="90"
followMouse={false} followMouse={false}
lookAtTarget={getMascotTarget( lookAtTarget={midPointTarget}
aggregatorNames[quoteCount],
midPointTarget,
aggregatorLocationMap,
)}
/> />
</div> </div>
{currentMascotContainer &&
midPointTarget &&
aggregatorNames.map((aggName) => (
<div
className={classnames('loading-swaps-quotes__logo', {
'loading-swaps-quotes__logo--transition':
aggName === aggregatorNames[quoteCount],
})}
style={{
opacity: aggName === aggregatorNames[quoteCount] ? 1 : 0,
top:
aggregatorLocationMap[aggName]?.y + midPointTarget?.y ?? 0,
left:
aggregatorLocationMap[aggName]?.x + midPointTarget?.x ?? 0,
}}
key={`aggregator-logo-${aggName}`}
>
<AggregatorLogo
name={aggregatorMetadata[aggName]?.title}
icon={aggregatorMetadata[aggName]?.icon}
color={aggregatorMetadata[aggName]?.color}
/>
</div>
))}
</div> </div>
</div> </div>
<SwapsFooter <SwapsFooter

View File

@ -35,7 +35,6 @@ describe('LoadingSwapsQuotes', () => {
store, store,
); );
expect(getByText('Quote 1 of 2')).toBeInTheDocument(); expect(getByText('Quote 1 of 2')).toBeInTheDocument();
expect(getByText('Checking agg', { exact: false })).toBeInTheDocument();
expect(getByText('Back')).toBeInTheDocument(); expect(getByText('Back')).toBeInTheDocument();
}); });
}); });

View File

@ -76,7 +76,7 @@ const getBaseUrlForNewSwapsApi = (type, chainId) => {
return `${v2ApiBaseUrl}/networks/${chainIdDecimal}`; return `${v2ApiBaseUrl}/networks/${chainIdDecimal}`;
}; };
const getBaseApi = function ( export const getBaseApi = function (
type, type,
chainId = MAINNET_CHAIN_ID, chainId = MAINNET_CHAIN_ID,
useNewSwapsApi = false, useNewSwapsApi = false,
@ -84,6 +84,7 @@ const getBaseApi = function (
const baseUrl = useNewSwapsApi const baseUrl = useNewSwapsApi
? getBaseUrlForNewSwapsApi(type, chainId) ? getBaseUrlForNewSwapsApi(type, chainId)
: METASWAP_CHAINID_API_HOST_MAP[chainId]; : METASWAP_CHAINID_API_HOST_MAP[chainId];
const chainIdDecimal = chainId && parseInt(chainId, 16);
if (!baseUrl) { if (!baseUrl) {
throw new Error(`Swaps API calls are disabled for chainId: ${chainId}`); throw new Error(`Swaps API calls are disabled for chainId: ${chainId}`);
} }
@ -100,8 +101,9 @@ const getBaseApi = function (
return `${baseUrl}/aggregatorMetadata`; return `${baseUrl}/aggregatorMetadata`;
case 'gasPrices': case 'gasPrices':
return `${baseUrl}/gasPrices`; return `${baseUrl}/gasPrices`;
case 'refreshTime': case 'network':
return `${baseUrl}/quoteRefreshRate`; // Only use v2 for this endpoint.
return `${SWAPS_API_V2_BASE_URL}/networks/${chainIdDecimal}`;
default: default:
throw new Error('getBaseApi requires an api call type'); throw new Error('getBaseApi requires an api call type');
} }
@ -441,23 +443,6 @@ export async function fetchSwapsFeatureFlags() {
return response; return response;
} }
export async function fetchSwapsQuoteRefreshTime(chainId, useNewSwapsApi) {
const response = await fetchWithCache(
getBaseApi('refreshTime', chainId, useNewSwapsApi),
{ method: 'GET' },
{ cacheRefreshTime: 600000 },
);
// We presently use milliseconds in the UI
if (typeof response?.seconds === 'number' && response.seconds > 0) {
return response.seconds * 1000;
}
throw new Error(
`MetaMask - refreshTime provided invalid response: ${response}`,
);
}
export async function fetchTokenPrice(address) { export async function fetchTokenPrice(address) {
const query = `contract_addresses=${address}&vs_currencies=eth`; const query = `contract_addresses=${address}&vs_currencies=eth`;

View File

@ -35,6 +35,7 @@ import {
getBackgroundSwapRouteState, getBackgroundSwapRouteState,
swapsQuoteSelected, swapsQuoteSelected,
getSwapsQuoteRefreshTime, getSwapsQuoteRefreshTime,
getReviewSwapClickedTimestamp,
} from '../../../ducks/swaps/swaps'; } from '../../../ducks/swaps/swaps';
import { import {
conversionRateSelector, conversionRateSelector,
@ -56,6 +57,7 @@ import {
setCustomApproveTxData, setCustomApproveTxData,
setSwapsErrorKey, setSwapsErrorKey,
showModal, showModal,
setSwapsQuotesPollingLimitEnabled,
} from '../../../store/actions'; } from '../../../store/actions';
import { import {
ASSET_ROUTE, ASSET_ROUTE,
@ -105,6 +107,8 @@ export default function ViewQuote() {
const [warningHidden, setWarningHidden] = useState(false); const [warningHidden, setWarningHidden] = useState(false);
const [originalApproveAmount, setOriginalApproveAmount] = useState(null); const [originalApproveAmount, setOriginalApproveAmount] = useState(null);
const [showEditGasPopover, setShowEditGasPopover] = useState(false); const [showEditGasPopover, setShowEditGasPopover] = useState(false);
// We need to have currentTimestamp in state, otherwise it would change with each rerender.
const [currentTimestamp] = useState(Date.now());
const [ const [
acknowledgedPriceDifference, acknowledgedPriceDifference,
@ -150,6 +154,7 @@ export default function ViewQuote() {
const defaultSwapsToken = useSelector(getSwapsDefaultToken); const defaultSwapsToken = useSelector(getSwapsDefaultToken);
const chainId = useSelector(getCurrentChainId); const chainId = useSelector(getCurrentChainId);
const nativeCurrencySymbol = useSelector(getNativeCurrency); const nativeCurrencySymbol = useSelector(getNativeCurrency);
const reviewSwapClickedTimestamp = useSelector(getReviewSwapClickedTimestamp);
let gasFeeInputs; let gasFeeInputs;
if (networkAndAccountSupports1559) { if (networkAndAccountSupports1559) {
@ -371,6 +376,9 @@ export default function ViewQuote() {
const showInsufficientWarning = const showInsufficientWarning =
(balanceError || tokenBalanceNeeded || ethBalanceNeeded) && !warningHidden; (balanceError || tokenBalanceNeeded || ethBalanceNeeded) && !warningHidden;
const hardwareWalletUsed = useSelector(isHardwareWallet);
const hardwareWalletType = useSelector(getHardwareWalletType);
const numberOfQuotes = Object.values(quotes).length; const numberOfQuotes = Object.values(quotes).length;
const bestQuoteReviewedEventSent = useRef(); const bestQuoteReviewedEventSent = useRef();
const eventObjectBase = { const eventObjectBase = {
@ -384,10 +392,10 @@ export default function ViewQuote() {
response_time: fetchParams?.responseTime, response_time: fetchParams?.responseTime,
best_quote_source: topQuote?.aggregator, best_quote_source: topQuote?.aggregator,
available_quotes: numberOfQuotes, available_quotes: numberOfQuotes,
is_hardware_wallet: hardwareWalletUsed,
hardware_wallet_type: hardwareWalletType,
}; };
const hardwareWalletUsed = useSelector(isHardwareWallet);
const hardwareWalletType = useSelector(getHardwareWalletType);
const allAvailableQuotesOpened = useNewMetricEvent({ const allAvailableQuotesOpened = useNewMetricEvent({
event: 'All Available Quotes Opened', event: 'All Available Quotes Opened',
category: 'swaps', category: 'swaps',
@ -398,8 +406,6 @@ export default function ViewQuote() {
usedQuote?.aggregator === topQuote?.aggregator usedQuote?.aggregator === topQuote?.aggregator
? null ? null
: usedQuote?.aggregator, : usedQuote?.aggregator,
is_hardware_wallet: hardwareWalletUsed,
hardware_wallet_type: hardwareWalletType,
}, },
}); });
const quoteDetailsOpened = useNewMetricEvent({ const quoteDetailsOpened = useNewMetricEvent({
@ -412,8 +418,6 @@ export default function ViewQuote() {
usedQuote?.aggregator === topQuote?.aggregator usedQuote?.aggregator === topQuote?.aggregator
? null ? null
: usedQuote?.aggregator, : usedQuote?.aggregator,
is_hardware_wallet: hardwareWalletUsed,
hardware_wallet_type: hardwareWalletType,
}, },
}); });
const editSpendLimitOpened = useNewMetricEvent({ const editSpendLimitOpened = useNewMetricEvent({
@ -424,8 +428,6 @@ export default function ViewQuote() {
custom_spend_limit_set: originalApproveAmount === approveAmount, custom_spend_limit_set: originalApproveAmount === approveAmount,
custom_spend_limit_amount: custom_spend_limit_amount:
originalApproveAmount === approveAmount ? null : approveAmount, originalApproveAmount === approveAmount ? null : approveAmount,
is_hardware_wallet: hardwareWalletUsed,
hardware_wallet_type: hardwareWalletType,
}, },
}); });
@ -435,10 +437,18 @@ export default function ViewQuote() {
sensitiveProperties: { sensitiveProperties: {
...eventObjectBase, ...eventObjectBase,
network_fees: feeInFiat, network_fees: feeInFiat,
is_hardware_wallet: hardwareWalletUsed,
hardware_wallet_type: hardwareWalletType,
}, },
}); });
const viewQuotePageLoadedEvent = useNewMetricEvent({
event: 'View Quote Page Loaded',
category: 'swaps',
sensitiveProperties: {
...eventObjectBase,
response_time: currentTimestamp - reviewSwapClickedTimestamp,
},
});
useEffect(() => { useEffect(() => {
if ( if (
!bestQuoteReviewedEventSent.current && !bestQuoteReviewedEventSent.current &&
@ -653,6 +663,14 @@ export default function ViewQuote() {
setShowEditGasPopover(false); setShowEditGasPopover(false);
}; };
useEffect(() => {
// Thanks to the next line we will only do quotes polling 3 times before showing a Quote Timeout modal.
dispatch(setSwapsQuotesPollingLimitEnabled(true));
if (reviewSwapClickedTimestamp) {
viewQuotePageLoadedEvent();
}
}, [dispatch, viewQuotePageLoadedEvent, reviewSwapClickedTimestamp]);
return ( return (
<div className="view-quote"> <div className="view-quote">
<div <div

View File

@ -47,6 +47,7 @@ setBackgroundConnection({
getGasFeeEstimatesAndStartPolling: jest.fn(), getGasFeeEstimatesAndStartPolling: jest.fn(),
updateTransaction: jest.fn(), updateTransaction: jest.fn(),
getGasFeeTimeEstimate: jest.fn(), getGasFeeTimeEstimate: jest.fn(),
setSwapsQuotesPollingLimitEnabled: jest.fn(),
}); });
describe('ViewQuote', () => { describe('ViewQuote', () => {

View File

@ -2161,6 +2161,13 @@ export function setSwapsTokens(tokens) {
}; };
} }
export function clearSwapsQuotes() {
return async (dispatch) => {
await promisifiedBackground.clearSwapsQuotes();
await forceUpdateMetamaskState(dispatch);
};
}
export function resetBackgroundSwapsState() { export function resetBackgroundSwapsState() {
return async (dispatch) => { return async (dispatch) => {
const id = await promisifiedBackground.resetSwapsState(); const id = await promisifiedBackground.resetSwapsState();
@ -2214,6 +2221,15 @@ export function updateSwapsUserFeeLevel(swapsCustomUserFeeLevel) {
}; };
} }
export function setSwapsQuotesPollingLimitEnabled(quotesPollingLimitEnabled) {
return async (dispatch) => {
await promisifiedBackground.setSwapsQuotesPollingLimitEnabled(
quotesPollingLimitEnabled,
);
await forceUpdateMetamaskState(dispatch);
};
}
export function customSwapsGasParamsUpdated(gasLimit, gasPrice) { export function customSwapsGasParamsUpdated(gasLimit, gasPrice) {
return async (dispatch) => { return async (dispatch) => {
await promisifiedBackground.setSwapsTxGasPrice(gasPrice); await promisifiedBackground.setSwapsTxGasPrice(gasPrice);