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:
commit
11781e8cc0
@ -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:
|
||||
|
@ -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";
|
||||
|
21
.eslintrc.js
21
.eslintrc.js
@ -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: [
|
||||
|
15
.github/ISSUE_TEMPLATE.md
vendored
15
.github/ISSUE_TEMPLATE.md
vendored
@ -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
8
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
Fixes: #
|
||||
|
||||
Explanation:
|
||||
|
||||
Manual testing steps:
|
||||
-
|
||||
-
|
||||
-
|
13
.github/dependabot.yml
vendored
Normal file
13
.github/dependabot.yml
vendored
Normal 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"
|
19
CHANGELOG.md
19
CHANGELOG.md
@ -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
|
||||
|
@ -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"
|
||||
|
@ -68,6 +68,6 @@
|
||||
"notifications"
|
||||
],
|
||||
"short_name": "__MSG_appName__",
|
||||
"version": "8.1.2",
|
||||
"version": "8.1.3",
|
||||
"web_accessible_resources": ["inpage.js", "phishing.html"]
|
||||
}
|
||||
|
@ -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 */
|
||||
}
|
||||
|
@ -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'
|
||||
|
@ -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) {
|
||||
|
@ -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,
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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: {
|
||||
|
@ -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',
|
||||
})
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
@ -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'
|
||||
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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')
|
||||
|
@ -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))
|
||||
}
|
||||
|
@ -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,
|
||||
})
|
||||
|
@ -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))
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
const EventEmitter = require('events')
|
||||
const { spawn } = require('child_process')
|
||||
const spawn = require('cross-spawn')
|
||||
|
||||
const tasks = {}
|
||||
const taskEvents = new EventEmitter()
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
32
package.json
32
package.json
@ -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"
|
||||
}
|
||||
}
|
||||
|
251
shared/modules/metametrics.js
Normal file
251
shared/modules/metametrics.js
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -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,
|
||||
})
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
|
@ -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')
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
|
@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -19,7 +19,7 @@ export default class ConnectedStatusIndicator extends Component {
|
||||
|
||||
static defaultProps = {
|
||||
status: STATUS_NOT_CONNECTED,
|
||||
onClick: null,
|
||||
onClick: undefined,
|
||||
}
|
||||
|
||||
renderStatusCircle = () => {
|
||||
|
@ -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,
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
}}
|
||||
>
|
||||
|
@ -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,
|
||||
|
@ -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()}
|
||||
|
@ -59,6 +59,7 @@ function QrCodeView (props) {
|
||||
/>
|
||||
<ReadOnlyInput
|
||||
wrapperClass="ellip-address-wrapper"
|
||||
autoFocus
|
||||
value={checksumAddress(data)}
|
||||
/>
|
||||
</div>
|
||||
|
@ -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,
|
||||
}
|
||||
|
@ -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 (
|
||||
|
@ -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}
|
||||
|
@ -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%;
|
||||
}
|
||||
|
@ -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 = {
|
||||
|
@ -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'
|
||||
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
@ -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
|
@ -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 = []) {
|
||||
|
@ -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'
|
||||
|
@ -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 }
|
||||
}
|
||||
|
@ -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])
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -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 } })
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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 || ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
)
|
@ -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) => {
|
||||
|
@ -118,4 +118,9 @@
|
||||
max-height: 276px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Prevents the swaps "Swap to" field from overflowing */
|
||||
.dropdown-input-pair__to .dropdown-search-list {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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 {
|
||||
|
@ -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') }
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
@ -66,7 +66,8 @@
|
||||
border-bottom: 1px solid $Grey-100;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
&:hover,
|
||||
&:focus {
|
||||
background: $Grey-000;
|
||||
}
|
||||
|
||||
|
@ -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} />)}
|
||||
|
@ -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>
|
||||
)
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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 = [
|
||||
|
@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -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
|
||||
),
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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 []
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user