Merge pull request #10072 from MetaMask/Version-v8.1.9

Version v8.1.9 RC
feature/default_network_editable
Mark Stacey 4 years ago committed by GitHub
commit 47734b2c63
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      .eslintrc.js
  2. 8
      CHANGELOG.md
  3. 3
      app/manifest/_base.json
  4. 27
      app/scripts/contentscript.js
  5. 19
      app/scripts/controllers/permissions/enums.js
  6. 45
      app/scripts/controllers/swaps.js
  7. 9
      app/scripts/disable-console.js
  8. 11
      app/scripts/runLockdown.js
  9. 18
      development/build/scripts.js
  10. 25
      test/unit/app/controllers/swaps-test.js
  11. 2
      ui/app/components/app/signature-request/signature-request-message/signature-request-message.component.js
  12. 20
      ui/app/ducks/swaps/swaps.js
  13. 11
      ui/app/helpers/utils/conversions.util.js
  14. 9
      ui/app/helpers/utils/fetch-with-cache.js
  15. 43
      ui/app/helpers/utils/fetch-with-cache.test.js
  16. 10
      ui/app/pages/first-time-flow/metametrics-opt-in/metametrics-opt-in.component.js
  17. 2
      ui/app/pages/send/send.component.js
  18. 15
      ui/app/pages/swaps/countdown-timer/countdown-timer.js
  19. 38
      ui/app/pages/swaps/swaps.util.js
  20. 31
      ui/app/pages/swaps/view-quote/view-quote.js
  21. 12
      yarn.lock

