1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-12-23 09:52:26 +01:00

Merge pull request #9720 from MetaMask/Version-v8.1.3

Version v8.1.3 RC
This commit is contained in:
Mark Stacey 2020-10-29 00:04:35 -02:30 committed by GitHub
commit 11781e8cc0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
86 changed files with 2383 additions and 1911 deletions

View File

@ -122,6 +122,8 @@ jobs:
prep-build:
docker:
- image: circleci/node@sha256:e16740707de2ebed45c05d507f33ef204902349c7356d720610b5ec6a35d3d88
environment:
NODE_OPTIONS: --max_old_space_size=1024
steps:
- checkout
- attach_workspace:
@ -141,6 +143,8 @@ jobs:
prep-build-test:
docker:
- image: circleci/node@sha256:e16740707de2ebed45c05d507f33ef204902349c7356d720610b5ec6a35d3d88
environment:
NODE_OPTIONS: --max_old_space_size=1024
steps:
- checkout
- attach_workspace:

View File

@ -45,6 +45,7 @@ install_github_cli
printf '%s\n' "Creating a Pull Request for $version on GitHub"
if ! hub pull-request \
--draft \
--message "${CIRCLE_BRANCH/-/ } RC" --message ':package: :rocket:' \
--base "$CIRCLE_PROJECT_USERNAME:$base_branch" \
--head "$CIRCLE_PROJECT_USERNAME:$CIRCLE_BRANCH";

View File