@ -195,6 +195,7 @@ module.exports = {
'babel.config.js', 'babel.config.js',
'nyc.config.js', 'nyc.config.js',
'stylelint.config.js', 'stylelint.config.js',
'app/scripts/runLockdown.js',
'development/**/*.js', 'development/**/*.js',
'test/e2e/**/*.js', 'test/e2e/**/*.js',
'test/lib/wait-until-called.js', 'test/lib/wait-until-called.js',

@ -2,6 +2,14 @@
## Current Develop Branch ## Current Develop Branch
## 8.1.9 Tue Dec 15 2020
- [#10034](https://github.com/MetaMask/metamask-extension/pull/10034): Fix contentscript injection failure on Firefox 56
- [#10045](https://github.com/MetaMask/metamask-extension/pull/10045): Fix token validation in Send flow
- [#10048](https://github.com/MetaMask/metamask-extension/pull/10048): Display boolean values when signing typed data
- [#10070](https://github.com/MetaMask/metamask-extension/pull/10070): Add eth_getProof
- [#10043](https://github.com/MetaMask/metamask-extension/pull/10043): Improve swaps maximum gas estimation
- [#10069](https://github.com/MetaMask/metamask-extension/pull/10069): Fetch swap quote refresh time from API
## 8.1.8 Wed Dec 09 2020 ## 8.1.8 Wed Dec 09 2020
- [#9992](https://github.com/MetaMask/metamask-extension/pull/9992): Improve transaction params validation - [#9992](https://github.com/MetaMask/metamask-extension/pull/9992): Improve transaction params validation
- [#9991](https://github.com/MetaMask/metamask-extension/pull/9991): Don't allow more than 15% slippage - [#9991](https://github.com/MetaMask/metamask-extension/pull/9991): Don't allow more than 15% slippage

@ -38,6 +38,7 @@
{ {
"matches": ["file://*/*", "http://*/*", "https://*/*"], "matches": ["file://*/*", "http://*/*", "https://*/*"],
"js": [ "js": [
"disable-console.js",
"globalthis.js", "globalthis.js",
"lockdown.js", "lockdown.js",
"runLockdown.js", "runLockdown.js",
@ -77,6 +78,6 @@
"notifications" "notifications"
], ],
"short_name": "__MSG_appName__", "short_name": "__MSG_appName__",
"version": "8.1.8", "version": "8.1.9",
"web_accessible_resources": ["inpage.js", "phishing.html"] "web_accessible_resources": ["inpage.js", "phishing.html"]
} }

@ -41,8 +41,8 @@ function injectScript(content) {
scriptTag.textContent = content scriptTag.textContent = content
container.insertBefore(scriptTag, container.children[0]) container.insertBefore(scriptTag, container.children[0])
container.removeChild(scriptTag) container.removeChild(scriptTag)
} catch (e) { } catch (error) {
console.error('MetaMask provider injection failed.', e) console.error('MetaMask: Provider injection failed.', error)
} }
} }
@ -95,10 +95,10 @@ async function setupStreams() {
function forwardTrafficBetweenMuxers(channelName, muxA, muxB) { function forwardTrafficBetweenMuxers(channelName, muxA, muxB) {
const channelA = muxA.createStream(channelName) const channelA = muxA.createStream(channelName)
const channelB = muxB.createStream(channelName) const channelB = muxB.createStream(channelName)
pump(channelA, channelB, channelA, (err) => pump(channelA, channelB, channelA, (error) =>
logStreamDisconnectWarning( console.debug(
`MetaMask muxed traffic for channel "${channelName}" failed.`, `MetaMask: Muxed traffic for channel "${channelName}" failed.`,
err, error,
), ),
) )
} }
@ -107,14 +107,13 @@ function forwardTrafficBetweenMuxers(channelName, muxA, muxB) {
* Error handler for page to extension stream disconnections * Error handler for page to extension stream disconnections
* *
* @param {string} remoteLabel - Remote stream name * @param {string} remoteLabel - Remote stream name
* @param {Error} err - Stream connection error * @param {Error} error - Stream connection error
*/ */
function logStreamDisconnectWarning(remoteLabel, err) { function logStreamDisconnectWarning(remoteLabel, error) {
let warningMsg = `MetamaskContentscript - lost connection to ${remoteLabel}` console.debug(
if (err) { `MetaMask: Content script lost connection to "${remoteLabel}".`,
warningMsg += `\n${err.stack}` error,
} )
console.warn(warningMsg)
} }
/** /**
@ -214,7 +213,7 @@ function blockedDomainCheck() {
* Redirects the current page to a phishing information page * Redirects the current page to a phishing information page
*/ */
function redirectToPhishingWarning() { function redirectToPhishingWarning() {
console.log('MetaMask - routing to Phishing Warning component') console.debug('MetaMask: Routing to Phishing Warning component.')
const extensionURL = extension.runtime.getURL('phishing.html') const extensionURL = extension.runtime.getURL('phishing.html')
window.location.href = `${extensionURL}#${querystring.stringify({ window.location.href = `${extensionURL}#${querystring.stringify({
hostname: window.location.hostname, hostname: window.location.hostname,

@ -32,15 +32,11 @@ export const LOG_METHOD_TYPES = {
export const LOG_LIMIT = 100 export const LOG_LIMIT = 100
export const SAFE_METHODS = [ export const SAFE_METHODS = [
'web3_sha3',
'web3_clientVersion',
'net_listening',
'net_peerCount',
'net_version',
'eth_blockNumber', 'eth_blockNumber',
'eth_call', 'eth_call',
'eth_chainId', 'eth_chainId',
'eth_coinbase', 'eth_coinbase',
'eth_decrypt',
'eth_estimateGas', 'eth_estimateGas',
'eth_gasPrice', 'eth_gasPrice',
'eth_getBalance', 'eth_getBalance',
@ -49,9 +45,11 @@ export const SAFE_METHODS = [
'eth_getBlockTransactionCountByHash', 'eth_getBlockTransactionCountByHash',
'eth_getBlockTransactionCountByNumber', 'eth_getBlockTransactionCountByNumber',
'eth_getCode', 'eth_getCode',
'eth_getEncryptionPublicKey',
'eth_getFilterChanges', 'eth_getFilterChanges',
'eth_getFilterLogs', 'eth_getFilterLogs',
'eth_getLogs', 'eth_getLogs',
'eth_getProof',
'eth_getStorageAt', 'eth_getStorageAt',
'eth_getTransactionByBlockHashAndIndex', 'eth_getTransactionByBlockHashAndIndex',
'eth_getTransactionByBlockNumberAndIndex', 'eth_getTransactionByBlockNumberAndIndex',
@ -72,8 +70,6 @@ export const SAFE_METHODS = [
'eth_sendRawTransaction', 'eth_sendRawTransaction',
'eth_sendTransaction', 'eth_sendTransaction',
'eth_sign', 'eth_sign',
'personal_sign',
'personal_ecRecover',
'eth_signTypedData', 'eth_signTypedData',
'eth_signTypedData_v1', 'eth_signTypedData_v1',
'eth_signTypedData_v3', 'eth_signTypedData_v3',
@ -83,7 +79,12 @@ export const SAFE_METHODS = [
'eth_syncing', 'eth_syncing',
'eth_uninstallFilter', 'eth_uninstallFilter',
'metamask_watchAsset', 'metamask_watchAsset',
'net_listening',
'net_peerCount',
'net_version',
'personal_ecRecover',
'personal_sign',
'wallet_watchAsset', 'wallet_watchAsset',
'eth_getEncryptionPublicKey', 'web3_clientVersion',
'eth_decrypt', 'web3_sha3',
] ]

@ -17,6 +17,7 @@ import {
import { import {
fetchTradesInfo as defaultFetchTradesInfo, fetchTradesInfo as defaultFetchTradesInfo,
fetchSwapsFeatureLiveness as defaultFetchSwapsFeatureLiveness, fetchSwapsFeatureLiveness as defaultFetchSwapsFeatureLiveness,
fetchSwapsQuoteRefreshTime as defaultFetchSwapsQuoteRefreshTime,
} from '../../../ui/app/pages/swaps/swaps.util' } from '../../../ui/app/pages/swaps/swaps.util'
const METASWAP_ADDRESS = '0x881d40237659c251811cec9c364ef91dc08d300c' const METASWAP_ADDRESS = '0x881d40237659c251811cec9c364ef91dc08d300c'
@ -28,6 +29,14 @@ const MAX_GAS_LIMIT = 2500000
// 3 seems to be an appropriate balance of giving users the time they need when MetaMask is not left idle, and turning polling off when it is. // 3 seems to be an appropriate balance of giving users the time they need when MetaMask is not left idle, and turning polling off when it is.
const POLL_COUNT_LIMIT = 3 const POLL_COUNT_LIMIT = 3
// If for any reason the MetaSwap API fails to provide a refresh time,
// provide a reasonable fallback to avoid further errors
const FALLBACK_QUOTE_REFRESH_TIME = 60000
// 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 = 10 * 1000
function calculateGasEstimateWithRefund( function calculateGasEstimateWithRefund(
maxGas = MAX_GAS_LIMIT, maxGas = MAX_GAS_LIMIT,
estimatedRefund = 0, estimatedRefund = 0,
@ -42,9 +51,6 @@ function calculateGasEstimateWithRefund(
return gasEstimateWithRefund return gasEstimateWithRefund
} }
// This is the amount of time to wait, after successfully fetching quotes and their gas estimates, before fetching for new quotes
const QUOTE_POLLING_INTERVAL = 50 * 1000
const initialState = { const initialState = {
swapsState: { swapsState: {
quotes: {}, quotes: {},
@ -61,6 +67,7 @@ const initialState = {
topAggId: null, topAggId: null,
routeState: '', routeState: '',
swapsFeatureIsLive: false, swapsFeatureIsLive: false,
swapsQuoteRefreshTime: FALLBACK_QUOTE_REFRESH_TIME,
}, },
} }
@ -73,6 +80,7 @@ export default class SwapsController {
tokenRatesStore, tokenRatesStore,
fetchTradesInfo = defaultFetchTradesInfo, fetchTradesInfo = defaultFetchTradesInfo,
fetchSwapsFeatureLiveness = defaultFetchSwapsFeatureLiveness, fetchSwapsFeatureLiveness = defaultFetchSwapsFeatureLiveness,
fetchSwapsQuoteRefreshTime = defaultFetchSwapsQuoteRefreshTime,
}) { }) {
this.store = new ObservableStore({ this.store = new ObservableStore({
swapsState: { ...initialState.swapsState }, swapsState: { ...initialState.swapsState },
@ -80,6 +88,7 @@ export default class SwapsController {
this._fetchTradesInfo = fetchTradesInfo this._fetchTradesInfo = fetchTradesInfo
this._fetchSwapsFeatureLiveness = fetchSwapsFeatureLiveness this._fetchSwapsFeatureLiveness = fetchSwapsFeatureLiveness
this._fetchSwapsQuoteRefreshTime = fetchSwapsQuoteRefreshTime
this.getBufferedGasLimit = getBufferedGasLimit this.getBufferedGasLimit = getBufferedGasLimit
this.tokenRatesStore = tokenRatesStore this.tokenRatesStore = tokenRatesStore
@ -101,11 +110,31 @@ export default class SwapsController {
this._setupSwapsLivenessFetching() this._setupSwapsLivenessFetching()
} }
// Sets the refresh rate for quote updates from the MetaSwap API
async _setSwapsQuoteRefreshTime() {
// Default to fallback time unless API returns valid response
let swapsQuoteRefreshTime = FALLBACK_QUOTE_REFRESH_TIME
try {
swapsQuoteRefreshTime = await this._fetchSwapsQuoteRefreshTime()
} catch (e) {
console.error('Request for swaps quote refresh time failed: ', e)
}
const { swapsState } = this.store.getState()
this.store.updateState({
swapsState: { ...swapsState, swapsQuoteRefreshTime },
})
}
// 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 that parameters 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 {
swapsState: { swapsQuoteRefreshTime },
} = this.store.getState()
this.pollingTimeout = setTimeout(() => { this.pollingTimeout = setTimeout(() => {
const { swapsState } = this.store.getState() const { swapsState } = this.store.getState()
this.fetchAndSetQuotes( this.fetchAndSetQuotes(
@ -113,7 +142,7 @@ export default class SwapsController {
swapsState.fetchParams?.metaData, swapsState.fetchParams?.metaData,
true, true,
) )
}, QUOTE_POLLING_INTERVAL) }, swapsQuoteRefreshTime - QUOTE_POLLING_DIFFERENCE_INTERVAL)
} }
stopPollingForQuotes() { stopPollingForQuotes() {
@ -128,7 +157,6 @@ export default class SwapsController {
if (!fetchParams) { if (!fetchParams) {
return null return null
} }
// Every time we get a new request that is not from the polling, we reset the poll count so we can poll for up to three more sets of quotes with these new params. // Every time we get a new request that is not from the polling, we reset the poll count so we can poll for up to three more sets of quotes with these new params.
if (!isPolledRequest) { if (!isPolledRequest) {
this.pollCount = 0 this.pollCount = 0
@ -144,7 +172,10 @@ export default class SwapsController {
const indexOfCurrentCall = this.indexOfNewestCallInFlight + 1 const indexOfCurrentCall = this.indexOfNewestCallInFlight + 1
this.indexOfNewestCallInFlight = indexOfCurrentCall this.indexOfNewestCallInFlight = indexOfCurrentCall
let newQuotes = await this._fetchTradesInfo(fetchParams) let [newQuotes] = await Promise.all([
this._fetchTradesInfo(fetchParams),
this._setSwapsQuoteRefreshTime(),
])
newQuotes = mapValues(newQuotes, (quote) => ({ newQuotes = mapValues(newQuotes, (quote) => ({
...quote, ...quote,
@ -422,6 +453,7 @@ export default class SwapsController {
tokens: swapsState.tokens, tokens: swapsState.tokens,
fetchParams: swapsState.fetchParams, fetchParams: swapsState.fetchParams,
swapsFeatureIsLive: swapsState.swapsFeatureIsLive, swapsFeatureIsLive: swapsState.swapsFeatureIsLive,
swapsQuoteRefreshTime: swapsState.swapsQuoteRefreshTime,
}, },
}) })
clearTimeout(this.pollingTimeout) clearTimeout(this.pollingTimeout)
@ -435,6 +467,7 @@ export default class SwapsController {
...initialState.swapsState, ...initialState.swapsState,
tokens: swapsState.tokens, tokens: swapsState.tokens,
swapsFeatureIsLive: swapsState.swapsFeatureIsLive, swapsFeatureIsLive: swapsState.swapsFeatureIsLive,
swapsQuoteRefreshTime: swapsState.swapsQuoteRefreshTime,
}, },
}) })
clearTimeout(this.pollingTimeout) clearTimeout(this.pollingTimeout)

@ -0,0 +1,9 @@
// Disable console.log in contentscript to prevent SES/lockdown logging to external page
// eslint-disable-next-line import/unambiguous
if (
!(typeof process !== 'undefined' && process.env.METAMASK_DEBUG) &&
typeof console !== undefined
) {
console.log = () => undefined
console.info = () => undefined
}

@ -1,4 +1,5 @@
// Freezes all intrinsics // Freezes all intrinsics
try {
// eslint-disable-next-line no-undef,import/unambiguous // eslint-disable-next-line no-undef,import/unambiguous
lockdown({ lockdown({
consoleTaming: 'unsafe', consoleTaming: 'unsafe',
@ -6,3 +7,13 @@ lockdown({
mathTaming: 'unsafe', mathTaming: 'unsafe',
dateTaming: 'unsafe', dateTaming: 'unsafe',
}) })
} catch (error) {
// If the `lockdown` call throws an exception, it interferes with the
// contentscript injection on some versions of Firefox. The error is
// caught and logged here so that the contentscript still gets injected.
// This affects Firefox v56 and Waterfox Classic
console.error('Lockdown failed:', error)
if (window.sentry && window.sentry.captureException) {
window.sentry.captureException(error)
}
}

@ -115,6 +115,7 @@ function createScriptTasks({ browserPlatforms, livereload }) {
}), }),
) )
}) })
// inpage must be built before contentscript // inpage must be built before contentscript
// because inpage bundle result is included inside contentscript // because inpage bundle result is included inside contentscript
const contentscriptSubtask = createTask( const contentscriptSubtask = createTask(
@ -122,6 +123,12 @@ function createScriptTasks({ browserPlatforms, livereload }) {
createTaskForBuildJsExtensionContentscript({ devMode, testing }), createTaskForBuildJsExtensionContentscript({ devMode, testing }),
) )
// this can run whenever
const disableConsoleSubtask = createTask(
`${taskPrefix}:disable-console`,
createTaskForBuildJsExtensionDisableConsole({ devMode }),
)
// task for initiating livereload // task for initiating livereload
const initiateLiveReload = async () => { const initiateLiveReload = async () => {
if (devMode) { if (devMode) {
@ -142,6 +149,7 @@ function createScriptTasks({ browserPlatforms, livereload }) {
const allSubtasks = [ const allSubtasks = [
...standardSubtasks, ...standardSubtasks,
contentscriptSubtask, contentscriptSubtask,
disableConsoleSubtask,
].map((subtask) => runInChildProcess(subtask)) ].map((subtask) => runInChildProcess(subtask))
// const allSubtasks = [...standardSubtasks, contentscriptSubtask].map(subtask => (subtask)) // const allSubtasks = [...standardSubtasks, contentscriptSubtask].map(subtask => (subtask))
// make a parent task that runs each task in a child thread // make a parent task that runs each task in a child thread
@ -165,6 +173,16 @@ function createScriptTasks({ browserPlatforms, livereload }) {
}) })
} }
function createTaskForBuildJsExtensionDisableConsole({ devMode }) {
const filename = 'disable-console'
return bundleTask({
label: filename,
filename: `${filename}.js`,
filepath: `./app/scripts/${filename}.js`,
devMode,
})
}
function createTaskForBuildJsExtensionContentscript({ devMode, testing }) { function createTaskForBuildJsExtensionContentscript({ devMode, testing }) {
const inpage = 'inpage' const inpage = 'inpage'
const contentscript = 'contentscript' const contentscript = 'contentscript'

@ -121,12 +121,14 @@ const EMPTY_INIT_STATE = {
topAggId: null, topAggId: null,
routeState: '', routeState: '',
swapsFeatureIsLive: false, swapsFeatureIsLive: false,
swapsQuoteRefreshTime: 60000,
}, },
} }
const sandbox = sinon.createSandbox() const sandbox = sinon.createSandbox()
const fetchTradesInfoStub = sandbox.stub() const fetchTradesInfoStub = sandbox.stub()
const fetchSwapsFeatureLivenessStub = sandbox.stub() const fetchSwapsFeatureLivenessStub = sandbox.stub()
const fetchSwapsQuoteRefreshTimeStub = sandbox.stub()
describe('SwapsController', function () { describe('SwapsController', function () {
let provider let provider
@ -140,6 +142,7 @@ describe('SwapsController', function () {
tokenRatesStore: MOCK_TOKEN_RATES_STORE, tokenRatesStore: MOCK_TOKEN_RATES_STORE,
fetchTradesInfo: fetchTradesInfoStub, fetchTradesInfo: fetchTradesInfoStub,
fetchSwapsFeatureLiveness: fetchSwapsFeatureLivenessStub, fetchSwapsFeatureLiveness: fetchSwapsFeatureLivenessStub,
fetchSwapsQuoteRefreshTime: fetchSwapsQuoteRefreshTimeStub,
}) })
} }
@ -639,9 +642,9 @@ describe('SwapsController', function () {
const quotes = await swapsController.fetchAndSetQuotes(undefined) const quotes = await swapsController.fetchAndSetQuotes(undefined)
assert.strictEqual(quotes, null) assert.strictEqual(quotes, null)
}) })
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
@ -682,9 +685,9 @@ describe('SwapsController', function () {
true, true,
) )
}) })
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
@ -707,6 +710,7 @@ 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
@ -732,6 +736,7 @@ 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
@ -762,6 +767,7 @@ 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
@ -779,6 +785,7 @@ 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
@ -805,6 +812,7 @@ describe('SwapsController', function () {
assert.deepStrictEqual(swapsState, { assert.deepStrictEqual(swapsState, {
...EMPTY_INIT_STATE.swapsState, ...EMPTY_INIT_STATE.swapsState,
tokens: old.tokens, tokens: old.tokens,
swapsQuoteRefreshTime: old.swapsQuoteRefreshTime,
}) })
}) })
@ -850,8 +858,14 @@ describe('SwapsController', function () {
const tokens = 'test' const tokens = 'test'
const fetchParams = 'test' const fetchParams = 'test'
const swapsFeatureIsLive = false const swapsFeatureIsLive = false
const swapsQuoteRefreshTime = 0
swapsController.store.updateState({ swapsController.store.updateState({
swapsState: { tokens, fetchParams, swapsFeatureIsLive }, swapsState: {
tokens,
fetchParams,
swapsFeatureIsLive,
swapsQuoteRefreshTime,
},
}) })
swapsController.resetPostFetchState() swapsController.resetPostFetchState()
@ -862,6 +876,7 @@ describe('SwapsController', function () {
tokens, tokens,
fetchParams, fetchParams,
swapsFeatureIsLive, swapsFeatureIsLive,
swapsQuoteRefreshTime,
}) })
}) })
}) })
@ -1615,3 +1630,7 @@ function getTopQuoteAndSavingsBaseExpectedResults() {
}, },
} }
} }
function getMockQuoteRefreshTime() {
return 45000
}