@ -1,6 +1,6 @@
module.exports = {
root: true,
parser: 'babel-eslint',
parser: '@babel/eslint-parser',
parserOptions: {
'sourceType': 'module',
'ecmaVersion': 2017,
@ -39,7 +39,7 @@ module.exports = {
],
plugins: [
'babel',
'@babel',
'react',
'import',
],
@ -82,10 +82,16 @@ module.exports = {
}],
'no-invalid-this': 'off',
'babel/no-invalid-this': 'error',
'@babel/no-invalid-this': 'error',
'babel/semi': ['error', 'never'],
'@babel/semi': ['error', 'never'],
'mocha/no-setup-in-describe': 'off',
'node/no-process-env': 'off',
// TODO: re-enable these rules
'node/no-sync': 'off',
'node/no-unpublished-import': 'off',
'node/no-unpublished-require': 'off',
},
overrides: [{
@ -108,7 +114,7 @@ module.exports = {
'app/scripts/migrations/*.js',
],
rules: {
'global-require': 'off',
'node/global-require': 'off',
},
}, {
files: [
@ -117,7 +123,7 @@ module.exports = {
],
rules: {
// Mocha will re-assign `this` in a test context
'babel/no-invalid-this': 'off',
'@babel/no-invalid-this': 'off',
},
}, {
files: [
@ -126,7 +132,8 @@ module.exports = {
'test/helper.js',
],
rules: {
'no-process-exit': 'off',
'node/no-process-exit': 'off',
'node/shebang': 'off',
},
}, {
files: [

View File

@ -1,15 +0,0 @@
<!--
BEFORE SUBMITTING, please make sure your question hasn't been answered in our support center: https://support.metamask.io
Common questions such as "Where is my ether?" or "Where did my tokens go?" are answered there.
Bug Reports:
Briefly describe the issue you've encountered
* Expected Behavior
* Actual Behavior
* Browser Used
* Operating System Used
Screenshots are very helpful and will expedite your issue being resolved!
-->

8
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@ -0,0 +1,8 @@
Fixes: #
Explanation:
Manual testing steps:
-
-
-

13
.github/dependabot.yml vendored Normal file
View File

@ -0,0 +1,13 @@
# Please see the documentation for all configuration options:
# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "daily"
allow:
- dependency-name: "@metamask/*"
- dependency-name: "eth-contract-metadata"
versioning-strategy: "lockfile-only"

View File

@ -1,6 +1,25 @@
# Changelog
## Current Develop Branch
- [#9612](https://github.com/MetaMask/metamask-extension/pull/9612): Update main-quote-summary designs/styles
## 8.1.3 Mon Oct 26 2020
- [#9642](https://github.com/MetaMask/metamask-extension/pull/9642) Prevent excessive overflow from swap dropdowns
- [#9658](https://github.com/MetaMask/metamask-extension/pull/9658): Fix sorting Quote Source column of quote sort list
- [#9667](https://github.com/MetaMask/metamask-extension/pull/9667): Fix adding contact with QR code
- [#9674](https://github.com/MetaMask/metamask-extension/pull/9674): Fix ENS resolution of `.eth` URLs with query strings
- [#9691](https://github.com/MetaMask/metamask-extension/pull/9691): Bump @metamask/inpage-provider from 6.1.0 to 6.3.0
- [#9700](https://github.com/MetaMask/metamask-extension/pull/9700): Provide image sizing so there's no jump when opening the swaps token search
- [#9568](https://github.com/MetaMask/metamask-extension/pull/9568): Add ses lockdown to build system
- [#9705](https://github.com/MetaMask/metamask-extension/pull/9705): Prevent memory leak from selected account copy tooltip
- [#9671](https://github.com/MetaMask/metamask-extension/pull/9671): Prevent old fetches from polluting the swap state
- [#9702](https://github.com/MetaMask/metamask-extension/pull/9702): Keyboard navigation for swaps dropdowns
- [#9646](https://github.com/MetaMask/metamask-extension/pull/9646): Switch from Matomo to Segment
- [#9745](https://github.com/MetaMask/metamask-extension/pull/9745): Fix fetching swaps when initial network not Mainnet
- [#9621](https://github.com/MetaMask/metamask-extension/pull/9621): Include aggregator fee as part of displayed network fees
- [#9736](https://github.com/MetaMask/metamask-extension/pull/9736): Bump eth-contract-metadata from 1.16.0 to 1.17.0
- [#9743](https://github.com/MetaMask/metamask-extension/pull/9743): Fix "+-" prefix on swap token amount
- [#9715](https://github.com/MetaMask/metamask-extension/pull/9715): Focus on wallet address in buy workflow
## 8.1.2 Mon Oct 19 2020
- [#9608](https://github.com/MetaMask/metamask-extension/pull/9608): Ensure QR code scanner works

View File

@ -82,6 +82,9 @@
"affirmAgree": {
"message": "I Agree"
},
"aggregatorFeeCost": {
"message": "Aggregator network fee"
},
"alertDisableTooltip": {
"message": "This can be changed in \"Settings > Alerts\""
},
@ -129,6 +132,9 @@
"message": "MetaMask",
"description": "The name of the application"
},
"approvalAndAggregatorTxFeeCost": {
"message": "Approval and aggregator network fee"
},
"approvalTxGasCost": {
"message": "Approval Tx Gas Cost"
},
@ -1685,9 +1691,6 @@
"swapFinalizing": {
"message": "Finalizing..."
},
"swapGasFeeSummary": {
"message": "The gas fee covers the cost of processing your swap and storing it on the Ethereum network. MetaMask does not profit from this fee."
},
"swapGetQuotes": {
"message": "Get quotes"
},
@ -1736,6 +1739,9 @@
"message": "$1 quotes available",
"description": "$1 is the number of quotes that the user can select from when opening the list of quotes on the 'view quote' screen"
},
"swapNetworkFeeSummary": {
"message": "The network fee covers the cost of processing your swap and storing it on the Ethereum network. MetaMask does not profit from this fee."
},
"swapNewQuoteIn": {
"message": "New quotes in $1",
"description": "Tells the user the amount of time until the currently displayed quotes are update. $1 is a time that is counting down from 1:00 to 0:00"

View File

@ -68,6 +68,6 @@
"notifications"
],
"short_name": "__MSG_appName__",
"version": "8.1.2",
"version": "8.1.3",
"web_accessible_resources": ["inpage.js", "phishing.html"]
}

View File

@ -86,6 +86,7 @@ export default class DetectTokensController {
this.interval = DEFAULT_INTERVAL
}
/* eslint-disable accessor-pairs */
/**
* @type {Number}
*/
@ -162,4 +163,5 @@ export default class DetectTokensController {
get isActive () {
return this.isOpen && this.isUnlocked
}
/* eslint-enable accessor-pairs */
}

View File

@ -1,4 +1,4 @@
import punycode from 'punycode'
import punycode from 'punycode/punycode'
import ethUtil from 'ethereumjs-util'
import ObservableStore from 'obs-store'
import log from 'loglevel'

View File

@ -111,7 +111,7 @@ export default function createPermissionsMethodMiddleware ({
}
// when this promise resolves, the response is on its way back
// eslint-disable-next-line callback-return
// eslint-disable-next-line node/callback-return
await next()
if (responseHandler) {

View File

@ -12,6 +12,7 @@ import {
DEFAULT_ERC20_APPROVE_GAS,
QUOTES_EXPIRED_ERROR,
QUOTES_NOT_AVAILABLE_ERROR,
SWAPS_FETCH_ORDER_CONFLICT,
} from '../../../ui/app/helpers/constants/swaps'
import {
fetchTradesInfo as defaultFetchTradesInfo,
@ -69,6 +70,7 @@ const initialState = {
export default class SwapsController {
constructor ({
getBufferedGasLimit,
networkController,
provider,
getProviderConfig,
tokenRatesStore,
@ -88,7 +90,16 @@ export default class SwapsController {
this.pollCount = 0
this.getProviderConfig = getProviderConfig
this.indexOfNewestCallInFlight = 0
this.ethersProvider = new ethers.providers.Web3Provider(provider)
this._currentNetwork = networkController.store.getState().network
networkController.on('networkDidChange', (network) => {
if (network !== 'loading' && network !== this._currentNetwork) {
this._currentNetwork = network
this.ethersProvider = new ethers.providers.Web3Provider(provider)
}
})
this._setupSwapsLivenessFetching()
}
@ -124,6 +135,10 @@ export default class SwapsController {
if (!isPolledRequest) {
this.setSwapsErrorKey('')
}
const indexOfCurrentCall = this.indexOfNewestCallInFlight + 1
this.indexOfNewestCallInFlight = indexOfCurrentCall
let newQuotes = await this._fetchTradesInfo(fetchParams)
newQuotes = mapValues(newQuotes, (quote) => ({
@ -175,19 +190,27 @@ export default class SwapsController {
if (Object.values(newQuotes).length === 0) {
this.setSwapsErrorKey(QUOTES_NOT_AVAILABLE_ERROR)
} else {
const topAggData = await this._findTopQuoteAggId(newQuotes)
const topQuoteData = await this._findTopQuoteAndCalculateSavings(newQuotes)
if (topAggData.topAggId) {
topAggId = topAggData.topAggId
newQuotes[topAggId].isBestQuote = topAggData.isBest
if (topQuoteData.topAggId) {
topAggId = topQuoteData.topAggId
newQuotes[topAggId].isBestQuote = topQuoteData.isBest
newQuotes[topAggId].savings = topQuoteData.savings
}
}
// If a newer call has been made, don't update state with old information
// Prevents timing conflicts between fetches
if (this.indexOfNewestCallInFlight !== indexOfCurrentCall) {
throw new Error(SWAPS_FETCH_ORDER_CONFLICT)
}
const { swapsState } = this.store.getState()
let { selectedAggId } = swapsState
if (!newQuotes[selectedAggId]) {
selectedAggId = null
}
this.store.updateState({
swapsState: {
...swapsState,
@ -394,48 +417,61 @@ export default class SwapsController {
return ethersGasPrice.toHexString()
}
async _findTopQuoteAggId (quotes) {
async _findTopQuoteAndCalculateSavings (quotes = {}) {
const tokenConversionRates = this.tokenRatesStore.getState()
.contractExchangeRates
const {
swapsState: { customGasPrice },
} = this.store.getState()
if (!Object.values(quotes).length) {
const numQuotes = Object.keys(quotes).length
if (!numQuotes) {
return {}
}
const usedGasPrice = customGasPrice || await this._getEthersGasPrice()
let topAggId = ''
let ethValueOfTradeForBestQuote = null
let ethTradeValueOfBestQuote = null
let ethFeeForBestQuote = null
const allEthTradeValues = []
const allEthFees = []
Object.values(quotes).forEach((quote) => {
const {
aggregator,
approvalNeeded,
averageGas,
destinationAmount = 0,
destinationToken,
destinationTokenInfo,
trade,
approvalNeeded,
averageGas,
gasEstimate,
aggregator,
sourceAmount,
sourceToken,
trade,
} = quote
const tradeGasLimitForCalculation = gasEstimate
? new BigNumber(gasEstimate, 16)
: new BigNumber(averageGas || MAX_GAS_LIMIT, 10)
const totalGasLimitForCalculation = tradeGasLimitForCalculation
.plus(approvalNeeded?.gas || '0x0', 16)
.toString(16)
const gasTotalInWeiHex = calcGasTotal(
totalGasLimitForCalculation,
usedGasPrice,
)
const totalEthCost = new BigNumber(gasTotalInWeiHex, 16).plus(
trade.value,
16,
)
const ethFee = conversionUtil(totalEthCost, {
// trade.value is a sum of different values depending on the transaction.
// It always includes any external fees charged by the quote source. In
// addition, if the source asset is ETH, trade.value includes the amount
// of swapped ETH.
const totalWeiCost = new BigNumber(gasTotalInWeiHex, 16)
.plus(trade.value, 16)
const totalEthCost = conversionUtil(totalWeiCost, {
fromCurrency: 'ETH',
fromDenomination: 'WEI',
toDenomination: 'ETH',
@ -443,10 +479,26 @@ export default class SwapsController {
numberOfDecimals: 6,
})
// The total fee is aggregator/exchange fees plus gas fees.
// If the swap is from ETH, subtract the sourceAmount from the total cost.
// Otherwise, the total fee is simply trade.value plus gas fees.
const ethFee = sourceToken === ETH_SWAPS_TOKEN_ADDRESS
? conversionUtil(
totalWeiCost.minus(sourceAmount, 10), // sourceAmount is in wei
{
fromCurrency: 'ETH',
fromDenomination: 'WEI',
toDenomination: 'ETH',
fromNumericBase: 'BN',
numberOfDecimals: 6,
},
)
: totalEthCost
const tokenConversionRate = tokenConversionRates[destinationToken]
const ethValueOfTrade =
destinationTokenInfo.symbol === 'ETH'
? calcTokenAmount(destinationAmount, 18).minus(ethFee, 10)
destinationToken === ETH_SWAPS_TOKEN_ADDRESS
? calcTokenAmount(destinationAmount, 18).minus(totalEthCost, 10)
: new BigNumber(tokenConversionRate || 1, 10)
.times(
calcTokenAmount(
@ -455,22 +507,51 @@ export default class SwapsController {
),
10,
)
.minus(tokenConversionRate ? ethFee.toString(10) : 0, 10)
.minus(tokenConversionRate ? totalEthCost : 0, 10)
// collect values for savings calculation
allEthTradeValues.push(ethValueOfTrade)
allEthFees.push(ethFee)
if (
ethValueOfTradeForBestQuote === null ||
ethValueOfTrade.gt(ethValueOfTradeForBestQuote)
ethTradeValueOfBestQuote === null ||
ethValueOfTrade.gt(ethTradeValueOfBestQuote)
) {
topAggId = aggregator
ethValueOfTradeForBestQuote = ethValueOfTrade
ethTradeValueOfBestQuote = ethValueOfTrade
ethFeeForBestQuote = ethFee
}
})
const isBest =
quotes[topAggId]?.destinationTokenInfo?.symbol === 'ETH' ||
quotes[topAggId].destinationToken === ETH_SWAPS_TOKEN_ADDRESS ||
Boolean(tokenConversionRates[quotes[topAggId]?.destinationToken])
return { topAggId, isBest }
let savings = null
if (isBest) {
savings = {}
// Performance savings are calculated as:
// valueForBestTrade - medianValueOfAllTrades
savings.performance = ethTradeValueOfBestQuote.minus(
getMedian(allEthTradeValues),
10,
)
// Performance savings are calculated as:
// medianFeeOfAllTrades - feeForBestTrade
savings.fee = getMedian(allEthFees).minus(
ethFeeForBestQuote,
10,
)
// Total savings are the sum of performance and fee savings
savings.total = savings.performance.plus(savings.fee, 10).toString(10)
savings.performance = savings.performance.toString(10)
savings.fee = savings.fee.toString(10)
}
return { topAggId, isBest, savings }
}
async _getERC20Allowance (contractAddress, walletAddress) {
@ -563,5 +644,39 @@ export default class SwapsController {
this.setSwapsLiveness(swapsFeatureIsLive)
}
}
}
/**
* Calculates the median of a sample of BigNumber values.
*
* @param {import('bignumber.js').BigNumber[]} values - A sample of BigNumber
* values. The array will be sorted in place.
* @returns {import('bignumber.js').BigNumber} The median of the sample.
*/
function getMedian (values) {
if (!Array.isArray(values) || values.length === 0) {
throw new Error('Expected non-empty array param.')
}
values.sort((a, b) => {
if (a.equals(b)) {
return 0
}
return a.lessThan(b) ? -1 : 1
})
if (values.length % 2 === 1) {
// return middle value
return values[(values.length - 1) / 2]
}
// return mean of middle two values
const upperIndex = values.length / 2
return values[upperIndex]
.plus(values[upperIndex - 1])
.dividedBy(2)
}
export const utils = {
getMedian,
}

View File

@ -46,6 +46,7 @@ export default class TokenRatesController {
this.store.putState({ contractExchangeRates })
}
/* eslint-disable accessor-pairs */
/**
* @type {Object}
*/
@ -68,6 +69,7 @@ export default class TokenRatesController {
this._tokens = tokens
this.updateExchangeRates()
}
/* eslint-enable accessor-pairs */
start (interval = DEFAULT_INTERVAL) {
this._handle && clearInterval(this._handle)

View File

@ -76,7 +76,7 @@ export default class TransactionController extends EventEmitter {
this.blockTracker = opts.blockTracker
this.signEthTx = opts.signTransaction
this.inProcessOfSigning = new Set()
this._trackSegmentEvent = opts.trackSegmentEvent
this._trackMetaMetricsEvent = opts.trackMetaMetricsEvent
this._getParticipateInMetrics = opts.getParticipateInMetrics
this.memStore = new ObservableStore({})
@ -829,13 +829,13 @@ export default class TransactionController extends EventEmitter {
_trackSwapsMetrics (txMeta, approvalTxMeta) {
if (this._getParticipateInMetrics() && txMeta.swapMetaData) {
if (txMeta.txReceipt.status === '0x0') {
this._trackSegmentEvent({
this._trackMetaMetricsEvent({
event: 'Swap Failed',
category: 'swaps',
excludeMetaMetricsId: false,
})
this._trackSegmentEvent({
this._trackMetaMetricsEvent({
event: 'Swap Failed',
properties: { ...txMeta.swapMetaData },
category: 'swaps',
@ -860,18 +860,18 @@ export default class TransactionController extends EventEmitter {
const estimatedVsUsedGasRatio = `${
(new BigNumber(txMeta.txReceipt.gasUsed, 16))
.div(txMeta.swapMetaData.estimated_gas, 16)
.div(txMeta.swapMetaData.estimated_gas, 10)
.times(100)
.round(2)
}%`
this._trackSegmentEvent({
this._trackMetaMetricsEvent({
event: 'Swap Completed',
category: 'swaps',
excludeMetaMetricsId: false,
})
this._trackSegmentEvent({
this._trackMetaMetricsEvent({
event: 'Swap Completed',
category: 'swaps',
properties: {

View File

@ -1,14 +0,0 @@
import { getBackgroundMetaMetricState } from '../../../ui/app/selectors'
import { sendMetaMetricsEvent } from '../../../ui/app/helpers/utils/metametrics.util'
export default function backgroundMetaMetricsEvent (metaMaskState, version, eventData) {
const stateEventData = getBackgroundMetaMetricState({ metamask: metaMaskState })
if (stateEventData.participateInMetaMetrics) {
sendMetaMetricsEvent({
...stateEventData,
...eventData,
version,
currentPath: '/background',
})
}
}

View File

@ -1,4 +1,3 @@
import urlUtil from 'url'
import extension from 'extensionizer'
import resolveEnsToIpfsContentId from './resolver'
@ -26,8 +25,7 @@ export default function setupEnsIpfsResolver ({ provider, getCurrentNetwork, get
return
}
// parse ens name
const urlData = urlUtil.parse(url)
const { hostname: name, path, search, hash: fragment } = urlData
const { hostname: name, pathname, search, hash: fragment } = new URL(url)
const domainParts = name.split('.')
const topLevelDomain = domainParts[domainParts.length - 1]
// if unsupported TLD, abort
@ -35,17 +33,17 @@ export default function setupEnsIpfsResolver ({ provider, getCurrentNetwork, get
return
}
// otherwise attempt resolve
attemptResolve({ tabId, name, path, search, fragment })
attemptResolve({ tabId, name, pathname, search, fragment })
}
async function attemptResolve ({ tabId, name, path, search, fragment }) {
async function attemptResolve ({ tabId, name, pathname, search, fragment }) {
const ipfsGateway = getIpfsGateway()
extension.tabs.update(tabId, { url: `loading.html` })
let url = `https://app.ens.domains/name/${name}`
try {
const { type, hash } = await resolveEnsToIpfsContentId({ provider, name })
if (type === 'ipfs-ns' || type === 'ipns-ns') {
const resolvedUrl = `https://${hash}.${type.slice(0, 4)}.${ipfsGateway}${path}${search || ''}${fragment || ''}`
const resolvedUrl = `https://${hash}.${type.slice(0, 4)}.${ipfsGateway}${pathname}${search || ''}${fragment || ''}`
try {
// check if ipfs gateway has result
const response = await window.fetch(resolvedUrl, { method: 'HEAD' })
@ -56,11 +54,11 @@ export default function setupEnsIpfsResolver ({ provider, getCurrentNetwork, get
console.warn(err)
}
} else if (type === 'swarm-ns') {
url = `https://swarm-gateways.net/bzz:/${hash}${path}${search || ''}${fragment || ''}`
url = `https://swarm-gateways.net/bzz:/${hash}${pathname}${search || ''}${fragment || ''}`
} else if (type === 'onion' || type === 'onion3') {
url = `http://${hash}.onion${path}${search || ''}${fragment || ''}`
url = `http://${hash}.onion${pathname}${search || ''}${fragment || ''}`
} else if (type === 'zeronet') {
url = `http://127.0.0.1:43110/${hash}${path}${search || ''}${fragment || ''}`
url = `http://127.0.0.1:43110/${hash}${pathname}${search || ''}${fragment || ''}`
}
} catch (err) {
console.warn(err)

View File

@ -1,3 +1,12 @@
/**
* A string representing the type of environment the application is currently running in
* popup - When the user click's the icon in their browser's extension bar; the default view
* notification - When the extension opens due to interaction with a Web3 enabled website
* fullscreen - When the user clicks 'expand view' to open the extension in a new tab
* background - The background process that powers the extension
* @typedef {'popup' | 'notification' | 'fullscreen' | 'background'} EnvironmentType
*/
const ENVIRONMENT_TYPE_POPUP = 'popup'
const ENVIRONMENT_TYPE_NOTIFICATION = 'notification'
const ENVIRONMENT_TYPE_FULLSCREEN = 'fullscreen'

View File

@ -50,7 +50,12 @@ function logWeb3UsageHandler (
event: `Website Accessed window.web3`,
category: 'inpage_provider',
properties: { action, web3Property: name },
referrerUrl: origin,
eventContext: {
referrer: {
url: origin,
},
},
excludeMetaMetricsId: true,
})
}

View File

@ -1,112 +0,0 @@
import Analytics from 'analytics-node'
const inDevelopment = process.env.METAMASK_DEBUG || process.env.IN_TEST
const flushAt = inDevelopment ? 1 : undefined
const METAMETRICS_ANONYMOUS_ID = '0x0000000000000000'
const segmentNoop = {
track () {
// noop
},
page () {
// noop
},
identify () {
// noop
},
}
// We do not want to track events on development builds unless specifically
// provided a SEGMENT_WRITE_KEY. This also holds true for test environments and
// E2E, which is handled in the build process by never providing the SEGMENT_WRITE_KEY
// which process.env.IN_TEST is true
const segment = process.env.SEGMENT_WRITE_KEY
? new Analytics(process.env.SEGMENT_WRITE_KEY, { flushAt })
: segmentNoop
/**
* Returns a function for tracking Segment events.
*
* @param {string} metamaskVersion - The current version of the MetaMask
* extension.
* @param {Function} getParticipateInMetrics - A function that returns
* whether the user participates in MetaMetrics.
* @param {Function} getMetricsState - A function for getting state relevant
* to MetaMetrics/Segment.
*/
export function getTrackSegmentEvent (
metamaskVersion,
getParticipateInMetrics,
getMetricsState,
) {
const version = process.env.METAMASK_ENVIRONMENT === 'production'
? metamaskVersion
: `${metamaskVersion}-${process.env.METAMASK_ENVIRONMENT}`
const segmentContext = {
app: {
name: 'MetaMask Extension',
version,
},
page: {
path: '/background-process',
title: 'Background Process',
url: '/background-process',
},
userAgent: window.navigator.userAgent,
}
/**
* Tracks a Segment event per the given arguments.
*
* @param {string} event - The event name.
* @param {string} category - The event category.
* @param {Object} [properties] - The event properties.
* @param {string} [referrerUrl] - The event's referrer URL, if relevant.
* @param {boolean} [excludeMetaMetricsId] - `true` if the user's MetaMetrics id should
* not be included, and `false` otherwise. Default: `true`
*/
return function trackSegmentEvent ({
event,
category,
properties = {},
excludeMetaMetricsId = true,
referrerUrl,
}) {
if (!event || !category) {
throw new Error('Must specify event and category.')
}
if (!getParticipateInMetrics()) {
return
}
const { currentLocale, metaMetricsId } = getMetricsState()
const trackOptions = {
event,
category,
context: {
...segmentContext,
locale: currentLocale.replace('_', '-'),
},
properties,
}
if (excludeMetaMetricsId) {
trackOptions.anonymousId = METAMETRICS_ANONYMOUS_ID
} else {
trackOptions.userId = metaMetricsId
}
if (referrerUrl) {
trackOptions.context.referrer = {
url: referrerUrl,
}
}
segment.track(trackOptions)
}
}

View File

@ -24,6 +24,8 @@ import {
CurrencyRateController,
PhishingController,
} from '@metamask/controllers'
import { getTrackMetaMetricsEvent } from '../../shared/modules/metametrics'
import { getBackgroundMetaMetricState } from '../../ui/app/selectors'
import ComposableObservableStore from './lib/ComposableObservableStore'
import AccountTracker from './lib/account-tracker'
import createLoggerMiddleware from './lib/createLoggerMiddleware'
@ -55,9 +57,7 @@ import getRestrictedMethods from './controllers/permissions/restrictedMethods'
import nodeify from './lib/nodeify'
import accountImporter from './account-import-strategies'
import seedPhraseVerifier from './lib/seed-phrase-verifier'
import { getTrackSegmentEvent } from './lib/segment'
import backgroundMetaMetricsEvent from './lib/background-metametrics'
import { ENVIRONMENT_TYPE_BACKGROUND } from './lib/enums'
export default class MetamaskController extends EventEmitter {
@ -115,16 +115,32 @@ export default class MetamaskController extends EventEmitter {
migrateAddressBookState: this.migrateAddressBookState.bind(this),
})
// This depends on preferences controller state
this.trackSegmentEvent = getTrackSegmentEvent(
this.trackMetaMetricsEvent = getTrackMetaMetricsEvent(
this.platform.getVersion(),
() => this.preferencesController.getParticipateInMetaMetrics(),
() => {
const participateInMetaMetrics = this.preferencesController.getParticipateInMetaMetrics()
const {
currentLocale,
metaMetricsId,
} = this.preferencesController.store.getState()
return { currentLocale, metaMetricsId }
const chainId = this.networkController.getCurrentChainId()
const provider = this.networkController.getProviderConfig()
const network = provider.type === 'rpc' ? provider.rpcUrl : provider.type
return {
participateInMetaMetrics,
metaMetricsId,
environmentType: ENVIRONMENT_TYPE_BACKGROUND,
chainId,
network,
context: {
page: {
path: '/background-process',
title: 'Background Process',
url: '/background-process',
},
locale: currentLocale.replace('_', '-'),
},
}
},
)
@ -252,7 +268,7 @@ export default class MetamaskController extends EventEmitter {
signTransaction: this.keyringController.signTransaction.bind(this.keyringController),
provider: this.provider,
blockTracker: this.blockTracker,
trackSegmentEvent: this.trackSegmentEvent,
trackMetaMetricsEvent: this.trackMetaMetricsEvent,
getParticipateInMetrics: () => this.preferencesController.getParticipateInMetaMetrics(),
})
this.txController.on('newUnapprovedTx', () => opts.showUnapprovedTx())
@ -295,6 +311,7 @@ export default class MetamaskController extends EventEmitter {
this.swapsController = new SwapsController({
getBufferedGasLimit: this.txController.txGasUtil.getBufferedGasLimit.bind(this.txController.txGasUtil),
networkController: this.networkController,
provider: this.provider,
getProviderConfig: this.networkController.getProviderConfig.bind(this.networkController),
tokenRatesStore: this.tokenRatesController.store,
@ -1671,7 +1688,7 @@ export default class MetamaskController extends EventEmitter {
}))
engine.push(createMethodMiddleware({
origin,
sendMetrics: this.trackSegmentEvent,
sendMetrics: this.trackMetaMetricsEvent,
}))
// filter and subscription polyfills
engine.push(filterMiddleware)
@ -1876,19 +1893,18 @@ export default class MetamaskController extends EventEmitter {
}
const metamaskState = await this.getState()
const version = this.platform.getVersion()
backgroundMetaMetricsEvent(
metamaskState,
version,
{
customVariables,
eventOpts: {
action,
category: 'Background',
name,
},
const additionalProperties = getBackgroundMetaMetricState(metamaskState)
this.trackMetaMetricsEvent({
event: name,
category: 'Background',
matomoEvent: true,
properties: {
action,
...additionalProperties,
...customVariables,
},
)
})
}
/**
@ -2146,6 +2162,7 @@ export default class MetamaskController extends EventEmitter {
}
// TODO: Replace isClientOpen methods with `controllerConnectionChanged` events.
/* eslint-disable accessor-pairs */
/**
* A method for recording whether the MetaMask user interface is open or not.
* @private
@ -2155,6 +2172,7 @@ export default class MetamaskController extends EventEmitter {
this._isClientOpen = open
this.detectTokensController.isOpen = open
}
/* eslint-enable accessor-pairs */
/**
* Creates RPC engine middleware for processing eth_signTypedData requests

View File

@ -1,9 +1,3 @@
/* The migrator has two methods the user should be concerned with:
*
* getData(), which returns the app-consumable data object
* saveData(), which persists the app-consumable data object.
*/
// Migrations must start at version 1 or later.
// They are objects with a `version` number
// and a `migrate` function.

View File

@ -3,6 +3,10 @@
//
// run any task with "yarn build ${taskName}"
//
global.globalThis = global // eslint-disable-line node/no-unsupported-features/es-builtins
require('lavamoat-core/lib/ses.umd.js')
lockdown() // eslint-disable-line no-undef
const livereload = require('gulp-livereload')
const { createTask, composeSeries, composeParallel, detectAndRunEntryTask } = require('./task')

View File

@ -1,4 +1,5 @@
const { promises: fs } = require('fs')
const path = require('path')
const { merge, cloneDeep } = require('lodash')
const baseManifest = require('../../app/manifest/_base.json')
@ -16,11 +17,11 @@ function createManifestTasks ({ browserPlatforms }) {
// merge base manifest with per-platform manifests
const prepPlatforms = async () => {
return Promise.all(browserPlatforms.map(async (platform) => {
const platformModifications = await readJson(`${__dirname}/../../app/manifest/${platform}.json`)
const platformModifications = await readJson(path.join(__dirname, '..', '..', 'app', 'manifest', `${platform}.json`))
const result = merge(cloneDeep(baseManifest), platformModifications)
const dir = `./dist/${platform}`
const dir = path.join('.', 'dist', platform)
await fs.mkdir(dir, { recursive: true })
await writeJson(result, `${dir}/manifest.json`)
await writeJson(result, path.join(dir, 'manifest.json'))
}))
}
@ -75,10 +76,10 @@ function createManifestTasks ({ browserPlatforms }) {
function createTaskForModifyManifestForEnvironment (transformFn) {
return () => {
return Promise.all(browserPlatforms.map(async (platform) => {
const path = `./dist/${platform}/manifest.json`
const manifest = await readJson(path)
const manifestPath = path.join('.', 'dist', platform, 'manifest.json')
const manifest = await readJson(manifestPath)
transformFn(manifest)
await writeJson(manifest, path)
await writeJson(manifest, manifestPath)
}))
}
}
@ -86,11 +87,11 @@ function createManifestTasks ({ browserPlatforms }) {
}
// helper for reading and deserializing json from fs
async function readJson (path) {
return JSON.parse(await fs.readFile(path, 'utf8'))
async function readJson (file) {
return JSON.parse(await fs.readFile(file, 'utf8'))
}
// helper for serializing and writing json to fs
async function writeJson (obj, path) {
return fs.writeFile(path, JSON.stringify(obj, null, 2))
async function writeJson (obj, file) {
return fs.writeFile(file, JSON.stringify(obj, null, 2))
}

View File

@ -18,6 +18,7 @@ const { makeStringTransform } = require('browserify-transform-tools')
const conf = require('rc')('metamask', {
INFURA_PROJECT_ID: process.env.INFURA_PROJECT_ID,
SEGMENT_WRITE_KEY: process.env.SEGMENT_WRITE_KEY,
SEGMENT_LEGACY_WRITE_KEY: process.env.SEGMENT_LEGACY_WRITE_KEY,
})
const packageJSON = require('../../package.json')
@ -324,6 +325,7 @@ function createScriptTasks ({ browserPlatforms, livereload }) {
// inflating event volume.
const SEGMENT_PROD_WRITE_KEY = opts.testing ? undefined : process.env.SEGMENT_PROD_WRITE_KEY
const SEGMENT_DEV_WRITE_KEY = opts.testing ? undefined : conf.SEGMENT_WRITE_KEY
const SEGMENT_LEGACY_WRITE_KEY = opts.testing ? undefined : conf.SEGMENT_LEGACY_WRITE_KEY
// Inject variables into bundle
bundler.transform(envify({
@ -343,6 +345,7 @@ function createScriptTasks ({ browserPlatforms, livereload }) {
: conf.INFURA_PROJECT_ID
),
SEGMENT_WRITE_KEY: environment === 'production' ? SEGMENT_PROD_WRITE_KEY : SEGMENT_DEV_WRITE_KEY,
SEGMENT_LEGACY_WRITE_KEY: environment === 'production' ? process.env.SEGMENT_LEGACY_WRITE_KEY : SEGMENT_LEGACY_WRITE_KEY,
}), {
global: true,
})

View File

@ -50,26 +50,29 @@ function createStyleTasks ({ livereload }) {
livereload.changed(event.path)
})
}
await buildScss(devMode)
await buildScss()
}
async function buildScss () {
await pump(...[
// pre-process
gulp.src(src),
devMode && sourcemaps.init(),
sass().on('error', sass.logError),
devMode && sourcemaps.write(),
autoprefixer(),
// standard
gulp.dest(dest),
// right-to-left
rtlcss(),
rename({ suffix: '-rtl' }),
devMode && sourcemaps.write(),
gulp.dest(dest),
].filter(Boolean))
await Promise.all([
buildScssPipeline(src, dest, devMode, false),
buildScssPipeline(src, dest, devMode, true),
])
}
}
}
async function buildScssPipeline (src, dest, devMode, rtl) {
await pump(...[
// pre-process
gulp.src(src),
devMode && sourcemaps.init(),
sass().on('error', sass.logError),
autoprefixer(),
rtl && rtlcss(),
rtl && rename({ suffix: '-rtl' }),
devMode && sourcemaps.write(),
gulp.dest(dest),
].filter(Boolean))
}

View File

@ -1,5 +1,5 @@
const EventEmitter = require('events')
const { spawn } = require('child_process')
const spawn = require('cross-spawn')
const tasks = {}
const taskEvents = new EventEmitter()

View File

@ -97,7 +97,6 @@ async function getLocale (code) {
log.error(`Error opening your locale ("${code}") file: `, e)
}
process.exit(1)
return undefined
}
}
@ -112,7 +111,6 @@ async function writeLocale (code, locale) {
log.error(`Error writing your locale ("${code}") file: `, e)
}
process.exit(1)
return undefined
}
}

View File

@ -1,72 +0,0 @@
## Creating Metrics Events
The `metricsEvent` method is made available to all components via context. This is done in `metamask-extension/ui/app/helpers/higher-order-components/metametrics/metametrics.provider.js`. As such, it can be called in all components by first adding it to the context proptypes:
```
static contextTypes = {
t: PropTypes.func,
metricsEvent: PropTypes.func,
}
```
and then accessing it on `this.context`.
Below is an example of a metrics event call:
```
this.context.metricsEvent({
eventOpts: {
category: 'Navigation',
action: 'Main Menu',
name: 'Switched Account',
},
})
```
### Base Schema
Every `metricsEvent` call is passed an object that must have an `eventOpts` property. This property is an object that itself must have three properties:
- category: categorizes events according to the schema we have set up in our matomo.org instance
- action: usually describes the page on which the event takes place, or sometimes a significant subsections of a page
- name: a very specific descriptor of the event
### Implicit properties
All metrics events send the following data when called:
- network
- environmentType
- activeCurrency
- accountType
- numberOfTokens
- numberOfAccounts
- version
These are added to the metrics event via the metametrics provider.
### Custom Variables
Metrics events can include custom variables. These are included within the `customVariables` property that is a first-level property within first param passed to `metricsEvent`.
For example:
```
this.context.metricsEvent({
eventOpts: {
category: 'Settings',
action: 'Custom RPC',
name: 'Error',
},
customVariables: {
networkId: newRpc,
chainId,
},
})
```
Custom variables can have custom property names and values can be strings or numbers.
**To include a custom variable, there are a set of necessary steps you must take.**
1. First you must declare a constant equal to the desired name of the custom variable property in `metamask-extension/ui/app/helpers/utils/metametrics.util.js` under `//Custom Variable Declarations`
1. Then you must add that name to the `customVariableNameIdMap` declaration
1. The id must be between 1 and 5
1. There can be no more than 5 custom variables assigned ids on a given url

View File

@ -17,7 +17,7 @@
"dapp-forwarder": "concurrently -k -n forwarder,dapp -p '[{time}][{name}]' 'yarn forwarder' 'yarn dapp'",
"sendwithprivatedapp": "node development/static-server.js test/e2e/send-eth-with-private-key-test --port 8080",
"test:unit": "mocha --exit --require test/env.js --require test/setup.js --recursive \"test/unit/**/*.js\" \"ui/app/**/*.test.js\"",
"test:unit:global": "mocha --exit --require test/env.js --require test/setup.js --recursive mocha test/unit-global/*",
"test:unit:global": "mocha --exit --require test/env.js --require test/setup.js --recursive test/unit-global/*",
"test:unit:lax": "mocha --exit --require test/env.js --require test/setup.js --recursive \"test/unit/{,**/!(permissions)}/*.js\" \"ui/app/**/*.test.js\"",
"test:unit:strict": "mocha --exit --require test/env.js --require test/setup.js --recursive \"test/unit/**/permissions/*.js\"",
"test:unit:path": "mocha --exit --require test/env.js --require test/setup.js --recursive",
@ -61,9 +61,7 @@
"3box/ipfs/ipld-zcash/zcash-bitcore-lib/elliptic": "^6.5.3",
"3box/**/libp2p-crypto/node-forge": "^0.10.0",
"3box/**/libp2p-keychain/node-forge": "^0.10.0",
"browserify-derequire/derequire": "^2.1.1",
"ganache-core/lodash": "^4.17.19",
"ganache-core/websocket": "^1.0.32"
"browserify-derequire/derequire": "^2.1.1"
},
"dependencies": {
"3box": "^1.10.2",
@ -175,7 +173,9 @@
"web3-stream-provider": "^4.0.0"
},
"devDependencies": {
"@babel/core": "^7.5.5",
"@babel/core": "^7.12.1",
"@babel/eslint-parser": "^7.12.1",
"@babel/eslint-plugin": "^7.12.1",
"@babel/plugin-proposal-class-properties": "^7.5.5",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.10.4",
"@babel/plugin-proposal-object-rest-spread": "^7.5.5",
@ -184,9 +184,9 @@
"@babel/preset-env": "^7.5.5",
"@babel/preset-react": "^7.0.0",
"@babel/register": "^7.5.5",
"@metamask/eslint-config": "^3.2.0",
"@metamask/eslint-config": "^4.1.0",
"@metamask/forwarder": "^1.1.0",
"@metamask/test-dapp": "^3.1.0",
"@metamask/test-dapp": "^3.2.0",
"@sentry/cli": "^1.58.0",
"@storybook/addon-actions": "^5.3.14",
"@storybook/addon-backgrounds": "^5.3.14",
@ -196,8 +196,8 @@
"@storybook/storybook-deployer": "^2.8.6",
"@testing-library/react": "^10.4.8",
"@testing-library/react-hooks": "^3.2.1",
"@types/react": "^16.9.53",
"addons-linter": "1.14.0",
"babel-eslint": "^10.1.0",
"babel-loader": "^8.0.6",
"babelify": "^10.0.0",
"brfs": "^2.0.2",
@ -210,24 +210,25 @@
"concurrently": "^5.2.0",
"copy-webpack-plugin": "^6.0.3",
"coveralls": "^3.0.0",
"cross-spawn": "^7.0.3",
"css-loader": "^2.1.1",
"del": "^3.0.0",
"deps-dump": "^1.1.0",
"envify": "^4.1.0",
"enzyme": "^3.10.0",
"enzyme-adapter-react-16": "^1.15.1",
"eslint": "^6.8.0",
"eslint-plugin-babel": "^5.3.0",
"eslint": "^7.7.0",
"eslint-plugin-import": "^2.22.0",
"eslint-plugin-mocha": "^6.3.0",
"eslint-plugin-react": "^7.18.3",
"eslint-plugin-mocha": "^8.0.0",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-react": "~7.20.0",
"eslint-plugin-react-hooks": "^4.0.4",
"fancy-log": "^1.3.3",
"fast-glob": "^3.2.2",
"file-loader": "^1.1.11",
"fs-extra": "^8.1.0",
"ganache-cli": "^6.9.1",
"ganache-core": "^2.10.2",
"ganache-cli": "^6.12.1",
"ganache-core": "^2.13.1",
"geckodriver": "^1.19.1",
"get-port": "^5.1.0",
"gulp": "^4.0.2",
@ -248,6 +249,7 @@
"gulp-zip": "^4.0.0",
"jsdom": "^11.2.0",
"koa": "^2.7.0",
"lavamoat-core": "^6.1.0",
"lockfile-lint": "^4.0.0",
"mocha": "^7.2.0",
"nock": "^9.0.14",
@ -287,7 +289,7 @@
"webpack": "^4.41.6"
},
"engines": {
"node": "^10.16.0",
"node": "^10.18.1",
"yarn": "^1.16.0"
}
}

View File

@ -0,0 +1,251 @@
import Analytics from 'analytics-node'
import { omit, pick } from 'lodash'
// flushAt controls how many events are collected in the queue before they
// are sent to segment. I recommend a queue size of one due to an issue with
// detecting and flushing events in an extension beforeunload doesn't work in
// a notification context. Because notification windows are opened and closed
// in reaction to the very events we want to track, it is problematic to cache
// at all.
const flushAt = 1
export const METAMETRICS_ANONYMOUS_ID = '0x0000000000000000'
const segmentNoop = {
track (_, callback = () => undefined) {
// Need to call the callback so that environments without a segment id still
// resolve the promise from trackMetaMetricsEvent
return callback()
},
page () {
// noop
},
identify () {
// noop
},
}
/**
* Used to determine whether or not to attach a user's metametrics id
* to events that include on-chain data. This helps to prevent identifying
* a user by being able to trace their activity on etherscan/block exploring
*/
const trackableSendCounts = {
1: true,
10: true,
30: true,
50: true,
100: true,
250: true,
500: true,
1000: true,
2500: true,
5000: true,
10000: true,
25000: true,
}
export function sendCountIsTrackable (sendCount) {
return Boolean(trackableSendCounts[sendCount])
}
// We do not want to track events on development builds unless specifically
// provided a SEGMENT_WRITE_KEY. This also holds true for test environments and
// E2E, which is handled in the build process by never providing the SEGMENT_WRITE_KEY
// when process.env.IN_TEST is truthy
export const segment = process.env.SEGMENT_WRITE_KEY
? new Analytics(process.env.SEGMENT_WRITE_KEY, { flushAt })
: segmentNoop
export const segmentLegacy = process.env.SEGMENT_LEGACY_WRITE_KEY
? new Analytics(process.env.SEGMENT_LEGACY_WRITE_KEY, { flushAt })
: segmentNoop
/**
* We attach context to every meta metrics event that help to qualify our analytics.
* This type has all optional values because it represents a returned object from a
* method call. Ideally app and userAgent are defined on every event. This is confirmed
* in the getTrackMetaMetricsEvent function, but still provides the consumer a way to
* override these values if necessary.
* @typedef {Object} MetaMetricsContext
* @property {Object} app
* @property {string} app.name - the name of the application tracking the event
* @property {string} app.version - the version of the application
* @property {string} userAgent - the useragent string of the user
* @property {string} locale - the locale string for the user
* @property {Object} [page] - an object representing details of the current page
* @property {string} [page.path] - the path of the current page (e.g /home)
* @property {string} [page.title] - the title of the current page (e.g 'home')
* @property {string} [page.url] - the fully qualified url of the current page
* @property {Object} [referrer] - for metamask, this is the dapp that triggered an interaction
* @property {string} [referrer.url] - the origin of the dapp issuing the notification
*/
/**
* page and referrer from the MetaMetricsContext are very dynamic in nature and may be
* provided as part of the initial context payload when creating the trackMetaMetricsEvent function,
* or at the event level when calling the trackMetaMetricsEvent function.
* @typedef {Pick<MetaMetricsContext, 'page' | 'referrer'>} MetaMetricsDynamicContext
*/
/**
* @typedef {import('../../app/scripts/lib/enums').EnvironmentType} EnvironmentType
*/
/**
* @typedef {Object} MetaMetricsRequiredState
* @property {bool} participateInMetaMetrics - has the user opted into metametrics
* @property {string} [metaMetricsId] - the user's metaMetricsId, if they have opted in
* @property {MetaMetricsDynamicContext} context - context about the event
* @property {string} chainId - the chain id of the current network
* @property {string} locale - the locale string of the current user
* @property {string} network - the name of the current network
* @property {EnvironmentType} environmentType - environment that the event happened in
* @property {string} [metaMetricsSendCount] - number of transactions sent, used to add metametricsId
* intermittently to events with onchain data attached to them used to protect identity of users.
*/
/**
* @typedef {Object} MetaMetricsEventPayload
* @property {string} event - event name to track
* @property {string} category - category to associate event to
* @property {boolean} [isOptIn] - happened during opt in/out workflow
* @property {object} [properties] - object of custom values to track, snake_case
* @property {number} [revenue] - amount of currency that event creates in revenue for MetaMask
* @property {string} [currency] - ISO 4127 format currency for events with revenue, defaults to US dollars
* @property {number} [value] - Abstract "value" that this event has for MetaMask.
* @property {boolean} [excludeMetaMetricsId] - whether to exclude the user's metametrics id for anonymity
* @property {string} [metaMetricsId] - an override for the metaMetricsId in the event one is created as part
* of an asynchronous workflow, such as awaiting the result of the metametrics opt-in function that generates the
* user's metametrics id.
* @property {boolean} [matomoEvent] - is this event a holdover from matomo that needs further migration?
* when true, sends the data to a special segment source that marks the event data as not conforming to our
* ideal schema
* @property {MetaMetricsDynamicContext} [eventContext] - additional context to attach to event
*/
/**
* Returns a function for tracking Segment events.
*
* @param {string} metamaskVersion - The current version of the MetaMask extension.
* @param {() => MetaMetricsRequiredState} getDynamicState - A function returning required fields
* @returns {(payload: MetaMetricsEventPayload) => Promise<void>} - function to track an event
*/
export function getTrackMetaMetricsEvent (
metamaskVersion,
getDynamicState,
) {
const version = process.env.METAMASK_ENVIRONMENT === 'production'
? metamaskVersion
: `${metamaskVersion}-${process.env.METAMASK_ENVIRONMENT}`
return function trackMetaMetricsEvent ({
event,
category,
isOptIn,
properties = {},
revenue,
currency,
value,
metaMetricsId: metaMetricsIdOverride,
excludeMetaMetricsId: excludeId,
matomoEvent = false,
eventContext = {},
}) {
if (!event || !category) {
throw new Error('Must specify event and category.')
}
const {
participateInMetaMetrics,
context: providedContext,
metaMetricsId,
environmentType,
chainId,
locale,
network,
metaMetricsSendCount,
} = getDynamicState()
let excludeMetaMetricsId = excludeId ?? false
// This is carried over from the old implementation, and will likely need
// to be updated to work with the new tracking plan. I think we should use
// a config setting for this instead of trying to match the event name
const isSendFlow = Boolean(event.match(/^send|^confirm/u))
if (isSendFlow && metaMetricsSendCount && !sendCountIsTrackable(metaMetricsSendCount + 1)) {
excludeMetaMetricsId = true
}
if (!participateInMetaMetrics && !isOptIn) {
return Promise.resolve()
}
/** @type {MetaMetricsContext} */
const context = {
app: {
name: 'MetaMask Extension',
version,
},
locale,
userAgent: window.navigator.userAgent,
...pick(providedContext, ['page', 'referrer']),
...pick(eventContext, ['page', 'referrer']),
}
const trackOptions = {
event,
properties: {
// These values are omitted from properties because they have special meaning
// in segment. https://segment.com/docs/connections/spec/track/#properties.
// to avoid accidentally using these inappropriately, you must add them as top
// level properties on the event payload.
...omit(properties, ['revenue', 'currency', 'value']),
revenue,
value,
currency,
category,
network,
chain_id: chainId,
environment_type: environmentType,
},
context,
}
// If we are tracking sensitive data we will always use the anonymousId property
// as well as our METAMETRICS_ANONYMOUS_ID. This prevents us from associating potentially
// identifiable information with a specific id. During the opt in flow we will track all
// events, but do so with the anonymous id. The one exception to that rule is after the
// user opts in to MetaMetrics. When that happens we receive back the user's new MetaMetrics
// id before it is fully persisted to state. To avoid a race condition we explicitly pass the
// new id to the track method. In that case we will track the opt in event to the user's id.
// In all other cases we use the metaMetricsId from state.
if (excludeMetaMetricsId) {
trackOptions.anonymousId = METAMETRICS_ANONYMOUS_ID
} else if (isOptIn && metaMetricsIdOverride) {
trackOptions.userId = metaMetricsIdOverride
} else if (isOptIn) {
trackOptions.anonymousId = METAMETRICS_ANONYMOUS_ID
} else {
trackOptions.userId = metaMetricsId
}
return new Promise((resolve, reject) => {
// This is only safe to do because we are no longer batching events through segment.
// If flushAt is greater than one the callback won't be triggered until after a number
// of events have been queued equal to the flushAt value OR flushInterval passes. The
// default flushInterval is ten seconds
const callback = (err) => {
if (err) {
return reject(err)
}
return resolve()
}
if (matomoEvent === true) {
segmentLegacy.track(trackOptions, callback)
} else {
segment.track(trackOptions, callback)
}
})
}
}

View File

@ -10,7 +10,7 @@ const largeDelayMs = regularDelayMs * 2
const dappPort = 8080
async function withFixtures (options, callback) {
async function withFixtures (options, testSuite) {
const { dapp, fixtures, ganacheOptions, driverOptions, title } = options
const fixtureServer = new FixtureServer()
const ganacheServer = new Ganache()
@ -33,8 +33,7 @@ async function withFixtures (options, callback) {
const { driver } = await buildWebDriver(driverOptions)
webDriver = driver
// eslint-disable-next-line callback-return
await callback({
await testSuite({
driver,
})

View File

@ -676,7 +676,7 @@ describe('MetaMask', function () {
await driver.switchToWindow(extension)
await driver.delay(largeDelayMs * 2)
await driver.findElements(By.css('.transaction-list-item'))
await driver.findElements(By.css('.transaction-list-item--unconfirmed'))
const txListValue = await driver.findClickableElement(By.css('.transaction-list-item__primary-currency'))
await driver.wait(until.elementTextMatches(txListValue, /-4\s*ETH/u), 10000)
await txListValue.click()
@ -772,9 +772,8 @@ describe('MetaMask', function () {
await driver.delay(regularDelayMs * 2)
await driver.clickElement(By.xpath(`//button[contains(text(), 'Create Token')]`))
await driver.delay(largeDelayMs)
windowHandles = await driver.waitUntilXWindowHandles(3)
windowHandles = await driver.getAllWindowHandles()
const popup = windowHandles[2]
await driver.switchToWindow(popup)
await driver.delay(regularDelayMs)

View File

@ -1,5 +1,4 @@
const http = require('http')
const url = require('url')
const port = 8889
@ -20,7 +19,7 @@ const requestHandler = (request, response) => {
response.end('ok')
})
} else if (request.method === 'GET') {
const { key } = url.parse(request.url, true).query
const key = (new URL(request.url, 'https://example.org/')).searchParams.get('key')
response.setHeader('Access-Control-Allow-Headers', '*')
response.end(JSON.stringify(database[key] || ''))
} else {

View File

@ -124,10 +124,11 @@ class Driver {
async waitUntilXWindowHandles (x, delayStep = 1000, timeout = 5000) {
let timeElapsed = 0
let windowHandles = []
while (timeElapsed <= timeout) {
const windowHandles = await this.driver.getAllWindowHandles()
windowHandles = await this.driver.getAllWindowHandles()
if (windowHandles.length === x) {
return
return windowHandles
}
await this.delay(delayStep)
timeElapsed += delayStep

View File

@ -86,6 +86,6 @@ if (!window.crypto) {
window.crypto = {}
}
if (!window.crypto.getRandomValues) {
// eslint-disable-next-line global-require
// eslint-disable-next-line node/global-require
window.crypto.getRandomValues = require('polyfill-crypto.getrandomvalues')
}

View File

@ -31,6 +31,9 @@ export function createTestProviderTools (opts = {}) {
// handle block tracker methods
engine.push(providerAsMiddleware(GanacheCore.provider({
mnemonic: getTestSeed(),
network_id: opts.networkId,
_chainId: opts.chainId,
_chainIdRpc: opts.chainId,
})))
// wrap in standard provider interface
const provider = providerFromEngine(engine)

View File

@ -29,7 +29,7 @@ describe('tx confirmation screen', function () {
it('creates COMPLETED_TX with the cancelled transaction ID', async function () {
actions._setBackgroundConnection({
approveTransaction (_, cb) {
cb('An error!')
cb(new Error('An error!'))
},
cancelTransaction (_, cb) {
cb()

View File

@ -4,8 +4,9 @@ import sinon from 'sinon'
import { ethers } from 'ethers'
import BigNumber from 'bignumber.js'
import ObservableStore from 'obs-store'
import { ROPSTEN_NETWORK_ID, MAINNET_NETWORK_ID } from '../../../../app/scripts/controllers/network/enums'
import { createTestProviderTools } from '../../../stub/provider'
import SwapsController from '../../../../app/scripts/controllers/swaps'
import SwapsController, { utils } from '../../../../app/scripts/controllers/swaps'
const MOCK_FETCH_PARAMS = {
slippage: 3,
@ -17,32 +18,11 @@ const MOCK_FETCH_PARAMS = {
exchangeList: 'zeroExV1',
}
const TEST_AGG_ID = 'zeroExV1'
const MOCK_QUOTES = {
[TEST_AGG_ID]: {
trade: {
data: '0x00',
from: '0x7F18BB4Dd92CF2404C54CBa1A9BE4A1153bdb078',
value: '0x17647444f166000',
gas: '0xe09c0',
gasPrice: undefined,
to: '0x881d40237659c251811cec9c364ef91dc08d300c',
},
sourceAmount: '1000000000000000000000000000000000000',
destinationAmount: '396493201125465',
error: null,
sourceToken: '0x6b175474e89094c44da98b954eedeac495271d0f',
destinationToken: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
approvalNeeded: null,
maxGas: 920000,
averageGas: 312510,
estimatedRefund: 343090,
fetchTime: 559,
aggregator: TEST_AGG_ID,
aggType: 'AGG',
slippage: 3,
},
}
const TEST_AGG_ID_1 = 'TEST_AGG_1'
const TEST_AGG_ID_2 = 'TEST_AGG_2'
const TEST_AGG_ID_BEST = 'TEST_AGG_BEST'
const TEST_AGG_ID_APPROVAL = 'TEST_AGG_APPROVAL'
const MOCK_APPROVAL_NEEDED = {
'data': '0x095ea7b300000000000000000000000095e6f48254609a6ee006f7d493c8e5fb97094cef0000000000000000000000000000000000000000004a817c7ffffffdabf41c00',
'to': '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2',
@ -51,8 +31,9 @@ const MOCK_APPROVAL_NEEDED = {
'gas': '12',
'gasPrice': '34',
}
const MOCK_QUOTES_APPROVAL_REQUIRED = {
[TEST_AGG_ID]: {
[TEST_AGG_ID_APPROVAL]: {
trade: {
data: '0x00',
from: '0x7F18BB4Dd92CF2404C54CBa1A9BE4A1153bdb078',
@ -70,12 +51,13 @@ const MOCK_QUOTES_APPROVAL_REQUIRED = {
averageGas: 312510,
estimatedRefund: 343090,
fetchTime: 559,
aggregator: TEST_AGG_ID,
aggregator: TEST_AGG_ID_APPROVAL,
aggType: 'AGG',
slippage: 3,
approvalNeeded: MOCK_APPROVAL_NEEDED,
},
}
const MOCK_FETCH_METADATA = {
destinationTokenInfo: {
symbol: 'FOO',
@ -94,6 +76,19 @@ const MOCK_GET_BUFFERED_GAS_LIMIT = async () => ({
simulationFails: undefined,
})
function getMockNetworkController () {
return {
store: {
getState: () => {
return {
network: ROPSTEN_NETWORK_ID,
}
},
},
on: sinon.stub().withArgs('networkDidChange').callsArgAsync(1),
}
}
const EMPTY_INIT_STATE = {
swapsState: {
quotes: {},
@ -123,6 +118,7 @@ describe('SwapsController', function () {
const getSwapsController = () => {
return new SwapsController({
getBufferedGasLimit: MOCK_GET_BUFFERED_GAS_LIMIT,
networkController: getMockNetworkController(),
provider,
getProviderConfig: MOCK_GET_PROVIDER_CONFIG,
tokenRatesStore: MOCK_TOKEN_RATES_STORE,
@ -138,7 +134,7 @@ describe('SwapsController', function () {
// by default, all accounts are external accounts (not contracts)
eth_getCode: '0x',
}
provider = createTestProviderTools({ scaffold: providerResultStub })
provider = createTestProviderTools({ scaffold: providerResultStub, networkId: 1, chainId: 1 })
.provider
})
@ -160,6 +156,78 @@ describe('SwapsController', function () {
MOCK_GET_PROVIDER_CONFIG,
)
})
it('should replace ethers instance when network changes', function () {
const networkController = getMockNetworkController()
const swapsController = new SwapsController({
getBufferedGasLimit: MOCK_GET_BUFFERED_GAS_LIMIT,
networkController,
provider,
getProviderConfig: MOCK_GET_PROVIDER_CONFIG,
tokenRatesStore: MOCK_TOKEN_RATES_STORE,
fetchTradesInfo: fetchTradesInfoStub,
fetchSwapsFeatureLiveness: fetchSwapsFeatureLivenessStub,
})
const currentEthersInstance = swapsController.ethersProvider
const onNetworkDidChange = networkController.on.getCall(0).args[1]
onNetworkDidChange(MAINNET_NETWORK_ID)
const newEthersInstance = swapsController.ethersProvider
assert.notStrictEqual(
currentEthersInstance,
newEthersInstance,
'Ethers provider should be replaced',
)
})
it('should not replace ethers instance when network changes to loading', function () {
const networkController = getMockNetworkController()
const swapsController = new SwapsController({
getBufferedGasLimit: MOCK_GET_BUFFERED_GAS_LIMIT,
networkController,
provider,
getProviderConfig: MOCK_GET_PROVIDER_CONFIG,
tokenRatesStore: MOCK_TOKEN_RATES_STORE,
fetchTradesInfo: fetchTradesInfoStub,
fetchSwapsFeatureLiveness: fetchSwapsFeatureLivenessStub,
})
const currentEthersInstance = swapsController.ethersProvider
const onNetworkDidChange = networkController.on.getCall(0).args[1]
onNetworkDidChange('loading')
const newEthersInstance = swapsController.ethersProvider
assert.strictEqual(
currentEthersInstance,
newEthersInstance,
'Ethers provider should not be replaced',
)
})
it('should not replace ethers instance when network changes to the same network', function () {
const networkController = getMockNetworkController()
const swapsController = new SwapsController({
getBufferedGasLimit: MOCK_GET_BUFFERED_GAS_LIMIT,
networkController,
provider,
getProviderConfig: MOCK_GET_PROVIDER_CONFIG,
tokenRatesStore: MOCK_TOKEN_RATES_STORE,
fetchTradesInfo: fetchTradesInfoStub,
fetchSwapsFeatureLiveness: fetchSwapsFeatureLivenessStub,
})
const currentEthersInstance = swapsController.ethersProvider
const onNetworkDidChange = networkController.on.getCall(0).args[1]
onNetworkDidChange(ROPSTEN_NETWORK_ID)
const newEthersInstance = swapsController.ethersProvider
assert.strictEqual(
currentEthersInstance,
newEthersInstance,
'Ethers provider should not be replaced',
)
})
})
describe('API', function () {
@ -233,14 +301,14 @@ describe('SwapsController', function () {
})
it('should set initial gas estimate', async function () {
const initialAggId = TEST_AGG_ID
const initialAggId = TEST_AGG_ID_1
const baseGasEstimate = 10
const { maxGas, estimatedRefund } = MOCK_QUOTES[TEST_AGG_ID]
const { maxGas, estimatedRefund } = getMockQuotes()[TEST_AGG_ID_1]
const { swapsState } = swapsController.store.getState()
// Set mock quotes in order to have data for the test agg
swapsController.store.updateState({
swapsState: { ...swapsState, quotes: MOCK_QUOTES },
swapsState: { ...swapsState, quotes: getMockQuotes() },
})
await swapsController.setInitialGasEstimate(
@ -272,6 +340,19 @@ describe('SwapsController', function () {
})
})
describe('_findTopQuoteAndCalculateSavings', function () {
it('returns empty object if passed undefined or empty object', async function () {
assert.deepStrictEqual(
await swapsController._findTopQuoteAndCalculateSavings(),
{},
)
assert.deepStrictEqual(
await swapsController._findTopQuoteAndCalculateSavings({}),
{},
)
})
})
describe('fetchAndSetQuotes', function () {
it('returns null if fetchParams is not provided', async function () {
const quotes = await swapsController.fetchAndSetQuotes(undefined)
@ -279,7 +360,7 @@ describe('SwapsController', function () {
})
it('calls fetchTradesInfo with the given fetchParams and returns the correct quotes', async function () {
fetchTradesInfoStub.resolves(MOCK_QUOTES)
fetchTradesInfoStub.resolves(getMockQuotes())
// Make it so approval is not required
sandbox
@ -291,8 +372,8 @@ describe('SwapsController', function () {
MOCK_FETCH_METADATA,
)
assert.deepStrictEqual(newQuotes[TEST_AGG_ID], {
...MOCK_QUOTES[TEST_AGG_ID],
assert.deepStrictEqual(newQuotes[TEST_AGG_ID_BEST], {
...getMockQuotes()[TEST_AGG_ID_BEST],
sourceTokenInfo: undefined,
destinationTokenInfo: {
symbol: 'FOO',
@ -301,7 +382,12 @@ describe('SwapsController', function () {
isBestQuote: true,
// TODO: find a way to calculate these values dynamically
gasEstimate: 2000000,
gasEstimateWithRefund: '8cd8e',
gasEstimateWithRefund: 'b8cae',
savings: {
fee: '0',
performance: '6',
total: '6',
},
})
assert.strictEqual(
@ -311,7 +397,7 @@ describe('SwapsController', function () {
})
it('performs the allowance check', async function () {
fetchTradesInfoStub.resolves(MOCK_QUOTES)
fetchTradesInfoStub.resolves(getMockQuotes())
// Make it so approval is not required
const allowanceStub = sandbox
@ -358,7 +444,7 @@ describe('SwapsController', function () {
})
it('marks the best quote', async function () {
fetchTradesInfoStub.resolves(MOCK_QUOTES)
fetchTradesInfoStub.resolves(getMockQuotes())
// Make it so approval is not required
sandbox
@ -370,7 +456,7 @@ describe('SwapsController', function () {
MOCK_FETCH_METADATA,
)
assert.strictEqual(topAggId, TEST_AGG_ID)
assert.strictEqual(topAggId, TEST_AGG_ID_BEST)
assert.strictEqual(newQuotes[topAggId].isBestQuote, true)
})
@ -379,15 +465,15 @@ describe('SwapsController', function () {
// Clone the existing mock quote and increase destination amount
const bestQuote = {
...MOCK_QUOTES[TEST_AGG_ID],
...getMockQuotes()[TEST_AGG_ID_1],
aggregator: bestAggId,
destinationAmount: ethers.BigNumber.from(
MOCK_QUOTES[TEST_AGG_ID].destinationAmount,
getMockQuotes()[TEST_AGG_ID_1].destinationAmount,
)
.add(1)
.add((100e18).toString())
.toString(),
}
const quotes = { ...MOCK_QUOTES, [bestAggId]: bestQuote }
const quotes = { ...getMockQuotes(), [bestAggId]: bestQuote }
fetchTradesInfoStub.resolves(quotes)
// Make it so approval is not required
@ -405,7 +491,7 @@ describe('SwapsController', function () {
})
it('does not mark as best quote if no conversion rate exists for destination token', async function () {
fetchTradesInfoStub.resolves(MOCK_QUOTES)
fetchTradesInfoStub.resolves(getMockQuotes())
// Make it so approval is not required
sandbox
@ -762,4 +848,150 @@ describe('SwapsController', function () {
})
})
})
describe('utils', function () {
describe('getMedian', function () {
const { getMedian } = utils
it('calculates median correctly with uneven sample', function () {
const values = [3, 2, 6].map((value) => new BigNumber(value))
const median = getMedian(values)
assert.strictEqual(
median.toNumber(), 3,
'should have returned correct median',
)
})
it('calculates median correctly with even sample', function () {
const values = [3, 2, 2, 6].map((value) => new BigNumber(value))
const median = getMedian(values)
assert.strictEqual(
median.toNumber(), 2.5,
'should have returned correct median',
)
})
it('throws on empty or non-array sample', function () {
assert.throws(
() => getMedian([]),
'should throw on empty array',
)
assert.throws(
() => getMedian(),
'should throw on non-array param',
)
assert.throws(
() => getMedian({}),
'should throw on non-array param',
)
})
})
})
})
function getMockQuotes () {
return {
[TEST_AGG_ID_1]: {
'trade': {
'from': '0xe18035bf8712672935fdb4e5e431b1a0183d2dfc',
'value': '0x0',
'gas': '0x61a80', // 4e5
'to': '0x881D40237659C251811CEC9c364ef91dC08D300C',
},
'sourceAmount': '10000000000000000000', // 10e18
'destinationAmount': '20000000000000000000', // 20e18
'error': null,
'sourceToken': '0x6b175474e89094c44da98b954eedeac495271d0f',
'destinationToken': '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
'approvalNeeded': null,
'maxGas': 600000,
'averageGas': 120000,
'estimatedRefund': 80000,
'fetchTime': 607,
'aggregator': TEST_AGG_ID_1,
'aggType': 'AGG',
'slippage': 2,
'sourceTokenInfo': {
'address': '0x6b175474e89094c44da98b954eedeac495271d0f',
'symbol': 'DAI',
'decimals': 18,
'iconUrl': 'https://foo.bar/logo.png',
},
'destinationTokenInfo': {
'address': '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
'symbol': 'USDC',
'decimals': 18,
},
},
[TEST_AGG_ID_BEST]: {
'trade': {
'from': '0xe18035bf8712672935fdb4e5e431b1a0183d2dfc',
'value': '0x0',
'gas': '0x61a80',
'to': '0x881D40237659C251811CEC9c364ef91dC08D300C',
},
'sourceAmount': '10000000000000000000',
'destinationAmount': '25000000000000000000', // 25e18
'error': null,
'sourceToken': '0x6b175474e89094c44da98b954eedeac495271d0f',
'destinationToken': '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
'approvalNeeded': null,
'maxGas': 1100000,
'averageGas': 411000,
'estimatedRefund': 343090,
'fetchTime': 1003,
'aggregator': TEST_AGG_ID_BEST,
'aggType': 'AGG',
'slippage': 2,
'sourceTokenInfo': {
'address': '0x6b175474e89094c44da98b954eedeac495271d0f',
'symbol': 'DAI',
'decimals': 18,
'iconUrl': 'https://foo.bar/logo.png',
},
'destinationTokenInfo': {
'address': '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
'symbol': 'USDC',
'decimals': 18,
},
},
[TEST_AGG_ID_2]: {
'trade': {
'from': '0xe18035bf8712672935fdb4e5e431b1a0183d2dfc',
'value': '0x0',
'gas': '0x61a80',
'to': '0x881D40237659C251811CEC9c364ef91dC08D300C',
},
'sourceAmount': '10000000000000000000',
'destinationAmount': '22000000000000000000', // 22e18
'error': null,
'sourceToken': '0x6b175474e89094c44da98b954eedeac495271d0f',
'destinationToken': '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
'approvalNeeded': null,
'maxGas': 368000,
'averageGas': 197000,
'estimatedRefund': 18205,
'fetchTime': 1354,
'aggregator': TEST_AGG_ID_2,
'aggType': 'AGG',
'slippage': 2,
'sourceTokenInfo': {
'address': '0x6b175474e89094c44da98b954eedeac495271d0f',
'symbol': 'DAI',
'decimals': 18,
'iconUrl': 'https://foo.bar/logo.png',
},
'destinationTokenInfo': {
'address': '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
'symbol': 'USDC',
'decimals': 18,
},
},
}
}

View File

@ -19,7 +19,7 @@ export default class ConnectedStatusIndicator extends Component {
static defaultProps = {
status: STATUS_NOT_CONNECTED,
onClick: null,
onClick: undefined,
}
renderStatusCircle = () => {

View File

@ -53,7 +53,7 @@ export default class GasModalPageContainer extends Component {
customTotalSupplement: PropTypes.string,
isSwap: PropTypes.bool,
value: PropTypes.string,
conversionRate: PropTypes.string,
conversionRate: PropTypes.number,
minimumGasLimit: PropTypes.number.isRequired,
}

View File

@ -124,7 +124,7 @@ export function setTickPosition (axis, n, newPosition, secondNewPosition) {
.style('visibility', 'visible')
}
/* eslint-disable babel/no-invalid-this */
/* eslint-disable @babel/no-invalid-this */
export function appendOrUpdateCircle ({ data, itemIndex, cx, cy, cssId, appendOnly }) {
const circle = this.main
.select(`.c3-selected-circles${this.getTargetSelectorSuffix(data.id)}`)
@ -145,7 +145,7 @@ export function appendOrUpdateCircle ({ data, itemIndex, cx, cy, cssId, appendOn
.attr('cy', cy)
}
}
/* eslint-enable babel/no-invalid-this */
/* eslint-enable @babel/no-invalid-this */
export function setSelectedCircle ({
chart,

View File

@ -18,6 +18,17 @@ class SelectedAccount extends Component {
selectedIdentity: PropTypes.object.isRequired,
}
componentDidMount () {
this.copyTimeout = null
}
componentWillUnmount () {
if (this.copyTimeout) {
clearTimeout(this.copyTimeout)
this.copyTimeout = null
}
}
render () {
const { t } = this.context
const { selectedIdentity } = this.props
@ -34,7 +45,7 @@ class SelectedAccount extends Component {
className="selected-account__clickable"
onClick={() => {
this.setState({ copied: true })
setTimeout(() => this.setState({ copied: false }), 3000)
this.copyTimeout = setTimeout(() => this.setState({ copied: false }), 3000)
copyToClipboard(checksummedAddress)
}}
>

View File

@ -36,7 +36,7 @@ export default function InfoTooltip ({
}
InfoTooltip.propTypes = {
contentText: PropTypes.string,
contentText: PropTypes.node,
position: PropTypes.oneOf(['top', 'left', 'bottom', 'right']),
wide: PropTypes.bool,
containerClassName: PropTypes.string,

View File

@ -16,18 +16,18 @@ class LoadingScreen extends Component {
renderMessage () {
const { loadingMessage } = this.props
if (isValidElement(loadingMessage)) {
return loadingMessage
if (!loadingMessage) {
return null
}
return loadingMessage
? <span>{loadingMessage}</span>
: null
return isValidElement(loadingMessage) ? loadingMessage : <span>{loadingMessage}</span>
}
render () {
return (
<div className="loading-overlay">
{this.props.header && this.props.header}
{this.props.header}
<div className="loading-overlay__container">
{this.props.showLoadingSpinner && <Spinner color="#F7C06C" className="loading-overlay__spinner" />}
{this.renderMessage()}

View File

@ -59,6 +59,7 @@ function QrCodeView (props) {
/>
<ReadOnlyInput
wrapperClass="ellip-address-wrapper"
autoFocus
value={checksumAddress(data)}
/>
</div>

View File

@ -9,6 +9,7 @@ export default function ReadOnlyInput (props) {
value,
textarea,
onClick,
autoFocus = false,
} = props
const InputType = textarea ? 'textarea' : 'input'
@ -21,6 +22,7 @@ export default function ReadOnlyInput (props) {
readOnly
onFocus={(event) => event.target.select()}
onClick={onClick}
autoFocus={autoFocus}
/>
</div>
)
@ -32,4 +34,5 @@ ReadOnlyInput.propTypes = {
value: PropTypes.string,
textarea: PropTypes.bool,
onClick: PropTypes.func,
autoFocus: PropTypes.bool,
}

View File

@ -1,4 +1,4 @@
import React, { Component, createContext, useEffect, useCallback, useState } from 'react'
import React, { Component, createContext, useEffect, useCallback, useState, useMemo } from 'react'
import { useSelector } from 'react-redux'
import PropTypes from 'prop-types'
import { useHistory } from 'react-router-dom'
@ -9,16 +9,15 @@ import {
getAccountType,
getNumberOfAccounts,
getNumberOfTokens,
getCurrentChainId,
} from '../selectors/selectors'
import { getSendToken } from '../selectors/send'
import {
txDataSelector,
} from '../selectors/confirm-transaction'
import { getEnvironmentType } from '../../../app/scripts/lib/util'
import {
sendMetaMetricsEvent,
sendCountIsTrackable,
} from '../helpers/utils/metametrics.util'
import { getTrackMetaMetricsEvent } from '../../../shared/modules/metametrics'
import { getCurrentLocale } from '../ducks/metamask/metamask'
export const MetaMetricsContext = createContext(() => {
captureException(
@ -30,6 +29,8 @@ export function MetaMetricsProvider ({ children }) {
const txData = useSelector(txDataSelector) || {}
const network = useSelector(getCurrentNetworkId)
const environmentType = getEnvironmentType()
const chainId = useSelector(getCurrentChainId)
const locale = useSelector(getCurrentLocale)
const activeCurrency = useSelector(getSendToken)?.symbol
const accountType = useSelector(getAccountType)
const confirmTransactionOrigin = txData.origin
@ -44,7 +45,7 @@ export function MetaMetricsProvider ({ children }) {
previousPath: '',
}))
const { previousPath, currentPath } = state
const { currentPath } = state
useEffect(() => {
const unlisten = history.listen(() => setState((prevState) => ({
@ -55,45 +56,63 @@ export function MetaMetricsProvider ({ children }) {
return unlisten
}, [history])
/**
* track a metametrics event
*
* @param {import('../../../shared/modules/metametrics').MetaMetricsEventPayload} - payload for event
* @returns undefined
*/
const trackEvent = useMemo(() => {
const referrer = confirmTransactionOrigin ? { url: confirmTransactionOrigin } : undefined
const page = {
path: currentPath,
}
return getTrackMetaMetricsEvent(global.platform.getVersion(), () => ({
context: {
referrer,
page,
},
environmentType,
locale: locale.replace('_', '-'),
network,
chainId,
participateInMetaMetrics,
metaMetricsId,
metaMetricsSendCount,
}))
}, [network, chainId, locale, environmentType, participateInMetaMetrics, currentPath, confirmTransactionOrigin, metaMetricsId, metaMetricsSendCount])
const metricsEvent = useCallback((config = {}, overrides = {}) => {
const { eventOpts = {} } = config
const { name = '' } = eventOpts
const { currentPath: overrideCurrentPath = '' } = overrides
const isSendFlow = Boolean(name.match(/^send|^confirm/u) || overrideCurrentPath.match(/send|confirm/u))
if (participateInMetaMetrics || config.isOptIn) {
return sendMetaMetricsEvent({
network,
environmentType,
activeCurrency,
accountType,
confirmTransactionOrigin,
metaMetricsId,
numberOfTokens,
numberOfAccounts,
version: global.platform.getVersion(),
...config,
previousPath,
currentPath,
excludeMetaMetricsId: isSendFlow && !sendCountIsTrackable(metaMetricsSendCount + 1),
...overrides,
})
}
return undefined
return trackEvent({
event: eventOpts.name,
category: eventOpts.category,
isOptIn: config.isOptIn,
excludeMetaMetricsId: eventOpts.excludeMetaMetricsId ?? overrides.excludeMetaMetricsId ?? false,
metaMetricsId: config.metaMetricsId,
matomoEvent: true,
properties: {
action: eventOpts.action,
number_of_tokens: numberOfTokens,
number_of_accounts: numberOfAccounts,
active_currency: activeCurrency,
account_type: accountType,
is_new_visit: config.is_new_visit,
// the properties coming from this key will not match our standards for
// snake_case on properties, and they may be redundant and/or not in the
// proper location (origin not as a referrer, for example). This is a temporary
// solution to not lose data, and the entire event system will be reworked in
// forthcoming PRs to deprecate the old Matomo events in favor of the new schema.
...config.customVariables,
},
})
}, [
network,
environmentType,
activeCurrency,
accountType,
confirmTransactionOrigin,
participateInMetaMetrics,
previousPath,
metaMetricsId,
activeCurrency,
numberOfTokens,
numberOfAccounts,
currentPath,
metaMetricsSendCount,
trackEvent,
])
return (

View File

@ -3,26 +3,19 @@
* MetaMetrics is our own brand, and should remain aptly named regardless of the underlying
* metrics system. This file implements Segment analytics tracking.
*/
import React, { useRef, Component, createContext, useEffect, useCallback } from 'react'
import React, { useRef, Component, createContext, useEffect, useMemo } from 'react'
import { useSelector } from 'react-redux'
import PropTypes from 'prop-types'
import { useLocation, matchPath, useRouteMatch } from 'react-router-dom'
import { captureException, captureMessage } from '@sentry/browser'
import { omit } from 'lodash'
import {
getCurrentNetworkId,
} from '../selectors/selectors'
import { getEnvironmentType } from '../../../app/scripts/lib/util'
import {
sendCountIsTrackable,
segment,
METAMETRICS_ANONYMOUS_ID,
} from '../helpers/utils/metametrics.util'
import { PATH_NAME_MAP } from '../helpers/constants/routes'
import { getCurrentLocale } from '../ducks/metamask/metamask'
import { txDataSelector } from '../selectors'
import { getCurrentChainId, getMetricsNetworkIdentifier, txDataSelector } from '../selectors'
import { getTrackMetaMetricsEvent, METAMETRICS_ANONYMOUS_ID, segment } from '../../../shared/modules/metametrics'
export const MetaMetricsContext = createContext(() => {
captureException(
@ -34,7 +27,6 @@ const PATHS_TO_CHECK = Object.keys(PATH_NAME_MAP)
function useSegmentContext () {
const match = useRouteMatch({ path: PATHS_TO_CHECK, exact: true, strict: true })
const locale = useSelector(getCurrentLocale)
const txData = useSelector(txDataSelector) || {}
const confirmTransactionOrigin = txData.origin
@ -42,11 +34,6 @@ function useSegmentContext () {
url: confirmTransactionOrigin,
} : undefined
let version = global.platform.getVersion()
if (process.env.METAMASK_ENVIRONMENT !== 'production') {
version = `${version}-${process.env.METAMASK_ENVIRONMENT}`
}
const page = match ? {
path: match.path,
title: PATH_NAME_MAP[match.path],
@ -54,24 +41,39 @@ function useSegmentContext () {
} : undefined
return {
app: {
version,
name: 'MetaMask Extension',
},
locale: locale.replace('_', '-'),
page,
referrer,
userAgent: window.navigator.userAgent,
}
}
export function MetaMetricsProvider ({ children }) {
const network = useSelector(getCurrentNetworkId)
const metaMetricsId = useSelector((state) => state.metamask.metaMetricsId)
const participateInMetaMetrics = useSelector((state) => state.metamask.participateInMetaMetrics)
const metaMetricsSendCount = useSelector((state) => state.metamask.metaMetricsSendCount)
const locale = useSelector(getCurrentLocale)
const location = useLocation()
const context = useSegmentContext()
const network = useSelector(getMetricsNetworkIdentifier)
const chainId = useSelector(getCurrentChainId)
/**
* track a metametrics event
*
* @param {import('../../../shared/modules/metametrics').MetaMetricsEventPayload} - payload for event
* @returns undefined
*/
const trackEvent = useMemo(() => {
return getTrackMetaMetricsEvent(global.platform.getVersion(), () => ({
context,
locale: locale.replace('_', '-'),
environmentType: getEnvironmentType(),
chainId,
network,
participateInMetaMetrics,
metaMetricsId,
metaMetricsSendCount,
}))
}, [network, participateInMetaMetrics, locale, metaMetricsId, metaMetricsSendCount, chainId, context])
// Used to prevent double tracking page calls
const previousMatch = useRef()
@ -131,72 +133,6 @@ export function MetaMetricsProvider ({ children }) {
}
}, [location, context, network, metaMetricsId, participateInMetaMetrics])
/**
* track a metametrics event using segment
* e.g metricsEvent({ event: 'Unlocked MetaMask', category: 'Navigation' })
*
* @param {object} config - configuration object for the event to track
* @param {string} config.event - event name to track
* @param {string} config.category - category to associate event to
* @param {boolean} [config.isOptIn] - happened during opt in/out workflow
* @param {object} [config.properties] - object of custom values to track, snake_case
* @param {number} [config.revenue] - amount of currency that event creates in revenue for MetaMask
* @param {string} [config.currency] - ISO 4127 format currency for events with revenue, defaults to US dollars
* @param {number} [config.value] - Abstract "value" that this event has for MetaMask.
* @return {undefined}
*/
const trackEvent = useCallback(
(config = {}) => {
const { event, category, isOptIn = false, properties = {}, revenue, value, currency } = config
if (!event) {
// Event name is required for tracking an event
throw new Error('MetaMetrics trackEvent function must be provided a payload with an "event" key')
}
if (!category) {
// Category must be supplied for every tracking event
throw new Error('MetaMetrics events must be provided a category')
}
const environmentType = getEnvironmentType()
let excludeMetaMetricsId = config.excludeMetaMetricsId ?? false
// This is carried over from the old implementation, and will likely need
// to be updated to work with the new tracking plan. I think we should use
// a config setting for this instead of trying to match the event name
const isSendFlow = Boolean(event.match(/^send|^confirm/u))
if (isSendFlow && !sendCountIsTrackable(metaMetricsSendCount + 1)) {
excludeMetaMetricsId = true
}
const idTrait = excludeMetaMetricsId ? 'anonymousId' : 'userId'
const idValue = excludeMetaMetricsId ? METAMETRICS_ANONYMOUS_ID : metaMetricsId
if (participateInMetaMetrics || isOptIn) {
segment.track({
[idTrait]: idValue,
event,
properties: {
...omit(properties, ['revenue', 'currency', 'value']),
revenue,
value,
currency,
category,
network,
environment_type: environmentType,
},
context,
})
}
return undefined
}, [
context,
network,
metaMetricsId,
metaMetricsSendCount,
participateInMetaMetrics,
],
)
return (
<MetaMetricsContext.Provider value={trackEvent}>
{children}

View File

@ -69,11 +69,22 @@ $fa-font-path: 'fonts/fontawesome';
$font-family: Euclid, Roboto, Helvetica, Arial, sans-serif;
$font-size-h1: 2.5rem;
$font-size-h2: 2rem;
$font-size-h3: 1.5rem;
$font-size-h4: 1.125rem;
$font-size-h5: 1rem;
$font-size-h6: 0.875rem;
$font-size-paragraph: 1rem;
$font-size-h7: 0.75rem;
$font-size-h8: 0.625rem;
$font-size-h9: 0.5rem;
// Typography
@mixin H1 {
font-style: normal;
font-weight: normal;
font-size: 2.5rem;
font-size: $font-size-h1;
font-family: $font-family;
line-height: 140%;
}
@ -82,7 +93,7 @@ $font-family: Euclid, Roboto, Helvetica, Arial, sans-serif;
@mixin H2 {
font-style: normal;
font-weight: normal;
font-size: 2rem;
font-size: $font-size-h2;
font-family: $font-family;
line-height: 140%;
}
@ -90,7 +101,7 @@ $font-family: Euclid, Roboto, Helvetica, Arial, sans-serif;
@mixin H3 {
font-style: normal;
font-weight: normal;
font-size: 1.5rem;
font-size: $font-size-h3;
font-family: $font-family;
line-height: 140%;
}
@ -98,7 +109,7 @@ $font-family: Euclid, Roboto, Helvetica, Arial, sans-serif;
@mixin H4 {
font-style: normal;
font-weight: normal;
font-size: 1.125rem;
font-size: $font-size-h4;
font-family: $font-family;
line-height: 140%;
}
@ -106,46 +117,41 @@ $font-family: Euclid, Roboto, Helvetica, Arial, sans-serif;
@mixin H5 {
font-style: normal;
font-weight: normal;
font-size: 1rem;
font-family: $font-family;
font-size: $font-size-h5;
line-height: 140%;
}
@mixin H6 {
font-style: normal;
font-weight: normal;
font-size: 0.875rem; // 14px @default
font-size: $font-size-h6; // 14px @default
line-height: 140%;
}
@mixin Paragraph {
font-style: normal;
font-weight: normal;
font-size: 1rem;
font-family: $font-family;
font-size: $font-size-paragraph;
line-height: 140%;
}
@mixin H7 {
font-style: normal;
font-weight: normal;
font-size: 0.75rem;
font-family: $font-family;
font-size: $font-size-h7;
line-height: 140%;
}
@mixin H8 {
font-style: normal;
font-weight: normal;
font-size: 0.625rem;
font-family: $font-family;
font-size: $font-size-h8;
line-height: 140%;
}
@mixin H9 {
font-style: normal;
font-weight: normal;
font-size: 0.5rem;
font-family: $font-family;
font-size: $font-size-h9;
line-height: 140%;
}

View File

@ -23,7 +23,7 @@ import {
import { AWAITING_SWAP_ROUTE, BUILD_QUOTE_ROUTE, LOADING_QUOTES_ROUTE, SWAPS_ERROR_ROUTE, SWAPS_MAINTENANCE_ROUTE } from '../../helpers/constants/routes'
import { fetchSwapsFeatureLiveness } from '../../pages/swaps/swaps.util'
import { calcGasTotal } from '../../pages/send/send.utils'
import { decimalToHex, getValueFromWeiHex, hexMax, decGWEIToHexWEI, hexToDecimal } from '../../helpers/utils/conversions.util'
import { decimalToHex, getValueFromWeiHex, hexMax, decGWEIToHexWEI, hexToDecimal, hexWEIToDecGWEI } from '../../helpers/utils/conversions.util'
import { calcTokenAmount } from '../../helpers/utils/token-util'
import {
getFastPriceEstimateInHexWEI,
@ -36,6 +36,7 @@ import {
QUOTES_NOT_AVAILABLE_ERROR,
ETH_SWAPS_TOKEN_OBJECT,
SWAP_FAILED_ERROR,
SWAPS_FETCH_ORDER_CONFLICT,
} from '../../helpers/constants/swaps'
import { SWAP, SWAP_APPROVAL } from '../../helpers/constants/transactions'
import { fetchBasicGasAndTimeEstimates, fetchGasEstimates, resetCustomGasState } from '../gas/gas.duck'
@ -50,7 +51,6 @@ const initialState = {
quotesFetchStartTime: null,
topAssets: {},
toToken: null,
metamaskFeeAmount: null,
}
const slice = createSlice({
@ -89,9 +89,6 @@ const slice = createSlice({
setToToken: (state, action) => {
state.toToken = action.payload
},
setMetamaskFeeAmount: (state, action) => {
state.metamaskFeeAmount = action.payload
},
},
})
@ -111,8 +108,6 @@ export const getTopAssets = (state) => state.swaps.topAssets
export const getToToken = (state) => state.swaps.toToken
export const getMetaMaskFeeAmount = (state) => state.swaps.metamaskFeeAmount
export const getFetchingQuotes = (state) => state.swaps.fetchingQuotes
export const getQuotesFetchStartTime = (state) => state.swaps.quotesFetchStartTime
@ -200,7 +195,6 @@ const {
setQuotesFetchStartTime,
setTopAssets,
setToToken,
setMetamaskFeeAmount,
} = actions
export {
@ -212,7 +206,6 @@ export {
setQuotesFetchStartTime as setSwapQuotesFetchStartTime,
setTopAssets,
setToToken as setSwapToToken,
setMetamaskFeeAmount,
}
export const navigateBackToBuildQuote = (history) => {
@ -407,6 +400,13 @@ export const fetchQuotesAndSetQuoteState = (history, inputValue, maxSlippage, me
dispatch(setInitialGasEstimate(selectedAggId))
}
} catch (e) {
// A newer swap request is running, so simply bail and let the newer request respond
if (e.message === SWAPS_FETCH_ORDER_CONFLICT) {
log.debug(`Swap fetch order conflict detected; ignoring older request`)
return
}
// TODO: Check for any errors we should expect to occur in production, and report others to Sentry
log.error(`Error fetching quotes: `, e)
dispatch(setSwapsErrorKey(ERROR_FETCHING_QUOTES))
}
@ -462,6 +462,7 @@ export const signAndSendTransactions = (history, metaMetricsEvent) => {
conversionRate,
numberOfDecimals: 6,
})
const swapMetaData = {
token_from: sourceTokenInfo.symbol,
token_from_amount: String(swapTokenValue),
@ -474,7 +475,10 @@ export const signAndSendTransactions = (history, metaMetricsEvent) => {
other_quote_selected: usedQuote.aggregator !== getTopQuote(state)?.aggregator,
other_quote_selected_source: usedQuote.aggregator === getTopQuote(state)?.aggregator ? '' : usedQuote.aggregator,
gas_fees: formatCurrency(gasEstimateTotalInEth, 'usd')?.slice(1),
estimated_gas: estimatedGasLimit.toString(16),
estimated_gas: estimatedGasLimit.toString(10),
suggested_gas_price: hexWEIToDecGWEI(usedGasPrice),
used_gas_price: hexWEIToDecGWEI(fastGasEstimate),
average_savings: usedQuote.savings?.performance,
}
const metaMetricsConfig = {

View File

@ -14,6 +14,7 @@ export const SWAP_FAILED_ERROR = 'swap-failed-error'
export const ERROR_FETCHING_QUOTES = 'error-fetching-quotes'
export const QUOTES_NOT_AVAILABLE_ERROR = 'quotes-not-avilable'
export const OFFLINE_FOR_MAINTENANCE = 'offline-for-maintenance'
export const SWAPS_FETCH_ORDER_CONFLICT = 'swaps-fetch-order-conflict'
// A gas value for ERC20 approve calls that should be sufficient for all ERC20 approve implementations
export const DEFAULT_ERC20_APPROVE_GAS = '0x1d4c0'

View File

@ -2,40 +2,42 @@ import assert from 'assert'
import { ETH } from '../constants/common'
import * as utils from './conversions.util'
describe('getWeiHexFromDecimalValue', function () {
it('should correctly convert 0 in ETH', function () {
const weiValue = utils.getWeiHexFromDecimalValue({
value: '0',
fromCurrency: ETH,
fromDenomination: ETH,
describe('conversion utils', function () {
describe('getWeiHexFromDecimalValue', function () {
it('should correctly convert 0 in ETH', function () {
const weiValue = utils.getWeiHexFromDecimalValue({
value: '0',
fromCurrency: ETH,
fromDenomination: ETH,
})
assert.equal(weiValue, '0')
})
})
describe('decETHToDecWEI', function () {
it('should correctly convert 1 ETH to WEI', function () {
const weiValue = utils.decETHToDecWEI('1')
assert.equal(weiValue, '1000000000000000000')
})
it('should correctly convert 0.000000000000000001 ETH to WEI', function () {
const weiValue = utils.decETHToDecWEI('0.000000000000000001')
assert.equal(weiValue, '1')
})
it('should correctly convert 1000000.000000000000000001 ETH to WEI', function () {
const weiValue = utils.decETHToDecWEI('1000000.000000000000000001')
assert.equal(weiValue, '1000000000000000000000001')
})
it('should correctly convert 9876.543210 ETH to WEI', function () {
const weiValue = utils.decETHToDecWEI('9876.543210')
assert.equal(weiValue, '9876543210000000000000')
})
it('should correctly convert 1.0000000000000000 ETH to WEI', function () {
const weiValue = utils.decETHToDecWEI('1.0000000000000000')
assert.equal(weiValue, '1000000000000000000')
})
assert.equal(weiValue, '0')
})
})
describe('decETHToDecWEI', function () {
it('should correctly convert 1 ETH to WEI', function () {
const weiValue = utils.decETHToDecWEI('1')
assert.equal(weiValue, '1000000000000000000')
})
it('should correctly convert 0.000000000000000001 ETH to WEI', function () {
const weiValue = utils.decETHToDecWEI('0.000000000000000001')
assert.equal(weiValue, '1')
})
it('should correctly convert 1000000.000000000000000001 ETH to WEI', function () {
const weiValue = utils.decETHToDecWEI('1000000.000000000000000001')
assert.equal(weiValue, '1000000000000000000000001')
})
it('should correctly convert 9876.543210 ETH to WEI', function () {
const weiValue = utils.decETHToDecWEI('9876.543210')
assert.equal(weiValue, '9876543210000000000000')
})
it('should correctly convert 1.0000000000000000 ETH to WEI', function () {
const weiValue = utils.decETHToDecWEI('1.0000000000000000')
assert.equal(weiValue, '1000000000000000000')
})
})

View File

@ -1,259 +0,0 @@
/* eslint camelcase: 0 */
import ethUtil from 'ethereumjs-util'
import Analytics from 'analytics-node'
const inDevelopment = process.env.METAMASK_DEBUG || process.env.IN_TEST
let projectId = process.env.METAMETRICS_PROJECT_ID
if (!projectId) {
projectId = inDevelopment ? 1 : 2
}
const METAMETRICS_BASE_URL = 'https://chromeextensionmm.innocraft.cloud/piwik.php'
const METAMETRICS_REQUIRED_PARAMS = `?idsite=${projectId}&rec=1&apiv=1`
const METAMETRICS_BASE_FULL = METAMETRICS_BASE_URL + METAMETRICS_REQUIRED_PARAMS
const METAMETRICS_TRACKING_URL = inDevelopment
? 'http://www.metamask.io/metametrics'
: 'http://www.metamask.io/metametrics-prod'
/** ***************Custom variables*************** **/
// Custom variable declarations
const METAMETRICS_CUSTOM_GAS_LIMIT_CHANGE = 'gasLimitChange'
const METAMETRICS_CUSTOM_GAS_PRICE_CHANGE = 'gasPriceChange'
const METAMETRICS_CUSTOM_FUNCTION_TYPE = 'functionType'
const METAMETRICS_CUSTOM_RECIPIENT_KNOWN = 'recipientKnown'
const METAMETRICS_REQUEST_ORIGIN = 'origin'
const METAMETRICS_CUSTOM_FROM_NETWORK = 'fromNetwork'
const METAMETRICS_CUSTOM_TO_NETWORK = 'toNetwork'
const METAMETRICS_CUSTOM_ERROR_FIELD = 'errorField'
const METAMETRICS_CUSTOM_ERROR_MESSAGE = 'errorMessage'
const METAMETRICS_CUSTOM_RPC_NETWORK_ID = 'networkId'
const METAMETRICS_CUSTOM_RPC_CHAIN_ID = 'chainId'
const METAMETRICS_CUSTOM_GAS_CHANGED = 'gasChanged'
const METAMETRICS_CUSTOM_ASSET_SELECTED = 'assetSelected'
const customVariableNameIdMap = {
[METAMETRICS_CUSTOM_FUNCTION_TYPE]: 1,
[METAMETRICS_CUSTOM_RECIPIENT_KNOWN]: 2,
[METAMETRICS_REQUEST_ORIGIN]: 3,
[METAMETRICS_CUSTOM_GAS_LIMIT_CHANGE]: 4,
[METAMETRICS_CUSTOM_GAS_PRICE_CHANGE]: 5,
[METAMETRICS_CUSTOM_FROM_NETWORK]: 1,
[METAMETRICS_CUSTOM_TO_NETWORK]: 2,
[METAMETRICS_CUSTOM_RPC_NETWORK_ID]: 1,
[METAMETRICS_CUSTOM_RPC_CHAIN_ID]: 2,
[METAMETRICS_CUSTOM_ERROR_FIELD]: 3,
[METAMETRICS_CUSTOM_ERROR_MESSAGE]: 4,
[METAMETRICS_CUSTOM_GAS_CHANGED]: 1,
[METAMETRICS_CUSTOM_ASSET_SELECTED]: 2,
}
/** ********************************************************** **/
const METAMETRICS_CUSTOM_NETWORK = 'network'
const METAMETRICS_CUSTOM_ENVIRONMENT_TYPE = 'environmentType'
const METAMETRICS_CUSTOM_ACTIVE_CURRENCY = 'activeCurrency'
const METAMETRICS_CUSTOM_ACCOUNT_TYPE = 'accountType'
const METAMETRICS_CUSTOM_NUMBER_OF_ACCOUNTS = 'numberOfAccounts'
const METAMETRICS_CUSTOM_NUMBER_OF_TOKENS = 'numberOfTokens'
const METAMETRICS_CUSTOM_VERSION = 'version'
const customDimensionsNameIdMap = {
[METAMETRICS_CUSTOM_NETWORK]: 5,
[METAMETRICS_CUSTOM_ENVIRONMENT_TYPE]: 6,
[METAMETRICS_CUSTOM_ACTIVE_CURRENCY]: 7,
[METAMETRICS_CUSTOM_ACCOUNT_TYPE]: 8,
[METAMETRICS_CUSTOM_NUMBER_OF_ACCOUNTS]: 9,
[METAMETRICS_CUSTOM_NUMBER_OF_TOKENS]: 10,
[METAMETRICS_CUSTOM_VERSION]: 11,
}
export const METAMETRICS_ANONYMOUS_ID = '0x0000000000000000'
function composeUrlRefParamAddition (previousPath, confirmTransactionOrigin) {
const externalOrigin = confirmTransactionOrigin && confirmTransactionOrigin !== 'metamask'
return `&urlref=${externalOrigin ? 'EXTERNAL' : encodeURIComponent(`${METAMETRICS_TRACKING_URL}${previousPath}`)}`
}
// composes query params of the form &dimension[0-999]=[value]
function composeCustomDimensionParamAddition (customDimensions) {
const customDimensionParamStrings = Object.keys(customDimensions).reduce((acc, name) => {
return [...acc, `dimension${customDimensionsNameIdMap[name]}=${customDimensions[name]}`]
}, [])
return `&${customDimensionParamStrings.join('&')}`
}
// composes query params in form: &cvar={[id]:[[name],[value]]}
// Example: &cvar={"1":["OS","iphone 5.0"],"2":["Matomo Mobile Version","1.6.2"],"3":["Locale","en::en"],"4":["Num Accounts","2"]}
function composeCustomVarParamAddition (customVariables) {
const customVariableIdValuePairs = Object.keys(customVariables).reduce((acc, name) => {
return {
[customVariableNameIdMap[name]]: [name, customVariables[name]],
...acc,
}
}, {})
return `&cvar=${encodeURIComponent(JSON.stringify(customVariableIdValuePairs))}`
}
function composeParamAddition (paramValue, paramName) {
return paramValue !== 0 && !paramValue
? ''
: `&${paramName}=${paramValue}`
}
/**
* @name composeUrl
* @param {Object} config - configuration object for composing the metametrics url
* @property {object} config.eventOpts Object containing event category, action and name descriptors
* @property {object} config.customVariables Object containing custom properties with values relevant to a specific event
* @property {object} config.pageOpts Objects containing information about a page/route the event is dispatched from
* @property {number} config.network The selected network of the user when the event occurs
* @property {string} config.environmentType The "environment" the user is using the app from: 'popup', 'notification' or 'fullscreen'
* @property {string} config.activeCurrency The current the user has select as their primary currency at the time of the event
* @property {string} config.accountType The account type being used at the time of the event: 'hardware', 'imported' or 'default'
* @property {number} config.numberOfTokens The number of tokens that the user has added at the time of the event
* @property {number} config.numberOfAccounts The number of accounts the user has added at the time of the event
* @property {string} config.version The current version of the MetaMask extension
* @property {string} config.previousPath The pathname of the URL the user was on prior to the URL they are on at the time of the event
* @property {string} config.currentPath The pathname of the URL the user is on at the time of the event
* @property {string} config.metaMetricsId A random id assigned to a user at the time of opting in to metametrics. A hexadecimal number
* @property {string} config.confirmTransactionOrigin The origin on a transaction
* @property {boolean} config.excludeMetaMetricsId Whether or not the tracked event data should be associated with a metametrics id
* @property {boolean} config.isNewVisit Whether or not the event should be tracked as a new visit/user sessions
* @returns {string} - Returns a url to be passed to fetch to make the appropriate request to matomo.
* Example: https://chromeextensionmm.innocraft.cloud/piwik.php?idsite=1&rec=1&apiv=1&e_c=Navigation&e_a=Home&e_n=Clicked%20Send:%20Eth&urlref=http%3A%2F%2Fwww.metamask.io%2Fmetametrics%2Fhome.html%23send&dimension5=3&dimension6=fullscreen&dimension7=ETH&dimension8=default&dimension9=0&dimension10=3&url=http%3A%2F%2Fwww.metamask.io%2Fmetametrics%2Fhome.html%23&_id=49c10aff19795e9a&rand=7906028754863992&pv_id=53acad&uid=49c1
*/
function composeUrl (config) {
const {
eventOpts = {},
customVariables = '',
pageOpts = '',
network,
environmentType,
activeCurrency,
accountType,
numberOfTokens,
numberOfAccounts,
version,
previousPath = '',
currentPath,
metaMetricsId,
confirmTransactionOrigin,
excludeMetaMetricsId,
isNewVisit,
} = config
const base = METAMETRICS_BASE_FULL
const e_c = composeParamAddition(eventOpts.category, 'e_c')
const e_a = composeParamAddition(eventOpts.action, 'e_a')
const e_n = composeParamAddition(eventOpts.name, 'e_n')
const new_visit = isNewVisit ? `&new_visit=1` : ''
const cvar = (customVariables && composeCustomVarParamAddition(customVariables)) || ''
const action_name = ''
const urlref = previousPath && composeUrlRefParamAddition(previousPath, confirmTransactionOrigin)
const dimensions = pageOpts.hideDimensions
? ''
: (
composeCustomDimensionParamAddition({
network,
environmentType,
activeCurrency,
accountType,
version,
numberOfTokens: (customVariables && customVariables.numberOfTokens) || numberOfTokens,
numberOfAccounts: (customVariables && customVariables.numberOfAccounts) || numberOfAccounts,
})
)
const url = currentPath ? `&url=${encodeURIComponent(`${METAMETRICS_TRACKING_URL}${currentPath}`)}` : ''
const _id = metaMetricsId && !excludeMetaMetricsId ? `&_id=${metaMetricsId.slice(2, 18)}` : ''
const rand = `&rand=${String(Math.random()).slice(2)}`
const pv_id = currentPath ? `&pv_id=${ethUtil.bufferToHex(ethUtil.sha3(currentPath)).slice(2, 8)}` : ''
let uid = ''
if (excludeMetaMetricsId) {
uid = '&uid=0000000000000000'
} else if (metaMetricsId) {
uid = `&uid=${metaMetricsId.slice(2, 18)}`
}
return [base, e_c, e_a, e_n, cvar, action_name, urlref, dimensions, url, _id, rand, pv_id, uid, new_visit].join('')
}
export function sendMetaMetricsEvent (config) {
return window.fetch(composeUrl(config), {
'headers': {},
'method': 'GET',
})
}
export function verifyUserPermission (config, props) {
const {
eventOpts = {},
} = config
const { userPermissionPreferences } = props
const {
allowAll,
allowNone,
allowSendMetrics,
} = userPermissionPreferences
if (allowNone) {
return false
} else if (allowAll) {
return true
} else if (allowSendMetrics && eventOpts.name === 'send') {
return true
}
return false
}
const trackableSendCounts = {
1: true,
10: true,
30: true,
50: true,
100: true,
250: true,
500: true,
1000: true,
2500: true,
5000: true,
10000: true,
25000: true,
}
export function sendCountIsTrackable (sendCount) {
return Boolean(trackableSendCounts[sendCount])
}
const flushAt = inDevelopment ? 1 : undefined
const segmentNoop = {
track () {
// noop
},
page () {
// noop
},
identify () {
// noop
},
}
// We do not want to track events on development builds unless specifically
// provided a SEGMENT_WRITE_KEY. This also holds true for test environments and
// E2E, which is handled in the build process by never providing the SEGMENT_WRITE_KEY
// which process.env.IN_TEST is true
export const segment = process.env.SEGMENT_WRITE_KEY
? new Analytics(process.env.SEGMENT_WRITE_KEY, { flushAt })
: segmentNoop

View File

@ -148,7 +148,7 @@ export function calcTokenValue (value, decimals) {
* @returns {string | undefined} A lowercase address string.
*/
export function getTokenAddressParam (tokenData = {}) {
const value = tokenData?.args?.['_to'] || tokenData?.args?.[0]
const value = tokenData?.args?._to || tokenData?.args?.[0]
return value?.toString().toLowerCase()
}
@ -160,7 +160,7 @@ export function getTokenAddressParam (tokenData = {}) {
* @returns {string | undefined} A decimal string value.
*/
export function getTokenValueParam (tokenData = {}) {
return tokenData?.args?.['_value']?.toString()
return tokenData?.args?._value?.toString()
}
export function getTokenValue (tokenParams = []) {

View File

@ -1,4 +1,4 @@
import punycode from 'punycode'
import punycode from 'punycode/punycode'
import abi from 'human-standard-token-abi'
import BigNumber from 'bignumber.js'
import ethUtil from 'ethereumjs-util'

View File

@ -44,6 +44,11 @@ export function useSwappedTokenValue (transactionGroup, currentAsset) {
decimals,
)
: transactionCategory === SWAP && primaryTransaction.swapTokenValue
const isNegative = typeof swapTokenValue === 'string'
? Math.sign(swapTokenValue) === -1
: false
const _swapTokenFiatAmount = useTokenFiatAmount(
address,
swapTokenValue || '',
@ -52,5 +57,5 @@ export function useSwappedTokenValue (transactionGroup, currentAsset) {
const swapTokenFiatAmount = (
swapTokenValue && isViewingReceivedTokenFromSwap && _swapTokenFiatAmount
)
return { swapTokenValue, swapTokenFiatAmount, isViewingReceivedTokenFromSwap }
return { swapTokenValue, swapTokenFiatAmount, isViewingReceivedTokenFromSwap, isNegative }
}

View File

@ -115,7 +115,7 @@ export function useTransactionDisplayData (transactionGroup) {
// The primary title of the Tx that will be displayed in the activity list
let title
const { swapTokenValue, swapTokenFiatAmount, isViewingReceivedTokenFromSwap } = useSwappedTokenValue(transactionGroup, currentAsset)
const { swapTokenValue, isNegative, swapTokenFiatAmount, isViewingReceivedTokenFromSwap } = useSwappedTokenValue(transactionGroup, currentAsset)
// There are seven types of transaction entries that are currently differentiated in the design
// 1. Signature request
@ -145,8 +145,13 @@ export function useTransactionDisplayData (transactionGroup) {
: initialTransaction.sourceTokenSymbol
primaryDisplayValue = swapTokenValue
secondaryDisplayValue = swapTokenFiatAmount
prefix = isViewingReceivedTokenFromSwap ? '+' : '-'
if (isNegative) {
prefix = ''
} else if (isViewingReceivedTokenFromSwap) {
prefix = '+'
} else {
prefix = '-'
}
} else if (transactionCategory === SWAP_APPROVAL) {
category = TRANSACTION_CATEGORY_APPROVAL
title = t('swapApproval', [primaryTransaction.sourceTokenSymbol])

View File

@ -56,7 +56,7 @@ export default class TokenSearch extends Component {
position="start"
style={{ marginRight: '12px' }}
>
<img src="images/search.svg" />
<img src="images/search.svg" width="17" height="17" />
</InputAdornment>
)
}

View File

@ -101,12 +101,6 @@ export default class Routes extends Component {
this.props.history.listen((locationObj, action) => {
if (action === 'PUSH') {
pageChanged(locationObj.pathname)
this.context.metricsEvent({}, {
currentPath: locationObj.pathname,
pageOpts: {
hideDimensions: true,
},
})
}
})
}

View File

@ -33,6 +33,7 @@ export default class EnsInput extends Component {
onReset: PropTypes.func,
onValidAddressTyped: PropTypes.func,
contact: PropTypes.object,
value: PropTypes.string,
}
state = {
@ -53,20 +54,33 @@ export default class EnsInput extends Component {
}
}
// If an address is sent without a nickname, meaning not from ENS or from
// the user's own accounts, a default of a one-space string is used.
componentDidUpdate (prevProps) {
const {
input,
} = this.state
const {
network,
value,
} = this.props
let newValue
// Set the value of our input based on QR code provided by parent
const newProvidedValue = input !== value && prevProps.value !== value
if (newProvidedValue) {
newValue = value
}
if (prevProps.network !== network) {
const provider = global.ethereumProvider
this.ens = new ENS({ provider, network })
this.onChange({ target: { value: input } })
if (!newProvidedValue) {
newValue = input
}
}
if (newValue !== undefined) {
this.onChange({ target: { value: newValue } })
}
}

View File

@ -52,6 +52,7 @@ export default class AddContact extends PureComponent {
validate = (address) => {
const valid = isValidAddress(address)
const validEnsAddress = isValidDomainName(address)
if (valid || validEnsAddress || address === '') {
this.setState({ error: '', ethAddress: address })
} else {
@ -73,6 +74,7 @@ export default class AddContact extends PureComponent {
this.setState({ ensAddress: address, error: '', ensError: '' })
}}
updateEnsResolutionError={(message) => this.setState({ ensError: message })}
value={this.state.ethAddress || ''}
/>
)
}

View File

@ -38,7 +38,7 @@ export default function ActionableMessage ({
}
ActionableMessage.propTypes = {
message: PropTypes.string.isRequired,
message: PropTypes.node.isRequired,
primaryAction: PropTypes.shape({
label: PropTypes.string,
onClick: PropTypes.func,

View File

@ -31,7 +31,7 @@ import {
import { SUBMITTED_STATUS } from '../../../helpers/constants/transactions'
import { ASSET_ROUTE, DEFAULT_ROUTE } from '../../../helpers/constants/routes'
import { getRenderableGasFeesForQuote } from '../swaps.util'
import { getRenderableNetworkFeesForQuote } from '../swaps.util'
import SwapsFooter from '../swaps-footer'
import SwapFailureIcon from './swap-failure-icon'
import SwapSuccessIcon from './swap-success-icon'
@ -41,7 +41,6 @@ import ViewOnEtherScanLink from './view-on-ether-scan-link'
export default function AwaitingSwap ({
swapComplete,
errorKey,
symbol,
txHash,
networkId,
tokensReceived,
@ -71,8 +70,17 @@ export default function AwaitingSwap ({
let feeinFiat
if (usedQuote && tradeTxParams) {
const renderableGasFees = getRenderableGasFeesForQuote(usedQuote.gasEstimateWithRefund || usedQuote.averageGas, approveTxParams?.gas || '0x0', tradeTxParams.gasPrice, currentCurrency, conversionRate)
feeinFiat = renderableGasFees.feeinFiat?.slice(1)
const renderableNetworkFees = getRenderableNetworkFeesForQuote(
usedQuote.gasEstimateWithRefund || usedQuote.averageGas,
approveTxParams?.gas || '0x0',
tradeTxParams.gasPrice,
currentCurrency,
conversionRate,
tradeTxParams.value,
sourceTokenInfo?.symbol,
usedQuote.sourceAmount,
)
feeinFiat = renderableNetworkFees.feeinFiat?.slice(1)
}
const quotesExpiredEvent = useNewMetricEvent({
@ -119,7 +127,14 @@ export default function AwaitingSwap ({
let countdownText
if (timeRemainingIsNumber && !timeRemainingExpired && tradeTxData?.submittedTime) {
countdownText = <CountdownTimer timeStarted={tradeTxData?.submittedTime} timerBase={estimatedTransactionWaitTime} timeOnly />
countdownText = (
<CountdownTimer
key="countdown-timer"
timeStarted={tradeTxData?.submittedTime}
timerBase={estimatedTransactionWaitTime}
timeOnly
/>
)
} else if (tradeTxData?.submittedTime) {
countdownText = t('swapsAlmostDone')
} else {
@ -182,7 +197,7 @@ export default function AwaitingSwap ({
headerText = t('swapProcessing')
statusImage = <PulseLoader />
submitText = t('swapsViewInActivity')
descriptionText = t('swapOnceTransactionHasProcess', [<span key="swapOnceTransactionHasProcess-1" className="awaiting-swap__amount-and-symbol">{symbol}</span>])
descriptionText = t('swapOnceTransactionHasProcess', [<span key="swapOnceTransactionHasProcess-1" className="awaiting-swap__amount-and-symbol">{destinationTokenInfo.symbol}</span>])
content = (
<>
<div
@ -207,8 +222,8 @@ export default function AwaitingSwap ({
} else if (!errorKey && swapComplete) {
headerText = t('swapTransactionComplete')
statusImage = <SwapSuccessIcon />
submitText = t('swapViewToken', [symbol])
descriptionText = t('swapTokenAvailable', [<span key="swapTokenAvailable-2" className="awaiting-swap__amount-and-symbol">{`${tokensReceived || ''} ${symbol}`}</span>])
submitText = t('swapViewToken', [destinationTokenInfo.symbol])
descriptionText = t('swapTokenAvailable', [<span key="swapTokenAvailable-2" className="awaiting-swap__amount-and-symbol">{`${tokensReceived || ''} ${destinationTokenInfo.symbol}`}</span>])
content = blockExplorerUrl && (
<ViewOnEtherScanLink
txHash={txHash}
@ -266,7 +281,6 @@ export default function AwaitingSwap ({
AwaitingSwap.propTypes = {
swapComplete: PropTypes.bool,
symbol: PropTypes.string.isRequired,
networkId: PropTypes.string.isRequired,
txHash: PropTypes.string,
tokensReceived: PropTypes.string,

View File

@ -1,76 +0,0 @@
import React from 'react'
import { number, text, select } from '@storybook/addon-knobs/react'
import {
QUOTES_EXPIRED_ERROR,
SWAP_FAILED_ERROR,
ERROR_FETCHING_QUOTES,
QUOTES_NOT_AVAILABLE_ERROR,
} from '../../../helpers/constants/swaps'
import AwaitingSwap from './awaiting-swap'
export default {
title: 'AwaitingSwap',
}
export const swapNotComplete = () => (
<div style={{ height: '528px', width: '357px', border: '1px solid grey' }}>
<AwaitingSwap
swapComplete={false}
errorKey={select('Error types', [
'',
QUOTES_EXPIRED_ERROR,
SWAP_FAILED_ERROR,
ERROR_FETCHING_QUOTES,
QUOTES_NOT_AVAILABLE_ERROR,
], '')}
symbol="ABC"
estimatedTime="2 minutes"
networkId="1"
txHash="0xnotATx"
submittedTime={number('submittedTime', Date.now())}
transactionTimeRemaining={number('transactionTimeRemaining', 120000)}
rpcPrefs={{ blockExplorerUrl: text('blockExplorerUrl 1', 'http://example.com') }}
/>
</div>
)
export const swapComplete = () => (
<div style={{ height: '528px', width: '357px', border: '1px solid grey' }}>
<AwaitingSwap
swapComplete
errorKey={select('Error types', [
'',
QUOTES_EXPIRED_ERROR,
SWAP_FAILED_ERROR,
ERROR_FETCHING_QUOTES,
QUOTES_NOT_AVAILABLE_ERROR,
], '')}
symbol="ABC"
estimatedTime={null}
tokensReceived={320.68}
networkId="1"
txHash="0xnotATx"
rpcPrefs={{ blockExplorerUrl: text('blockExplorerUrl 2', 'http://example.com') }}
/>
</div>
)
export const swapError = () => (
<div style={{ height: '528px', width: '357px', border: '1px solid grey' }}>
<AwaitingSwap
swapComplete={false}
errorKey={select('Error types', [
'',
QUOTES_EXPIRED_ERROR,
SWAP_FAILED_ERROR,
ERROR_FETCHING_QUOTES,
QUOTES_NOT_AVAILABLE_ERROR,
], '')}
symbol="ABC"
estimatedTime={null}
networkId="1"
txHash="0xnotATx"
rpcPrefs={{ blockExplorerUrl: text('blockExplorerUrl 3', 'http://example.com') }}
/>
</div>
)

View File

@ -241,22 +241,23 @@ export default function BuildQuote ({
<div className="build-quote__dropdown-swap-to-header">
<div className="build-quote__input-label">{t('swapSwapTo')}</div>
</div>
<DropdownSearchList
startingItem={selectedToToken}
itemsToSearch={tokensToSearch}
searchPlaceholderText={t('swapSearchForAToken')}
fuseSearchKeys={fuseSearchKeys}
selectPlaceHolderText={t('swapSelectAToken')}
maxListItems={30}
onSelect={onToSelect}
loading={loading && (!tokensToSearch?.length || !topAssets || !Object.keys(topAssets).length)}
externallySelectedItem={selectedToToken}
hideItemIf={hideDropdownItemIf}
listContainerClassName="build-quote__open-to-dropdown"
hideRightLabels
defaultToAll
/>
<div className="dropdown-input-pair dropdown-input-pair__to">
<DropdownSearchList
startingItem={selectedToToken}
itemsToSearch={tokensToSearch}
searchPlaceholderText={t('swapSearchForAToken')}
fuseSearchKeys={fuseSearchKeys}
selectPlaceHolderText={t('swapSelectAToken')}
maxListItems={30}
onSelect={onToSelect}
loading={loading && (!tokensToSearch?.length || !topAssets || !Object.keys(topAssets).length)}
externallySelectedItem={selectedToToken}
hideItemIf={hideDropdownItemIf}
listContainerClassName="build-quote__open-to-dropdown"
hideRightLabels
defaultToAll
/>
</div>
<div className="build-quote__slippage-buttons-container">
<SlippageButtons
onSelect={(newSlippage) => {

View File

@ -118,4 +118,9 @@
max-height: 276px;
}
}
/* Prevents the swaps "Swap to" field from overflowing */
.dropdown-input-pair__to .dropdown-search-list {
width: 100%;
}
}

View File

@ -29,12 +29,16 @@ export default function DropdownSearchList ({
const t = useContext(I18nContext)
const [isOpen, setIsOpen] = useState(false)
const [selectedItem, setSelectedItem] = useState(startingItem)
const close = useCallback(() => {
setIsOpen(false)
onClose && onClose()
}, [onClose])
const onClickItem = useCallback((item) => {
onSelect && onSelect(item)
setSelectedItem(item)
setIsOpen(false)
onClose && onClose()
}, [onClose, onSelect])
close()
}, [onSelect, close])
const onClickSelector = useCallback(() => {
if (!isOpen) {
@ -57,10 +61,20 @@ export default function DropdownSearchList ({
}
}, [externallySelectedItem, selectedItem, prevExternallySelectedItem])
const onKeyUp = (e) => {
if (e.key === 'Escape') {
close()
} else if (e.key === 'Enter') {
onClickSelector(e)
}
}
return (
<div
className={classnames('dropdown-search-list', className)}
onClick={onClickSelector}
onKeyUp={onKeyUp}
tabIndex="0"
>
{!isOpen && (
<div

View File

@ -1,7 +1,4 @@
.dropdown-search-list {
flex-flow: column;
border: none;
&__search-list-open {
margin: 24px;
box-shadow: none;
@ -126,6 +123,8 @@
background: white;
border-radius: 6px;
min-height: 194px;
overflow: hidden;
text-overflow: ellipsis;
}
&__loading-item {

View File

@ -26,7 +26,7 @@ export default function FeeCard ({
position="top"
contentText={(
<>
<p className="fee-card__info-tooltip-paragraph">{ t('swapGasFeeSummary') }</p>
<p className="fee-card__info-tooltip-paragraph">{ t('swapNetworkFeeSummary') }</p>
<p className="fee-card__info-tooltip-paragraph">{ t('swapEstimatedNetworkFeeSummary', [
<span className="fee-card__bold" key="fee-card-bold-1">
{ t('swapEstimatedNetworkFee') }

View File

@ -20,7 +20,6 @@ import {
getAggregatorMetadata,
getBackgroundSwapRouteState,
getSwapsErrorKey,
setMetamaskFeeAmount,
getSwapsFeatureLiveness,
prepareToLeaveSwaps,
fetchAndSetSwapsGasPriceInfo,
@ -49,7 +48,7 @@ import { useNewMetricEvent } from '../../hooks/useMetricEvent'
import { getValueFromWeiHex } from '../../helpers/utils/conversions.util'
import FeatureToggledRoute from '../../helpers/higher-order-components/feature-toggled-route'
import { fetchTokens, fetchTopAssets, getSwapsTokensReceivedFromTxMeta, fetchAggregatorMetadata, fetchMetaMaskFeeAmount } from './swaps.util'
import { fetchTokens, fetchTopAssets, getSwapsTokensReceivedFromTxMeta, fetchAggregatorMetadata } from './swaps.util'
import AwaitingSwap from './awaiting-swap'
import LoadingQuote from './loading-swaps-quotes'
import BuildQuote from './build-quote'
@ -155,11 +154,6 @@ export default function Swap () {
dispatch(setAggregatorMetadata(newAggregatorMetadata))
})
fetchMetaMaskFeeAmount()
.then((metaMaskFeeAmount) => {
dispatch(setMetamaskFeeAmount(metaMaskFeeAmount))
})
dispatch(resetCustomGasState())
dispatch(fetchAndSetSwapsGasPriceInfo())
@ -304,7 +298,6 @@ export default function Swap () {
<AwaitingSwap
swapComplete={false}
errorKey={swapsErrorKey}
symbol={destinationTokenInfo?.symbol}
txHash={tradeTxData?.hash}
networkId={networkId}
rpcPrefs={rpcPrefs}
@ -351,7 +344,6 @@ export default function Swap () {
return swapsEnabled === false ? (
<AwaitingSwap
errorKey={OFFLINE_FOR_MAINTENANCE}
symbol=""
networkId={networkId}
rpcPrefs={rpcPrefs}
/>
@ -366,7 +358,6 @@ export default function Swap () {
? (
<AwaitingSwap
swapComplete={tradeConfirmed}
symbol={destinationTokenInfo?.symbol}
networkId={networkId}
txHash={tradeTxData?.hash}
tokensReceived={tokensReceived}

View File

@ -1,91 +0,0 @@
import React, { useState, useEffect } from 'react'
import { storiesMetadata } from './loading-swaps-quotes-stories-metadata'
import LoadingSwapsQuotes from './loading-swaps-quotes'
export default {
title: 'LoadingSwapsQuotes',
}
export const FasterThanExpectedCompletion = () => {
const [loading, setLoading] = useState(false)
const [loadingComplete, setLoadingComplete] = useState(false)
const [done, setDone] = useState(false)
useEffect(() => {
if (!done && !loading) {
setLoading(true)
setTimeout(() => {
setLoading(false)
setLoadingComplete(true)
}, 3000)
}
}, [done, loading])
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<div style={{ height: '600px', width: '357px', border: '1px solid grey' }}>
<LoadingSwapsQuotes
loadingComplete={loadingComplete}
onDone={() => setDone(true)}
aggregatorMetadata={storiesMetadata}
/>
</div>
</div>
)
}
export const SlowerThanExpectedCompletion = () => {
const [loading, setLoading] = useState(false)
const [loadingComplete, setLoadingComplete] = useState(false)
const [done, setDone] = useState(false)
useEffect(() => {
if (!done && !loading) {
setLoading(true)
setTimeout(() => {
setLoading(false)
setLoadingComplete(true)
}, 10000)
}
}, [done, loading])
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<div style={{ height: '600px', width: '357px', border: '1px solid grey' }}>
<LoadingSwapsQuotes
loadingComplete={loadingComplete}
onDone={() => setDone(true)}
aggregatorMetadata={storiesMetadata}
/>
</div>
</div>
)
}
export const FasterThanExpectedCompletionWithError = () => {
const [loading, setLoading] = useState(false)
const [loadingComplete, setLoadingComplete] = useState(false)
const [done, setDone] = useState(false)
useEffect(() => {
if (!done && !loading) {
setLoading(true)
setTimeout(() => {
setLoading(false)
setLoadingComplete(true)
}, 3000)
}
}, [done, loading])
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<div style={{ height: '600px', width: '357px', border: '1px solid grey' }}>
<LoadingSwapsQuotes
loadingComplete={loadingComplete}
onDone={() => setDone(true)}
aggregatorMetadata={storiesMetadata}
/>
</div>
</div>
)
}

View File

@ -66,7 +66,8 @@
border-bottom: 1px solid $Grey-100;
}
&:hover {
&:hover,
&:focus {
background: $Grey-000;
}

View File

@ -34,6 +34,7 @@ export default function ItemList ({
return null
}
const onClick = () => onClickItem && onClickItem(result)
const {
iconUrl,
identiconAddress,
@ -47,11 +48,13 @@ export default function ItemList ({
} = result
return (
<div
tabIndex="0"
className={classnames('searchable-item-list__item', {
'searchable-item-list__item--selected': selected,
'searchable-item-list__item--disabled': disabled,
})}
onClick={() => onClickItem && onClickItem(result)}
onClick={onClick}
onKeyUp={(e) => e.key === 'Enter' && onClick()}
key={`searchable-item-list-item-${i}`}
>
{(iconUrl || primaryLabel) && (<UrlIcon url={iconUrl} name={primaryLabel} />)}

View File

@ -10,7 +10,7 @@ const renderAdornment = () => (
position="start"
style={{ marginRight: '12px' }}
>
<img src="images/search.svg" />
<img src="images/search.svg" width="17" height="17" />
</InputAdornment>
)

View File

@ -6,8 +6,6 @@ const quoteDataRows = [
destinationTokenSymbol: 'DAI',
destinationTokenValue: '100000000000000000000',
isBestQuote: false,
liquiditySource: 'AGG',
metaMaskFee: '1.00 DAI',
networkFees: '$15.25',
quoteSource: 'AGG',
rawNetworkFees: 10.25,
@ -23,8 +21,6 @@ const quoteDataRows = [
destinationTokenSymbol: 'DAI',
destinationTokenValue: '101000000000000000000',
isBestQuote: false,
liquiditySource: 'RFQ',
metaMaskFee: '1.01 DAI',
networkFees: '$14.26',
quoteSource: 'RFQ',
rawNetworkFees: 10.26,
@ -40,8 +36,6 @@ const quoteDataRows = [
destinationTokenSymbol: 'DAI',
destinationTokenValue: '102000000000000000000',
isBestQuote: false,
liquiditySource: 'DEX',
metaMaskFee: '1.02 DAI',
networkFees: '$13.27',
quoteSource: 'DEX',
rawNetworkFees: 10.27,
@ -57,8 +51,6 @@ const quoteDataRows = [
destinationTokenSymbol: 'DAI',
destinationTokenValue: '150000000000000000000',
isBestQuote: true,
liquiditySource: 'AGG',
metaMaskFee: '1.00 DAI',
networkFees: '$12.28',
quoteSource: 'AGG',
rawNetworkFees: 10.28,
@ -74,8 +66,6 @@ const quoteDataRows = [
destinationTokenSymbol: 'DAI',
destinationTokenValue: '104000000000000000000',
isBestQuote: false,
liquiditySource: 'RFQ',
metaMaskFee: '1.04 DAI',
networkFees: '$11.29',
quoteSource: 'RFQ',
rawNetworkFees: 10.29,
@ -91,8 +81,6 @@ const quoteDataRows = [
destinationTokenSymbol: 'DAI',
destinationTokenValue: '105000000000000000000',
isBestQuote: false,
liquiditySource: 'DEX',
metaMaskFee: '1.05 DAI',
networkFees: '$10.30',
quoteSource: 'DEX',
rawNetworkFees: 10.30,

View File

@ -1,8 +1,6 @@
import React, { useContext } from 'react'
import PropTypes from 'prop-types'
import { useSelector } from 'react-redux'
import { I18nContext } from '../../../../contexts/i18n'
import { getMetaMaskFeeAmount } from '../../../../ducks/swaps/swaps'
import InfoTooltip from '../../../../components/ui/info-tooltip'
import ExchangeRateDisplay from '../../exchange-rate-display'
@ -16,9 +14,9 @@ const QuoteDetails = ({
minimumAmountReceived,
feeInEth,
networkFees,
metaMaskFee,
}) => {
const t = useContext(I18nContext)
const metaMaskFee = useSelector(getMetaMaskFeeAmount)
return (
<div className="quote-details">
<div className="quote-details__row">
@ -105,6 +103,7 @@ QuoteDetails.propTypes = {
minimumAmountReceived: PropTypes.string.isRequired,
feeInEth: PropTypes.string.isRequired,
networkFees: PropTypes.string.isRequired,
metaMaskFee: PropTypes.number.isRequired,
}
export default QuoteDetails

View File

@ -7,8 +7,6 @@ export const QUOTE_DATA_ROWS_PROPTYPES_SHAPE = PropTypes.shape({
destinationTokenSymbol: PropTypes.string.isRequired,
destinationTokenValue: PropTypes.string.isRequired,
isBestQuote: PropTypes.bool,
liquiditySource: PropTypes.string.isRequired,
metaMaskFee: PropTypes.string.isRequired,
networkFees: PropTypes.string.isRequired,
quoteSource: PropTypes.string.isRequired,
rawNetworkFees: PropTypes.number.isRequired,

View File

@ -38,7 +38,7 @@ export default function SortList ({
// This sort aims to do the following:
// If there is no selected sort column, then the best quotes should be first in the list
// If there is no selected sort column, then quotes that are not the best quotes should be in random order, after the first in the list
// If the sort column is 'liquiditySource', sort alphabetically by 'liquiditySource'
// If the sort column is 'quoteSource', sort alphabetically by 'quoteSource'
// Otherwise, sort in either ascending or descending numerical order on the selected column
const sortedRows = useMemo(() => {
return [...quoteDataRows].sort((rowDataA, rowDataB) => {
@ -51,7 +51,7 @@ export default function SortList ({
const aHex = (new BigNumber(rowDataA.destinationTokenValue).toString(16))
const bHex = (new BigNumber(rowDataB.destinationTokenValue).toString(16))
return aHex[aHex.length - 1] < bHex[bHex.length - 1] ? -1 : 1
} else if (sortColumn === 'liquiditySource') {
} else if (sortColumn === 'quoteSource') {
return rowDataA[sortColumn] > rowDataB[sortColumn]
? sortDirection * -1
: sortDirection
@ -94,7 +94,7 @@ export default function SortList ({
</div>
<div
className="select-quote-popover__column-header select-quote-popover__quote-source"
onClick={() => onColumnHeaderClick('liquiditySource')}
onClick={() => onColumnHeaderClick('quoteSource')}
>
{t('swapQuoteSource')}
<div className="select-quote-popover__quote-source-toggle"><ToggleArrows /></div>

View File

@ -74,7 +74,7 @@ export default function SlippageButtons ({
</Button>
<Button
onClick={() => {
setCustomValue(undefined)
setCustomValue('')
setEnteringCustomValue(false)
setActiveButtonIndex(1)
onSelect(2)
@ -108,12 +108,12 @@ export default function SlippageButtons ({
ref={setInputRef}
onBlur={() => {
setEnteringCustomValue(false)
if (customValue === '' || customValue === '0') {
setCustomValue(null)
if (customValue === '0') {
setCustomValue('')
setActiveButtonIndex(1)
}
}}
value={customValue}
value={customValue || ''}
/>
</div>
)

View File

@ -1,3 +1,5 @@
import { ETH_SWAPS_TOKEN_OBJECT } from '../../helpers/constants/swaps'
export const TRADES_BASE_PROD_URL = 'https://api.metaswap.codefi.network/trades?'
export const TOKENS_BASE_PROD_URL = 'https://api.metaswap.codefi.network/tokens'
export const AGGREGATOR_METADATA_BASE_PROD_URL = 'https://api.metaswap.codefi.network/aggregatorMetadata'
@ -16,6 +18,7 @@ export const TOKENS = [
{ erc20: true, symbol: 'USDT', decimals: 6, address: '0xdAC17F958D2ee523a2206206994597C13D831ec7' },
{ erc20: true, symbol: 'WED', decimals: 18, address: '0x7848ae8F19671Dc05966dafBeFbBbb0308BDfAbD' },
{ erc20: true, symbol: 'WBTC', decimals: 8, address: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599' },
ETH_SWAPS_TOKEN_OBJECT,
]
export const MOCK_TRADE_RESPONSE_1 = [

View File

@ -2,9 +2,11 @@ import log from 'loglevel'
import BigNumber from 'bignumber.js'
import abi from 'human-standard-token-abi'
import { isValidAddress } from 'ethereumjs-util'
import { ETH_SWAPS_TOKEN_OBJECT } from '../../helpers/constants/swaps'
import { calcTokenValue, calcTokenAmount } from '../../helpers/utils/token-util'
import { constructTxParams, toPrecisionWithoutTrailingZeros } from '../../helpers/utils/util'
import { decimalToHex, getValueFromWeiHex } from '../../helpers/utils/conversions.util'
import { subtractCurrencies } from '../../helpers/utils/conversion-util'
import { formatCurrency } from '../../helpers/utils/confirm-tx.util'
import fetchWithCache from '../../helpers/utils/fetch-with-cache'
@ -27,8 +29,6 @@ const getBaseApi = function (type) {
return `https://api.metaswap.codefi.network/featureFlag`
case 'aggregatorMetadata':
return `https://api.metaswap.codefi.network/aggregatorMetadata`
case 'feeAmount':
return `https://api.metaswap.codefi.network/fee`
default:
throw new Error('getBaseApi requires an api call type')
}
@ -214,7 +214,10 @@ export async function fetchTradesInfo ({
export async function fetchTokens () {
const tokenUrl = getBaseApi('tokens')
const tokens = await fetchWithCache(tokenUrl, { method: 'GET' }, { cacheRefreshTime: CACHE_REFRESH_ONE_HOUR })
const filteredTokens = tokens.filter((token) => validateData(TOKEN_VALIDATORS, token, tokenUrl))
const filteredTokens = tokens.filter((token) => {
return validateData(TOKEN_VALIDATORS, token, tokenUrl) && (token.address !== ETH_SWAPS_TOKEN_OBJECT.address)
})
filteredTokens.push(ETH_SWAPS_TOKEN_OBJECT)
return filteredTokens
}
@ -247,11 +250,6 @@ export async function fetchSwapsFeatureLiveness () {
return status?.active
}
export async function fetchMetaMaskFeeAmount () {
const response = await fetchWithCache(getBaseApi('feeAmount'), { method: 'GET' }, { cacheRefreshTime: 600000 })
return response?.fee
}
export async function fetchTokenPrice (address) {
const query = `contract_addresses=${address}&vs_currencies=eth`
@ -268,17 +266,34 @@ export async function fetchTokenBalance (address, userAddress) {
return usersToken
}
export function getRenderableGasFeesForQuote (tradeGas, approveGas, gasPrice, currentCurrency, conversionRate) {
export function getRenderableNetworkFeesForQuote (
tradeGas,
approveGas,
gasPrice,
currentCurrency,
conversionRate,
tradeValue,
sourceSymbol,
sourceAmount,
) {
const totalGasLimitForCalculation = (new BigNumber(tradeGas || '0x0', 16)).plus(approveGas || '0x0', 16).toString(16)
const gasTotalInWeiHex = calcGasTotal(totalGasLimitForCalculation, gasPrice)
const nonGasFee = new BigNumber(tradeValue, 16)
.minus(sourceSymbol === 'ETH' ? sourceAmount : 0, 10)
.toString(16)
const totalWeiCost = new BigNumber(gasTotalInWeiHex, 16)
.plus(nonGasFee, 16)
.toString(16)
const ethFee = getValueFromWeiHex({
value: gasTotalInWeiHex,
value: totalWeiCost,
toDenomination: 'ETH',
numberOfDecimals: 5,
})
const rawNetworkFees = getValueFromWeiHex({
value: gasTotalInWeiHex,
value: totalWeiCost,
toCurrency: currentCurrency,
conversionRate,
numberOfDecimals: 2,
@ -289,12 +304,25 @@ export function getRenderableGasFeesForQuote (tradeGas, approveGas, gasPrice, cu
rawEthFee: ethFee,
feeInFiat: formattedNetworkFee,
feeInEth: `${ethFee} ETH`,
nonGasFee,
}
}
export function quotesToRenderableData (quotes, gasPrice, conversionRate, currentCurrency, approveGas, tokenConversionRates, customGasLimit) {
export function quotesToRenderableData (quotes, gasPrice, conversionRate, currentCurrency, approveGas, tokenConversionRates) {
return Object.values(quotes).map((quote) => {
const { destinationAmount = 0, sourceAmount = 0, sourceTokenInfo, destinationTokenInfo, slippage, aggType, aggregator, gasEstimateWithRefund, averageGas } = quote
const {
destinationAmount = 0,
sourceAmount = 0,
sourceTokenInfo,
destinationTokenInfo,
slippage,
aggType,
aggregator,
gasEstimateWithRefund,
averageGas,
fee,
trade,
} = quote
const sourceValue = calcTokenAmount(sourceAmount, sourceTokenInfo.decimals || 18).toString(10)
const destinationValue = calcTokenAmount(destinationAmount, destinationTokenInfo.decimals || 18).toPrecision(8)
@ -303,9 +331,8 @@ export function quotesToRenderableData (quotes, gasPrice, conversionRate, curren
rawNetworkFees,
rawEthFee,
feeInEth,
} = getRenderableGasFeesForQuote(
} = getRenderableNetworkFeesForQuote(
(
customGasLimit ||
gasEstimateWithRefund ||
decimalToHex(averageGas || 800000)
),
@ -313,6 +340,9 @@ export function quotesToRenderableData (quotes, gasPrice, conversionRate, curren
gasPrice,
currentCurrency,
conversionRate,
trade.value,
sourceTokenInfo.symbol,
sourceAmount,
)
const slippageMultiplier = (new BigNumber(100 - slippage)).div(100)
@ -358,6 +388,7 @@ export function quotesToRenderableData (quotes, gasPrice, conversionRate, curren
sourceTokenValue: sourceValue,
ethValueOfTrade,
minimumAmountReceived,
metaMaskFee: fee,
}
})
}

View File

@ -22,7 +22,6 @@ import {
getBalanceError,
getCustomSwapsGas,
getDestinationTokenInfo,
getMetaMaskFeeAmount,
getSwapsTradeTxParams,
getTopQuote,
navigateBackToBuildQuote,
@ -68,7 +67,7 @@ import MainQuoteSummary from '../main-quote-summary'
import { calcGasTotal } from '../../send/send.utils'
import { getCustomTxParamsData } from '../../confirm-approve/confirm-approve.util'
import ActionableMessage from '../actionable-message'
import { quotesToRenderableData, getRenderableGasFeesForQuote } from '../swaps.util'
import { quotesToRenderableData, getRenderableNetworkFeesForQuote } from '../swaps.util'
import { useTokenTracker } from '../../../hooks/useTokenTracker'
import { QUOTES_EXPIRED_ERROR } from '../../../helpers/constants/swaps'
import CountdownTimer from '../countdown-timer'
@ -100,7 +99,7 @@ export default function ViewQuote () {
// Select necessary data
const tradeTxParams = useSelector(getSwapsTradeTxParams)
const { gasPrice } = tradeTxParams || {}
const { gasPrice, value: tradeValue } = tradeTxParams || {}
const customMaxGas = useSelector(getCustomSwapsGas)
const tokenConversionRates = useSelector(getTokenExchangeRates)
const memoizedTokenConversionRates = useEqualityCheck(tokenConversionRates)
@ -163,12 +162,6 @@ export default function ViewQuote () {
calcTokenAmount(approveValue, selectedFromToken.decimals).toFixed(9)
)
const approveGas = approveTxParams?.gas
const approveGasTotal = calcGasTotal(approveGas || '0x0', gasPrice)
const approveGasTotalInEth = getValueFromWeiHex({
value: approveGasTotal,
toDenomination: 'ETH',
numberOfDecimals: 4,
})
const renderablePopoverData = useMemo(() => {
return quotesToRenderableData(
@ -203,23 +196,30 @@ export default function ViewQuote () {
sourceTokenValue,
} = renderableDataForUsedQuote
const { feeInFiat, feeInEth } = getRenderableGasFeesForQuote(
const { feeInFiat, feeInEth } = getRenderableNetworkFeesForQuote(
usedGasLimit,
approveGas,
gasPrice,
currentCurrency,
conversionRate,
tradeValue,
sourceTokenSymbol,
usedQuote.sourceAmount,
)
const {
feeInFiat: maxFeeInFiat,
feeInEth: maxFeeInEth,
} = getRenderableGasFeesForQuote(
nonGasFee,
} = getRenderableNetworkFeesForQuote(
maxGasLimit,
approveGas,
gasPrice,
currentCurrency,
conversionRate,
tradeValue,
sourceTokenSymbol,
usedQuote.sourceAmount,
)
const tokenCost = (new BigNumber(usedQuote.sourceAmount))
@ -341,7 +341,7 @@ export default function ViewQuote () {
}
}, [sourceTokenSymbol, sourceTokenValue, destinationTokenSymbol, destinationTokenValue, fetchParams, topQuote, numberOfQuotes, feeInFiat, bestQuoteReviewedEvent, anonymousBestQuoteReviewedEvent])
const metaMaskFee = useSelector(getMetaMaskFeeAmount)
const metaMaskFee = usedQuote.fee
const onFeeCardTokenApprovalClick = () => {
anonymousEditSpendLimitOpened()
@ -378,6 +378,26 @@ export default function ViewQuote () {
}))
}
const nonGasFeeIsPositive = (new BigNumber(nonGasFee, 16)).gt(0)
const approveGasTotal = calcGasTotal(approveGas || '0x0', gasPrice)
const extraNetworkFeeTotalInHexWEI = (new BigNumber(nonGasFee, 16))
.plus(approveGasTotal, 16)
.toString(16)
const extraNetworkFeeTotalInEth = getValueFromWeiHex({
value: extraNetworkFeeTotalInHexWEI,
toDenomination: 'ETH',
numberOfDecimals: 4,
})
let extraInfoRowLabel = ''
if (approveGas && nonGasFeeIsPositive) {
extraInfoRowLabel = t('approvalAndAggregatorTxFeeCost')
} else if (approveGas) {
extraInfoRowLabel = t('approvalTxGasCost')
} else if (nonGasFeeIsPositive) {
extraInfoRowLabel = t('aggregatorFeeCost')
}
const onFeeCardMaxRowClick = () => dispatch(showModal({
name: 'CUSTOMIZE_GAS',
txData: { txParams: { ...tradeTxParams, gas: maxGasLimit } },
@ -389,10 +409,10 @@ export default function ViewQuote () {
),
customTotalSupplement: approveGasTotal,
extraInfoRow: (
approveGas
extraInfoRowLabel
? {
label: t('approvalTxGasCost'),
value: t('amountInEth', [approveGasTotalInEth]),
label: extraInfoRowLabel,
value: t('amountInEth', [extraNetworkFeeTotalInEth]),
}
: null
),

View File

@ -210,7 +210,7 @@ export function getAccountToConnectToActiveTab (state) {
export function getOrderedConnectedAccountsForActiveTab (state) {
const { activeTab, metamask: { permissionsHistory } } = state
const permissionsHistoryByAccount = permissionsHistory[activeTab.origin]?.['eth_accounts']?.accounts
const permissionsHistoryByAccount = permissionsHistory[activeTab.origin]?.eth_accounts?.accounts
const orderedAccounts = getMetaMaskAccountsOrdered(state)
const connectedAccounts = getPermittedAccountsForCurrentTab(state)

View File

@ -14,6 +14,16 @@ export function getNetworkIdentifier (state) {
return nickname || rpcUrl || type
}
export function getMetricsNetworkIdentifier (state) {
const { provider } = state.metamask
return provider.type === 'rpc' ? provider.rpcUrl : provider.type
}
export function getCurrentChainId (state) {
const { chainId } = state.metamask.provider
return chainId
}
export function getCurrentKeyring (state) {
const identity = getSelectedIdentity(state)
@ -156,7 +166,7 @@ export function getAssetImages (state) {
}
export function getAddressBook (state) {
const { chainId } = state.metamask.provider
const chainId = getCurrentChainId(state)
if (!state.metamask.addressBook[chainId]) {
return []
}

1881
yarn.lock

File diff suppressed because it is too large Load Diff