@ -29,7 +29,7 @@ export default class SignatureRequestMessage extends PureComponent {
this.renderNode(value) this.renderNode(value)
) : ( ) : (
<span className="signature-request-message--node-value"> <span className="signature-request-message--node-value">
{value} {`${value}`}
</span> </span>
)} )}
</div> </div>

@ -39,7 +39,6 @@ import { calcGasTotal } from '../../pages/send/send.utils'
import { import {
decimalToHex, decimalToHex,
getValueFromWeiHex, getValueFromWeiHex,
hexMax,
decGWEIToHexWEI, decGWEIToHexWEI,
hexToDecimal, hexToDecimal,
hexWEIToDecGWEI, hexWEIToDecGWEI,
@ -67,6 +66,8 @@ const GAS_PRICES_LOADING_STATES = {
COMPLETED: 'COMPLETED', COMPLETED: 'COMPLETED',
} }
export const FALLBACK_GAS_MULTIPLIER = 1.5
const initialState = { const initialState = {
aggregatorMetadata: null, aggregatorMetadata: null,
approveTxId: null, approveTxId: null,
@ -223,6 +224,9 @@ const getSwapsState = (state) => state.metamask.swapsState
export const getSwapsFeatureLiveness = (state) => export const getSwapsFeatureLiveness = (state) =>
state.metamask.swapsState.swapsFeatureIsLive state.metamask.swapsState.swapsFeatureIsLive
export const getSwapsQuoteRefreshTime = (state) =>
state.metamask.swapsState.swapsQuoteRefreshTime
export const getBackgroundSwapRouteState = (state) => export const getBackgroundSwapRouteState = (state) =>
state.metamask.swapsState.routeState state.metamask.swapsState.routeState
@ -593,20 +597,16 @@ export const signAndSendTransactions = (history, metaMetricsEvent) => {
const usedQuote = getUsedQuote(state) const usedQuote = getUsedQuote(state)
const usedTradeTxParams = usedQuote.trade const usedTradeTxParams = usedQuote.trade
const estimatedGasLimit = new BigNumber( const estimatedGasLimit = new BigNumber(usedQuote?.gasEstimate || `0x0`, 16)
usedQuote?.gasEstimate || decimalToHex(usedQuote?.averageGas || 0),
16,
)
const estimatedGasLimitWithMultiplier = estimatedGasLimit const estimatedGasLimitWithMultiplier = estimatedGasLimit
.times(1.4, 10) .times(usedQuote?.gasMultiplier || FALLBACK_GAS_MULTIPLIER, 10)
.round(0) .round(0)
.toString(16) .toString(16)
const maxGasLimit = const maxGasLimit =
customSwapsGas || customSwapsGas ||
hexMax( (usedQuote?.gasEstimate
`0x${decimalToHex(usedQuote?.maxGas || 0)}`, ? estimatedGasLimitWithMultiplier
estimatedGasLimitWithMultiplier, : usedQuote?.maxGas)
)
const usedGasPrice = getUsedSwapsGasPrice(state) const usedGasPrice = getUsedSwapsGasPrice(state)
usedTradeTxParams.gas = maxGasLimit usedTradeTxParams.gas = maxGasLimit

@ -1,4 +1,3 @@
import BigNumber from 'bignumber.js'
import { ETH, GWEI, WEI } from '../constants/common' import { ETH, GWEI, WEI } from '../constants/common'
import { addHexPrefix } from '../../../../app/scripts/lib/util' import { addHexPrefix } from '../../../../app/scripts/lib/util'
import { import {
@ -163,16 +162,6 @@ export function hexWEIToDecETH(hexWEI) {
}) })
} }
export function hexMax(...hexNumbers) {
let max = hexNumbers[0]
hexNumbers.slice(1).forEach((hexNumber) => {
if (new BigNumber(hexNumber, 16).gt(max, 16)) {
max = hexNumber
}
})
return max
}
export function addHexes(aHexWEI, bHexWEI) { export function addHexes(aHexWEI, bHexWEI) {
return addCurrencies(aHexWEI, bHexWEI, { return addCurrencies(aHexWEI, bHexWEI, {
aBase: 16, aBase: 16,

@ -16,7 +16,6 @@ const fetchWithCache = async (
fetchOptions.headers = new window.Headers(fetchOptions.headers) fetchOptions.headers = new window.Headers(fetchOptions.headers)
} }
if ( if (
fetchOptions.headers &&
fetchOptions.headers.has('Content-Type') && fetchOptions.headers.has('Content-Type') &&
fetchOptions.headers.get('Content-Type') !== 'application/json' fetchOptions.headers.get('Content-Type') !== 'application/json'
) { ) {
@ -24,8 +23,8 @@ const fetchWithCache = async (
} }
const currentTime = Date.now() const currentTime = Date.now()
const cachedFetch = (await getStorageItem('cachedFetch')) || {} const cacheKey = `cachedFetch:${url}`
const { cachedResponse, cachedTime } = cachedFetch[url] || {} const { cachedResponse, cachedTime } = (await getStorageItem(cacheKey)) || {}
if (cachedResponse && currentTime - cachedTime < cacheRefreshTime) { if (cachedResponse && currentTime - cachedTime < cacheRefreshTime) {
return cachedResponse return cachedResponse
} }
@ -48,8 +47,8 @@ const fetchWithCache = async (
cachedResponse: responseJson, cachedResponse: responseJson,
cachedTime: currentTime, cachedTime: currentTime,
} }
cachedFetch[url] = cacheEntry
await setStorageItem('cachedFetch', cachedFetch) await setStorageItem(cacheKey, cacheEntry)
return responseJson return responseJson
} }

@ -37,10 +37,8 @@ describe('Fetch with cache', function () {
.reply(200, '{"average": 2}') .reply(200, '{"average": 2}')
fakeStorage.getStorageItem.returns({ fakeStorage.getStorageItem.returns({
'https://fetchwithcache.metamask.io/price': {
cachedResponse: { average: 1 }, cachedResponse: { average: 1 },
cachedTime: Date.now(), cachedTime: Date.now(),
},
}) })
const response = await fetchWithCache( const response = await fetchWithCache(
@ -57,10 +55,8 @@ describe('Fetch with cache', function () {
.reply(200, '{"average": 3}') .reply(200, '{"average": 3}')
fakeStorage.getStorageItem.returns({ fakeStorage.getStorageItem.returns({
'https://fetchwithcache.metamask.io/cached': {
cachedResponse: { average: 1 }, cachedResponse: { average: 1 },
cachedTime: Date.now() - 1000, cachedTime: Date.now() - 1000,
},
}) })
const response = await fetchWithCache( const response = await fetchWithCache(
@ -135,4 +131,43 @@ describe('Fetch with cache', function () {
{ message: 'fetchWithCache only supports JSON responses' }, { message: 'fetchWithCache only supports JSON responses' },
) )
}) })
it('should correctly cache responses from interwoven requests', async function () {
nock('https://fetchwithcache.metamask.io')
.get('/foo')
.reply(200, '{"average": 9}')
nock('https://fetchwithcache.metamask.io')
.get('/bar')
.reply(200, '{"average": 9}')
const testCache = {}
fakeStorage.getStorageItem.callsFake((key) => testCache[key])
fakeStorage.setStorageItem.callsFake((key, value) => {
testCache[key] = value
})
await Promise.all([
fetchWithCache(
'https://fetchwithcache.metamask.io/foo',
{},
{ cacheRefreshTime: 123 },
),
fetchWithCache(
'https://fetchwithcache.metamask.io/bar',
{},
{ cacheRefreshTime: 123 },
),
])
assert.deepStrictEqual(
testCache['cachedFetch:https://fetchwithcache.metamask.io/foo']
.cachedResponse,
{ average: 9 },
)
assert.deepStrictEqual(
testCache['cachedFetch:https://fetchwithcache.metamask.io/bar']
.cachedResponse,
{ average: 9 },
)
})
}) })

@ -105,7 +105,10 @@ export default class MetaMetricsOptIn extends Component {
await setParticipateInMetaMetrics(false) await setParticipateInMetaMetrics(false)
try { try {
if (participateInMetaMetrics === true) { if (
participateInMetaMetrics === null ||
participateInMetaMetrics === true
) {
await metricsEvent({ await metricsEvent({
eventOpts: { eventOpts: {
category: 'Onboarding', category: 'Onboarding',
@ -128,7 +131,10 @@ export default class MetaMetricsOptIn extends Component {
) )
try { try {
const metrics = [] const metrics = []
if (participateInMetaMetrics === false) { if (
participateInMetaMetrics === null ||
participateInMetaMetrics === false
) {
metrics.push( metrics.push(
metricsEvent({ metricsEvent({
eventOpts: { eventOpts: {

@ -156,7 +156,7 @@ export default class SendTransactionScreen extends Component {
if (sendTokenAddress && prevTokenAddress !== sendTokenAddress) { if (sendTokenAddress && prevTokenAddress !== sendTokenAddress) {
this.updateSendToken() this.updateSendToken()
this.validate(sendTokenAddress) this.validate(this.state.query)
updateGas = true updateGas = true
} }

@ -1,11 +1,11 @@
import React, { useState, useEffect, useContext, useRef } from 'react' import React, { useState, useEffect, useContext, useRef } from 'react'
import { useSelector } from 'react-redux'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import classnames from 'classnames' import classnames from 'classnames'
import { Duration } from 'luxon' import { Duration } from 'luxon'
import { I18nContext } from '../../../contexts/i18n' import { I18nContext } from '../../../contexts/i18n'
import InfoTooltip from '../../../components/ui/info-tooltip' import InfoTooltip from '../../../components/ui/info-tooltip'
import { getSwapsQuoteRefreshTime } from '../../../ducks/swaps/swaps'
const TIMER_BASE = 60000
// Return the mm:ss start time of the countdown timer. // Return the mm:ss start time of the countdown timer.
// If time has elapsed between `timeStarted` the time current time, // If time has elapsed between `timeStarted` the time current time,
@ -31,7 +31,7 @@ function timeBelowWarningTime(timer, warningTime) {
export default function CountdownTimer({ export default function CountdownTimer({
timeStarted, timeStarted,
timeOnly, timeOnly,
timerBase = TIMER_BASE, timerBase,
warningTime, warningTime,
labelKey, labelKey,
infoTooltipLabelKey, infoTooltipLabelKey,
@ -40,9 +40,12 @@ export default function CountdownTimer({
const intervalRef = useRef() const intervalRef = useRef()
const initialTimeStartedRef = useRef() const initialTimeStartedRef = useRef()
const swapsQuoteRefreshTime = useSelector(getSwapsQuoteRefreshTime)
const timerStart = Number(timerBase) || swapsQuoteRefreshTime
const [currentTime, setCurrentTime] = useState(() => Date.now()) const [currentTime, setCurrentTime] = useState(() => Date.now())
const [timer, setTimer] = useState(() => const [timer, setTimer] = useState(() =>
getNewTimer(currentTime, timeStarted, timerBase), getNewTimer(currentTime, timeStarted, timerStart),
) )
useEffect(() => { useEffect(() => {
@ -67,14 +70,14 @@ export default function CountdownTimer({
initialTimeStartedRef.current = timeStarted initialTimeStartedRef.current = timeStarted
const newCurrentTime = Date.now() const newCurrentTime = Date.now()
setCurrentTime(newCurrentTime) setCurrentTime(newCurrentTime)
setTimer(getNewTimer(newCurrentTime, timeStarted, timerBase)) setTimer(getNewTimer(newCurrentTime, timeStarted, timerStart))
clearInterval(intervalRef.current) clearInterval(intervalRef.current)
intervalRef.current = setInterval(() => { intervalRef.current = setInterval(() => {
setTimer(decreaseTimerByOne) setTimer(decreaseTimerByOne)
}, 1000) }, 1000)
} }
}, [timeStarted, timer, timerBase]) }, [timeStarted, timer, timerStart])
const formattedTimer = Duration.fromMillis(timer).toFormat('m:ss') const formattedTimer = Duration.fromMillis(timer).toFormat('m:ss')
let time let time

@ -24,20 +24,24 @@ const TOKEN_TRANSFER_LOG_TOPIC_HASH =
const CACHE_REFRESH_ONE_HOUR = 3600000 const CACHE_REFRESH_ONE_HOUR = 3600000
const METASWAP_API_HOST = 'https://api.metaswap.codefi.network'
const getBaseApi = function (type) { const getBaseApi = function (type) {
switch (type) { switch (type) {
case 'trade': case 'trade':
return `https://api.metaswap.codefi.network/trades?` return `${METASWAP_API_HOST}/trades?`
case 'tokens': case 'tokens':
return `https://api.metaswap.codefi.network/tokens` return `${METASWAP_API_HOST}/tokens`
case 'topAssets': case 'topAssets':
return `https://api.metaswap.codefi.network/topAssets` return `${METASWAP_API_HOST}/topAssets`
case 'featureFlag': case 'featureFlag':
return `https://api.metaswap.codefi.network/featureFlag` return `${METASWAP_API_HOST}/featureFlag`
case 'aggregatorMetadata': case 'aggregatorMetadata':
return `https://api.metaswap.codefi.network/aggregatorMetadata` return `${METASWAP_API_HOST}/aggregatorMetadata`
case 'gasPrices': case 'gasPrices':
return `https://api.metaswap.codefi.network/gasPrices` return `${METASWAP_API_HOST}/gasPrices`
case 'refreshTime':
return `${METASWAP_API_HOST}/quoteRefreshRate`
default: default:
throw new Error('getBaseApi requires an api call type') throw new Error('getBaseApi requires an api call type')
} }
@ -112,6 +116,11 @@ const QUOTE_VALIDATORS = [
property: 'maxGas', property: 'maxGas',
type: 'number', type: 'number',
}, },
{
property: 'gasEstimate',
type: 'number|undefined',
validator: (gasEstimate) => gasEstimate === undefined || gasEstimate > 0,
},
] ]
const TOKEN_VALIDATORS = [ const TOKEN_VALIDATORS = [
@ -323,6 +332,23 @@ export async function fetchSwapsFeatureLiveness() {
return status?.active return status?.active
} }
export async function fetchSwapsQuoteRefreshTime() {
const response = await fetchWithCache(
getBaseApi('refreshTime'),
{ 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`

@ -12,6 +12,7 @@ import { useSwapsEthToken } from '../../../hooks/useSwapsEthToken'
import { MetaMetricsContext } from '../../../contexts/metametrics.new' import { MetaMetricsContext } from '../../../contexts/metametrics.new'
import FeeCard from '../fee-card' import FeeCard from '../fee-card'
import { import {
FALLBACK_GAS_MULTIPLIER,
getQuotes, getQuotes,
getSelectedQuote, getSelectedQuote,
getApproveTxParams, getApproveTxParams,
@ -27,6 +28,7 @@ import {
signAndSendTransactions, signAndSendTransactions,
getBackgroundSwapRouteState, getBackgroundSwapRouteState,
swapsQuoteSelected, swapsQuoteSelected,
getSwapsQuoteRefreshTime,
} from '../../../ducks/swaps/swaps' } from '../../../ducks/swaps/swaps'
import { import {
conversionRateSelector, conversionRateSelector,
@ -57,7 +59,6 @@ import {
} from '../../../helpers/utils/token-util' } from '../../../helpers/utils/token-util'
import { import {
decimalToHex, decimalToHex,
hexMax,
hexToDecimal, hexToDecimal,
getValueFromWeiHex, getValueFromWeiHex,
} from '../../../helpers/utils/conversions.util' } from '../../../helpers/utils/conversions.util'
@ -115,6 +116,7 @@ export default function ViewQuote() {
const topQuote = useSelector(getTopQuote) const topQuote = useSelector(getTopQuote)
const usedQuote = selectedQuote || topQuote const usedQuote = selectedQuote || topQuote
const tradeValue = usedQuote?.trade?.value ?? '0x0' const tradeValue = usedQuote?.trade?.value ?? '0x0'
const swapsQuoteRefreshTime = useSelector(getSwapsQuoteRefreshTime)
const { isBestQuote } = usedQuote const { isBestQuote } = usedQuote
const fetchParamsSourceToken = fetchParams?.sourceToken const fetchParamsSourceToken = fetchParams?.sourceToken
@ -123,18 +125,16 @@ export default function ViewQuote() {
usedQuote?.gasEstimateWithRefund || usedQuote?.gasEstimateWithRefund ||
`0x${decimalToHex(usedQuote?.averageGas || 0)}` `0x${decimalToHex(usedQuote?.averageGas || 0)}`
const gasLimitForMax = const gasLimitForMax = usedQuote?.gasEstimate || `0x0`
usedQuote?.gasEstimate || `0x${decimalToHex(usedQuote?.averageGas || 0)}`
const usedGasLimitWithMultiplier = new BigNumber(gasLimitForMax, 16) const usedGasLimitWithMultiplier = new BigNumber(gasLimitForMax, 16)
.times(1.4, 10) .times(usedQuote?.gasMultiplier || FALLBACK_GAS_MULTIPLIER, 10)
.round(0) .round(0)
.toString(16) .toString(16)
const nonCustomMaxGasLimit = hexMax( const nonCustomMaxGasLimit = usedQuote?.gasEstimate
`0x${decimalToHex(usedQuote?.maxGas || 0)}`, ? usedGasLimitWithMultiplier
usedGasLimitWithMultiplier, : `0x${decimalToHex(usedQuote?.maxGas || 0)}`
)
const maxGasLimit = customMaxGas || nonCustomMaxGasLimit const maxGasLimit = customMaxGas || nonCustomMaxGasLimit
const gasTotalInWeiHex = calcGasTotal(maxGasLimit, gasPrice) const gasTotalInWeiHex = calcGasTotal(maxGasLimit, gasPrice)
@ -265,14 +265,23 @@ export default function ViewQuote() {
useEffect(() => { useEffect(() => {
const currentTime = Date.now() const currentTime = Date.now()
const timeSinceLastFetched = currentTime - quotesLastFetched const timeSinceLastFetched = currentTime - quotesLastFetched
if (timeSinceLastFetched > 60000 && !dispatchedSafeRefetch) { if (
timeSinceLastFetched > swapsQuoteRefreshTime &&
!dispatchedSafeRefetch
) {
setDispatchedSafeRefetch(true) setDispatchedSafeRefetch(true)
dispatch(safeRefetchQuotes()) dispatch(safeRefetchQuotes())
} else if (timeSinceLastFetched > 60000) { } else if (timeSinceLastFetched > swapsQuoteRefreshTime) {
dispatch(setSwapsErrorKey(QUOTES_EXPIRED_ERROR)) dispatch(setSwapsErrorKey(QUOTES_EXPIRED_ERROR))
history.push(SWAPS_ERROR_ROUTE) history.push(SWAPS_ERROR_ROUTE)
} }
}, [quotesLastFetched, dispatchedSafeRefetch, dispatch, history]) }, [
quotesLastFetched,
dispatchedSafeRefetch,
dispatch,
history,
swapsQuoteRefreshTime,
])
useEffect(() => { useEffect(() => {
if (!originalApproveAmount && approveAmount) { if (!originalApproveAmount && approveAmount) {

@ -24922,9 +24922,9 @@ tunnel@^0.0.6:
integrity sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg== integrity sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==
tweetnacl-util@^0.15.0: tweetnacl-util@^0.15.0:
version "0.15.0" version "0.15.1"
resolved "https://registry.yarnpkg.com/tweetnacl-util/-/tweetnacl-util-0.15.0.tgz#4576c1cee5e2d63d207fee52f1ba02819480bc75" resolved "https://registry.yarnpkg.com/tweetnacl-util/-/tweetnacl-util-0.15.1.tgz#b80fcdb5c97bcc508be18c44a4be50f022eea00b"
integrity sha1-RXbBzuXi1j0gf+5S8boCgZSAvHU= integrity sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw==
tweetnacl@^0.14.3, tweetnacl@~0.14.0: tweetnacl@^0.14.3, tweetnacl@~0.14.0:
version "0.14.5" version "0.14.5"
@ -24932,9 +24932,9 @@ tweetnacl@^0.14.3, tweetnacl@~0.14.0:
integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q= integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=
tweetnacl@^1.0.0, tweetnacl@^1.0.1: tweetnacl@^1.0.0, tweetnacl@^1.0.1:
version "1.0.1" version "1.0.3"
resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-1.0.1.tgz#2594d42da73cd036bd0d2a54683dd35a6b55ca17" resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-1.0.3.tgz#ac0af71680458d8a6378d0d0d050ab1407d35596"
integrity sha512-kcoMoKTPYnoeS50tzoqjPY3Uv9axeuuFAZY9M/9zFnhoVvRfxz9K29IMPD7jGmt2c8SW7i3gT9WqDl2+nV7p4A== integrity sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==
type-check@^0.4.0, type-check@~0.4.0: type-check@^0.4.0, type-check@~0.4.0:
version "0.4.0" version "0.4.0"

Loading…
Cancel
Save