1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-11-26 12:29:06 +01:00

Merge remote-tracking branch 'origin/develop' into master-sync

This commit is contained in:
Dan Miller 2021-11-22 12:34:10 -03:30
commit 74719a8102
165 changed files with 14035 additions and 941 deletions

View File

@ -12,7 +12,7 @@ executors:
NODE_OPTIONS: --max_old_space_size=2048 NODE_OPTIONS: --max_old_space_size=2048
shellcheck: shellcheck:
docker: docker:
- image: koalaman/shellcheck-alpine@sha256:35882cba254810c7de458528011e935ba2c4f3ebcb224275dfa7ebfa930ef294 - image: koalaman/shellcheck-alpine@sha256:dfaf08fab58c158549d3be64fb101c626abc5f16f341b569092577ae207db199
workflows: workflows:
test_and_release: test_and_release:
@ -25,7 +25,9 @@ workflows:
only: only:
- /^Version-v(\d+)[.](\d+)[.](\d+)/ - /^Version-v(\d+)[.](\d+)[.](\d+)/
- prep-deps - prep-deps
- test-deps-audit - test-deps-audit:
requires:
- prep-deps
- test-deps-depcheck: - test-deps-depcheck:
requires: requires:
- prep-deps - prep-deps

View File

@ -1,6 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -e
set -u set -u
set -x
set -o pipefail set -o pipefail
# use `improved-yarn-audit` since that allows for exclude # use `improved-yarn-audit` since that allows for exclude

70
.github/workflows/codeql-analysis.yml vendored Normal file
View File

@ -0,0 +1,70 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ develop, Version-v*, cla-signatures, master, snaps ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ develop ]
schedule:
- cron: '28 12 * * 0'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'javascript' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Learn more about CodeQL language support at https://git.io/codeql-language-support
steps:
- name: Checkout repository
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1

View File

@ -1,5 +1,5 @@
node_modules/** node_modules/**
lavamoat/*/policy.json lavamoat/**/policy.json
dist/** dist/**
builds/** builds/**
test-*/** test-*/**

View File

@ -67,9 +67,17 @@ Whenever you change dependencies (adding, removing, or updating, either in `pack
* The `allow-scripts` configuration in `package.json` * The `allow-scripts` configuration in `package.json`
* Run `yarn allow-scripts auto` to update the `allow-scripts` configuration automatically. This config determines whether the package's install/postinstall scripts are allowed to run. Review each new package to determine whether the install script needs to run or not, testing if necessary. * Run `yarn allow-scripts auto` to update the `allow-scripts` configuration automatically. This config determines whether the package's install/postinstall scripts are allowed to run. Review each new package to determine whether the install script needs to run or not, testing if necessary.
* Unfortunately, `yarn allow-scripts auto` will behave inconsistently on different platforms. macOS and Windows users may see extraneous changes relating to optional dependencies. * Unfortunately, `yarn allow-scripts auto` will behave inconsistently on different platforms. macOS and Windows users may see extraneous changes relating to optional dependencies.
* The LavaMoat auto-generated policy in `lavamoat/node/policy.json` * The LavaMoat policy files. The _tl;dr_ is to run `yarn lavamoat:auto` to update these files, but there can be devils in the details. Continue reading for more information.
* Run `yarn lavamoat:auto` to re-generate this policy file. Review the changes to determine whether the access granted to each package seems appropriate. * There are two sets of LavaMoat policy files:
* Unfortunately, `yarn lavamoat:auto` will behave inconsistently on different platforms. macOS and Windows users may see extraneous changes relating to optional dependencies. * The production LavaMoat policy files (`lavamoat/browserify/*/policy.json`), which are re-generated using `yarn lavamoat:background:auto`.
* These should be regenerated whenever the production dependencies for the background change.
* The build system LavaMoat policy file (`lavamoat/build-system/policy.json`), which is re-generated using `yarn lavamoat:build:auto`.
* This should be regenerated whenever the dependencies used by the build system itself change.
* Whenever you regenerate a policy file, review the changes to determine whether the access granted to each package seems appropriate.
* Unfortunately, `yarn lavamoat:auto` will behave inconsistently on different platforms.
macOS and Windows users may see extraneous changes relating to optional dependencies.
* Keep in mind that any kind of dynamic import or dynamic use of globals may elude LavaMoat's static analysis.
Refer to the LavaMoat documentation or ask for help if you run into any issues.
## Architecture ## Architecture

View File

@ -43,9 +43,15 @@
"activityLog": { "activityLog": {
"message": "activity log" "message": "activity log"
}, },
"add": {
"message": "Add"
},
"addANetwork": { "addANetwork": {
"message": "Add a network" "message": "Add a network"
}, },
"addANickname": {
"message": "Add a nickname"
},
"addAcquiredTokens": { "addAcquiredTokens": {
"message": "Add the tokens you've acquired using MetaMask" "message": "Add the tokens you've acquired using MetaMask"
}, },
@ -82,6 +88,9 @@
"addFriendsAndAddresses": { "addFriendsAndAddresses": {
"message": "Add friends and addresses you trust" "message": "Add friends and addresses you trust"
}, },
"addMemo": {
"message": "Add memo"
},
"addNFT": { "addNFT": {
"message": "Add NFT" "message": "Add NFT"
}, },
@ -100,6 +109,9 @@
"addToken": { "addToken": {
"message": "Add Token" "message": "Add Token"
}, },
"address": {
"message": "Address"
},
"addressBookIcon": { "addressBookIcon": {
"message": "Address book icon" "message": "Address book icon"
}, },
@ -167,6 +179,14 @@
"message": "MetaMask", "message": "MetaMask",
"description": "The name of the application" "description": "The name of the application"
}, },
"appNameBeta": {
"message": "MetaMask Beta",
"description": "The name of the application (Beta)"
},
"appNameFlask": {
"message": "MetaMask Flask",
"description": "The name of the application (Flask)"
},
"approvalAndAggregatorTxFeeCost": { "approvalAndAggregatorTxFeeCost": {
"message": "Approval and aggregator network fee" "message": "Approval and aggregator network fee"
}, },
@ -549,6 +569,9 @@
"currentlyUnavailable": { "currentlyUnavailable": {
"message": "Unavailable on this network" "message": "Unavailable on this network"
}, },
"custom": {
"message": "Advanced"
},
"customGas": { "customGas": {
"message": "Customize Gas" "message": "Customize Gas"
}, },
@ -561,6 +584,16 @@
"customToken": { "customToken": {
"message": "Custom Token" "message": "Custom Token"
}, },
"dappSuggested": {
"message": "Site suggested"
},
"dappSuggestedShortLabel": {
"message": "Site"
},
"dappSuggestedTooltip": {
"message": "$1 has recommended this price.",
"description": "$1 represents the Dapp's origin"
},
"data": { "data": {
"message": "Data" "message": "Data"
}, },
@ -665,6 +698,9 @@
"edit": { "edit": {
"message": "Edit" "message": "Edit"
}, },
"editAddressNickname": {
"message": "Edit address nickname"
},
"editContact": { "editContact": {
"message": "Edit Contact" "message": "Edit Contact"
}, },
@ -686,6 +722,9 @@
"editGasEducationModalTitle": { "editGasEducationModalTitle": {
"message": "How to choose?" "message": "How to choose?"
}, },
"editGasFeeModalTitle": {
"message": "Edit gas fee"
},
"editGasHigh": { "editGasHigh": {
"message": "High" "message": "High"
}, },
@ -891,6 +930,9 @@
"etherscanView": { "etherscanView": {
"message": "View account on Etherscan" "message": "View account on Etherscan"
}, },
"etherscanViewOn": {
"message": "View on Etherscan"
},
"expandView": { "expandView": {
"message": "Expand view" "message": "Expand view"
}, },
@ -984,6 +1026,9 @@
"message": "Gas limit must be at least $1", "message": "Gas limit must be at least $1",
"description": "$1 is the custom gas limit, in decimal." "description": "$1 is the custom gas limit, in decimal."
}, },
"gasOption": {
"message": "Gas option"
},
"gasPrice": { "gasPrice": {
"message": "Gas Price (GWEI)" "message": "Gas Price (GWEI)"
}, },
@ -1002,10 +1047,18 @@
"gasPriceInfoTooltipContent": { "gasPriceInfoTooltipContent": {
"message": "Gas price specifies the amount of Ether you are willing to pay for each unit of gas." "message": "Gas price specifies the amount of Ether you are willing to pay for each unit of gas."
}, },
"gasTimingHoursShort": {
"message": "$1 hrs",
"description": "$1 represents a number of hours"
},
"gasTimingMinutes": { "gasTimingMinutes": {
"message": "$1 minutes", "message": "$1 minutes",
"description": "$1 represents a number of minutes" "description": "$1 represents a number of minutes"
}, },
"gasTimingMinutesShort": {
"message": "$1 min",
"description": "$1 represents a number of minutes"
},
"gasTimingNegative": { "gasTimingNegative": {
"message": "Maybe in $1", "message": "Maybe in $1",
"description": "$1 represents an amount of time" "description": "$1 represents an amount of time"
@ -1018,6 +1071,10 @@
"message": "$1 seconds", "message": "$1 seconds",
"description": "$1 represents a number of seconds" "description": "$1 represents a number of seconds"
}, },
"gasTimingSecondsShort": {
"message": "$1 sec",
"description": "$1 represents a number of seconds"
},
"gasTimingVeryPositive": { "gasTimingVeryPositive": {
"message": "Very likely in < $1", "message": "Very likely in < $1",
"description": "$1 represents an amount of time" "description": "$1 represents an amount of time"
@ -1100,9 +1157,15 @@
"hideZeroBalanceTokens": { "hideZeroBalanceTokens": {
"message": "Hide Tokens Without Balance" "message": "Hide Tokens Without Balance"
}, },
"high": {
"message": "Aggressive"
},
"history": { "history": {
"message": "History" "message": "History"
}, },
"id": {
"message": "ID"
},
"import": { "import": {
"message": "Import", "message": "Import",
"description": "Button to import an account from a selected file" "description": "Button to import an account from a selected file"
@ -1117,7 +1180,7 @@
"message": "import using Secret Recovery Phrase" "message": "import using Secret Recovery Phrase"
}, },
"importAccountMsg": { "importAccountMsg": {
"message": " Imported accounts will not be associated with your originally created MetaMask account Secret Recovery Phrase. Learn more about imported accounts " "message": "Imported accounts will not be associated with your originally created MetaMask account Secret Recovery Phrase. Learn more about imported accounts"
}, },
"importAccountSeedPhrase": { "importAccountSeedPhrase": {
"message": "Import a wallet with Secret Recovery Phrase" "message": "Import a wallet with Secret Recovery Phrase"
@ -1336,6 +1399,12 @@
"lockTimeTooGreat": { "lockTimeTooGreat": {
"message": "Lock time is too great" "message": "Lock time is too great"
}, },
"low": {
"message": "Low"
},
"lowPriorityMessage": {
"message": "Future transactions will queue after this one. This price was last seen was some time ago."
},
"mainnet": { "mainnet": {
"message": "Ethereum Mainnet" "message": "Ethereum Mainnet"
}, },
@ -1349,12 +1418,18 @@
"max": { "max": {
"message": "Max" "message": "Max"
}, },
"maxBaseFee": {
"message": "Max base fee"
},
"maxFee": { "maxFee": {
"message": "Max fee" "message": "Max fee"
}, },
"maxPriorityFee": { "maxPriorityFee": {
"message": "Max priority fee" "message": "Max priority fee"
}, },
"medium": {
"message": "Market"
},
"memo": { "memo": {
"message": "memo" "message": "memo"
}, },
@ -1538,6 +1613,12 @@
"newContract": { "newContract": {
"message": "New Contract" "message": "New Contract"
}, },
"newNFTsDetected": {
"message": "New NFTs detected"
},
"newNFTsDetectedInfo": {
"message": "One or more new NFTs were detected in your wallet."
},
"newNetworkAdded": { "newNetworkAdded": {
"message": "“$1” was successfully added!" "message": "“$1” was successfully added!"
}, },
@ -1560,9 +1641,15 @@
"message": "Nonce is higher than suggested nonce of $1", "message": "Nonce is higher than suggested nonce of $1",
"description": "The next nonce according to MetaMask's internal logic" "description": "The next nonce according to MetaMask's internal logic"
}, },
"nftTokenIdPlaceholder": {
"message": "Enter the collectible ID"
},
"nfts": { "nfts": {
"message": "NFTs" "message": "NFTs"
}, },
"nickname": {
"message": "Nickname"
},
"noAccountsFound": { "noAccountsFound": {
"message": "No accounts found for the given search query" "message": "No accounts found for the given search query"
}, },
@ -2129,6 +2216,9 @@
"selectHdPath": { "selectHdPath": {
"message": "Select HD Path" "message": "Select HD Path"
}, },
"selectNFTPrivacyPreference": {
"message": "Select NFT privacy preference"
},
"selectPathHelp": { "selectPathHelp": {
"message": "If you don't see the accounts you expect, try switching the HD path." "message": "If you don't see the accounts you expect, try switching the HD path."
}, },
@ -2236,6 +2326,9 @@
"signed": { "signed": {
"message": "Signed" "message": "Signed"
}, },
"simulationErrorMessage": {
"message": "This transaction is expected to fail. Trying to execute it is expected to be expensive but fail, and is not recommended."
},
"skip": { "skip": {
"message": "Skip" "message": "Skip"
}, },
@ -2742,6 +2835,9 @@
"thisWillCreate": { "thisWillCreate": {
"message": "This will create a new wallet and Secret Recovery Phrase" "message": "This will create a new wallet and Secret Recovery Phrase"
}, },
"time": {
"message": "Time"
},
"tips": { "tips": {
"message": "Tips" "message": "Tips"
}, },
@ -2896,6 +2992,9 @@
"tryAgain": { "tryAgain": {
"message": "Try again" "message": "Try again"
}, },
"tryAnywayOption": {
"message": "I will try anyway"
},
"turnOnTokenDetection": { "turnOnTokenDetection": {
"message": "Turn on enhanced token detection" "message": "Turn on enhanced token detection"
}, },

View File

@ -503,7 +503,7 @@
"message": "编辑权限" "message": "编辑权限"
}, },
"encryptionPublicKeyNotice": { "encryptionPublicKeyNotice": {
"message": "$1 希望得到您的加密公钥。同意后该网站将可以您发送加密信息。", "message": "$1 希望得到您的加密公钥。同意后该网站将可以您发送加密信息。",
"description": "$1 is the web3 site name" "description": "$1 is the web3 site name"
}, },
"encryptionPublicKeyRequest": { "encryptionPublicKeyRequest": {

View File

@ -21,6 +21,6 @@
"128": "images/icon-128.png", "128": "images/icon-128.png",
"512": "images/icon-512.png" "512": "images/icon-512.png"
}, },
"name": "__MSG_appName__ Beta", "name": "__MSG_appNameBeta__",
"short_name": "__MSG_appName__ Beta" "short_name": "__MSG_appNameBeta__"
} }

View File

@ -21,6 +21,6 @@
"128": "images/icon-128.png", "128": "images/icon-128.png",
"512": "images/icon-512.png" "512": "images/icon-512.png"
}, },
"name": "__MSG_appName__ Flask", "name": "__MSG_appNameFlask__",
"short_name": "__MSG_appName__ Flask" "short_name": "__MSG_appNameFlask__"
} }

View File

@ -17,13 +17,19 @@ import {
ENVIRONMENT_TYPE_FULLSCREEN, ENVIRONMENT_TYPE_FULLSCREEN,
} from '../../shared/constants/app'; } from '../../shared/constants/app';
import { SECOND } from '../../shared/constants/time'; import { SECOND } from '../../shared/constants/time';
import {
REJECT_NOTFICIATION_CLOSE,
REJECT_NOTFICIATION_CLOSE_SIG,
} from '../../shared/constants/metametrics';
import migrations from './migrations'; import migrations from './migrations';
import Migrator from './lib/migrator'; import Migrator from './lib/migrator';
import ExtensionPlatform from './platforms/extension'; import ExtensionPlatform from './platforms/extension';
import LocalStore from './lib/local-store'; import LocalStore from './lib/local-store';
import ReadOnlyNetworkStore from './lib/network-store'; import ReadOnlyNetworkStore from './lib/network-store';
import createStreamSink from './lib/createStreamSink'; import createStreamSink from './lib/createStreamSink';
import NotificationManager from './lib/notification-manager'; import NotificationManager, {
NOTIFICATION_MANAGER_EVENTS,
} from './lib/notification-manager';
import MetamaskController, { import MetamaskController, {
METAMASK_CONTROLLER_EVENTS, METAMASK_CONTROLLER_EVENTS,
} from './metamask-controller'; } from './metamask-controller';
@ -475,6 +481,69 @@ function setupController(initState, initLangCode) {
extension.browserAction.setBadgeBackgroundColor({ color: '#037DD6' }); extension.browserAction.setBadgeBackgroundColor({ color: '#037DD6' });
} }
notificationManager.on(
NOTIFICATION_MANAGER_EVENTS.POPUP_CLOSED,
rejectUnapprovedNotifications,
);
function rejectUnapprovedNotifications() {
Object.keys(
controller.txController.txStateManager.getUnapprovedTxList(),
).forEach((txId) =>
controller.txController.txStateManager.setTxStatusRejected(txId),
);
controller.messageManager.messages
.filter((msg) => msg.status === 'unapproved')
.forEach((tx) =>
controller.messageManager.rejectMsg(
tx.id,
REJECT_NOTFICIATION_CLOSE_SIG,
),
);
controller.personalMessageManager.messages
.filter((msg) => msg.status === 'unapproved')
.forEach((tx) =>
controller.personalMessageManager.rejectMsg(
tx.id,
REJECT_NOTFICIATION_CLOSE_SIG,
),
);
controller.typedMessageManager.messages
.filter((msg) => msg.status === 'unapproved')
.forEach((tx) =>
controller.typedMessageManager.rejectMsg(
tx.id,
REJECT_NOTFICIATION_CLOSE_SIG,
),
);
controller.decryptMessageManager.messages
.filter((msg) => msg.status === 'unapproved')
.forEach((tx) =>
controller.decryptMessageManager.rejectMsg(
tx.id,
REJECT_NOTFICIATION_CLOSE,
),
);
controller.encryptionPublicKeyManager.messages
.filter((msg) => msg.status === 'unapproved')
.forEach((tx) =>
controller.encryptionPublicKeyManager.rejectMsg(
tx.id,
REJECT_NOTFICIATION_CLOSE,
),
);
// We're specifcally avoid using approvalController directly for better
// Error support during rejection
Object.keys(
controller.permissionsController.approvals.state.pendingApprovals,
).forEach((approvalId) =>
controller.permissionsController.rejectPermissionsRequest(approvalId),
);
updateBadge();
}
return Promise.resolve(); return Promise.resolve();
} }

View File

@ -1,10 +1,13 @@
import { createScaffoldMiddleware, mergeMiddleware } from 'json-rpc-engine'; import { createScaffoldMiddleware, mergeMiddleware } from 'json-rpc-engine';
import createBlockRefMiddleware from 'eth-json-rpc-middleware/block-ref'; import {
import createRetryOnEmptyMiddleware from 'eth-json-rpc-middleware/retryOnEmpty'; createBlockRefMiddleware,
import createBlockCacheMiddleware from 'eth-json-rpc-middleware/block-cache'; createRetryOnEmptyMiddleware,
import createInflightCacheMiddleware from 'eth-json-rpc-middleware/inflight-cache'; createBlockCacheMiddleware,
import createBlockTrackerInspectorMiddleware from 'eth-json-rpc-middleware/block-tracker-inspector'; createInflightCacheMiddleware,
import providerFromMiddleware from 'eth-json-rpc-middleware/providerFromMiddleware'; createBlockTrackerInspectorMiddleware,
providerFromMiddleware,
} from 'eth-json-rpc-middleware';
import createInfuraMiddleware from 'eth-json-rpc-infura'; import createInfuraMiddleware from 'eth-json-rpc-infura';
import { PollingBlockTracker } from 'eth-block-tracker'; import { PollingBlockTracker } from 'eth-block-tracker';

View File

@ -1,10 +1,12 @@
import { createAsyncMiddleware, mergeMiddleware } from 'json-rpc-engine'; import { createAsyncMiddleware, mergeMiddleware } from 'json-rpc-engine';
import createFetchMiddleware from 'eth-json-rpc-middleware/fetch'; import {
import createBlockRefRewriteMiddleware from 'eth-json-rpc-middleware/block-ref-rewrite'; createFetchMiddleware,
import createBlockCacheMiddleware from 'eth-json-rpc-middleware/block-cache'; createBlockRefRewriteMiddleware,
import createInflightMiddleware from 'eth-json-rpc-middleware/inflight-cache'; createBlockCacheMiddleware,
import createBlockTrackerInspectorMiddleware from 'eth-json-rpc-middleware/block-tracker-inspector'; createInflightCacheMiddleware,
import providerFromMiddleware from 'eth-json-rpc-middleware/providerFromMiddleware'; createBlockTrackerInspectorMiddleware,
providerFromMiddleware,
} from 'eth-json-rpc-middleware';
import { PollingBlockTracker } from 'eth-block-tracker'; import { PollingBlockTracker } from 'eth-block-tracker';
import { SECOND } from '../../../../shared/constants/time'; import { SECOND } from '../../../../shared/constants/time';
@ -27,7 +29,7 @@ export default function createJsonRpcClient({ rpcUrl, chainId }) {
createChainIdMiddleware(chainId), createChainIdMiddleware(chainId),
createBlockRefRewriteMiddleware({ blockTracker }), createBlockRefRewriteMiddleware({ blockTracker }),
createBlockCacheMiddleware({ blockTracker }), createBlockCacheMiddleware({ blockTracker }),
createInflightMiddleware(), createInflightCacheMiddleware(),
createBlockTrackerInspectorMiddleware({ blockTracker }), createBlockTrackerInspectorMiddleware({ blockTracker }),
fetchMiddleware, fetchMiddleware,
]); ]);

View File

@ -1,5 +1,5 @@
import { createScaffoldMiddleware, mergeMiddleware } from 'json-rpc-engine'; import { createScaffoldMiddleware, mergeMiddleware } from 'json-rpc-engine';
import createWalletSubprovider from 'eth-json-rpc-middleware/wallet'; import { createWalletMiddleware } from 'eth-json-rpc-middleware';
import { import {
createPendingNonceMiddleware, createPendingNonceMiddleware,
createPendingTxMiddleware, createPendingTxMiddleware,
@ -21,11 +21,10 @@ export default function createMetamaskMiddleware({
}) { }) {
const metamaskMiddleware = mergeMiddleware([ const metamaskMiddleware = mergeMiddleware([
createScaffoldMiddleware({ createScaffoldMiddleware({
// staticSubprovider
eth_syncing: false, eth_syncing: false,
web3_clientVersion: `MetaMask/v${version}`, web3_clientVersion: `MetaMask/v${version}`,
}), }),
createWalletSubprovider({ createWalletMiddleware({
getAccounts, getAccounts,
processTransaction, processTransaction,
processEthSignMessage, processEthSignMessage,

View File

@ -2,7 +2,7 @@ import { strict as assert } from 'assert';
import EventEmitter from 'events'; import EventEmitter from 'events';
import { ComposedStore, ObservableStore } from '@metamask/obs-store'; import { ComposedStore, ObservableStore } from '@metamask/obs-store';
import { JsonRpcEngine } from 'json-rpc-engine'; import { JsonRpcEngine } from 'json-rpc-engine';
import providerFromEngine from 'eth-json-rpc-middleware/providerFromEngine'; import { providerFromEngine } from 'eth-json-rpc-middleware';
import log from 'loglevel'; import log from 'loglevel';
import { import {
createSwappableProxy, createSwappableProxy,
@ -430,7 +430,7 @@ export default class NetworkController extends EventEmitter {
} }
_setProviderAndBlockTracker({ provider, blockTracker }) { _setProviderAndBlockTracker({ provider, blockTracker }) {
// update or intialize proxies // update or initialize proxies
if (this._providerProxy) { if (this._providerProxy) {
this._providerProxy.setTarget(provider); this._providerProxy.setTarget(provider);
} else { } else {

View File

@ -37,6 +37,7 @@ export default class PreferencesController {
// set to true means the dynamic list from the API is being used // set to true means the dynamic list from the API is being used
// set to false will be using the static list from contract-metadata // set to false will be using the static list from contract-metadata
useTokenDetection: false, useTokenDetection: false,
advancedGasFee: null,
// WARNING: Do not use feature flags for security-sensitive things. // WARNING: Do not use feature flags for security-sensitive things.
// Feature flag toggling is available in the global namespace // Feature flag toggling is available in the global namespace
@ -129,6 +130,16 @@ export default class PreferencesController {
this.store.updateState({ useTokenDetection: val }); this.store.updateState({ useTokenDetection: val });
} }
/**
* Setter for the `advancedGasFee` property
*
* @param {object} val - holds the maxBaseFee and PriorityFee that the user set as default advanced settings.
*
*/
setAdvancedGasFee(val) {
this.store.updateState({ advancedGasFee: val });
}
/** /**
* Add new methodData to state, to avoid requesting this information again through Infura * Add new methodData to state, to avoid requesting this information again through Infura
* *

View File

@ -266,4 +266,28 @@ describe('preferences controller', function () {
); );
}); });
}); });
describe('setAdvancedGasFee', function () {
it('should default to null', function () {
const state = preferencesController.store.getState();
assert.equal(state.advancedGasFee, null);
});
it('should set the setAdvancedGasFee property in state', function () {
const state = preferencesController.store.getState();
assert.equal(state.advancedGasFee, null);
preferencesController.setAdvancedGasFee({
maxBaseFee: '1.5',
priorityFee: '2',
});
assert.equal(
preferencesController.store.getState().advancedGasFee.maxBaseFee,
'1.5',
);
assert.equal(
preferencesController.store.getState().advancedGasFee.priorityFee,
'2',
);
});
});
}); });

View File

@ -28,6 +28,7 @@ import {
} from '../../../ui/pages/swaps/swaps.util'; } from '../../../ui/pages/swaps/swaps.util';
import fetchWithCache from '../../../ui/helpers/utils/fetch-with-cache'; import fetchWithCache from '../../../ui/helpers/utils/fetch-with-cache';
import { MINUTE, SECOND } from '../../../shared/constants/time'; import { MINUTE, SECOND } from '../../../shared/constants/time';
import { isEqualCaseInsensitive } from '../../../ui/helpers/utils/util';
import { NETWORK_EVENTS } from './network'; import { NETWORK_EVENTS } from './network';
// The MAX_GAS_LIMIT is a number that is higher than the maximum gas costs we have observed on any aggregator // The MAX_GAS_LIMIT is a number that is higher than the maximum gas costs we have observed on any aggregator
@ -91,7 +92,7 @@ export default class SwapsController {
networkController, networkController,
provider, provider,
getProviderConfig, getProviderConfig,
tokenRatesStore, getTokenRatesState,
fetchTradesInfo = defaultFetchTradesInfo, fetchTradesInfo = defaultFetchTradesInfo,
getCurrentChainId, getCurrentChainId,
getEIP1559GasFeeEstimates, getEIP1559GasFeeEstimates,
@ -105,7 +106,7 @@ export default class SwapsController {
this._getEIP1559GasFeeEstimates = getEIP1559GasFeeEstimates; this._getEIP1559GasFeeEstimates = getEIP1559GasFeeEstimates;
this.getBufferedGasLimit = getBufferedGasLimit; this.getBufferedGasLimit = getBufferedGasLimit;
this.tokenRatesStore = tokenRatesStore; this.getTokenRatesState = getTokenRatesState;
this.pollCount = 0; this.pollCount = 0;
this.getProviderConfig = getProviderConfig; this.getProviderConfig = getProviderConfig;
@ -280,7 +281,7 @@ export default class SwapsController {
// For a user to be able to swap a token, they need to have approved the MetaSwap contract to withdraw that token. // For a user to be able to swap a token, they need to have approved the MetaSwap contract to withdraw that token.
// _getERC20Allowance() returns the amount of the token they have approved for withdrawal. If that amount is greater // _getERC20Allowance() returns the amount of the token they have approved for withdrawal. If that amount is greater
// than 0, it means that approval has already occured and is not needed. Otherwise, for tokens to be swapped, a new // than 0, it means that approval has already occurred and is not needed. Otherwise, for tokens to be swapped, a new
// call of the ERC-20 approve method is required. // call of the ERC-20 approve method is required.
approvalRequired = approvalRequired =
allowance.eq(0) && allowance.eq(0) &&
@ -610,7 +611,9 @@ export default class SwapsController {
} }
async _findTopQuoteAndCalculateSavings(quotes = {}) { async _findTopQuoteAndCalculateSavings(quotes = {}) {
const tokenConversionRates = this.tokenRatesStore.contractExchangeRates; const {
contractExchangeRates: tokenConversionRates,
} = this.getTokenRatesState();
const { const {
swapsState: { customGasPrice, customMaxPriorityFeePerGas }, swapsState: { customGasPrice, customMaxPriorityFeePerGas },
} = this.store.getState(); } = this.store.getState();
@ -734,7 +737,12 @@ export default class SwapsController {
decimalAdjustedDestinationAmount, decimalAdjustedDestinationAmount,
); );
const tokenConversionRate = tokenConversionRates[destinationToken]; const tokenConversionRate =
tokenConversionRates[
Object.keys(tokenConversionRates).find((tokenAddress) =>
isEqualCaseInsensitive(tokenAddress, destinationToken),
)
];
const conversionRateForSorting = tokenConversionRate || 1; const conversionRateForSorting = tokenConversionRate || 1;
const ethValueOfTokens = decimalAdjustedDestinationAmount.times( const ethValueOfTokens = decimalAdjustedDestinationAmount.times(
@ -777,7 +785,17 @@ export default class SwapsController {
isSwapsDefaultTokenAddress( isSwapsDefaultTokenAddress(
newQuotes[topAggId].destinationToken, newQuotes[topAggId].destinationToken,
chainId, chainId,
) || Boolean(tokenConversionRates[newQuotes[topAggId]?.destinationToken]); ) ||
Boolean(
tokenConversionRates[
Object.keys(tokenConversionRates).find((tokenAddress) =>
isEqualCaseInsensitive(
tokenAddress,
newQuotes[topAggId]?.destinationToken,
),
)
],
);
let savings = null; let savings = null;

View File

@ -82,12 +82,12 @@ const MOCK_FETCH_METADATA = {
chainId: MAINNET_CHAIN_ID, chainId: MAINNET_CHAIN_ID,
}; };
const MOCK_TOKEN_RATES_STORE = { const MOCK_TOKEN_RATES_STORE = () => ({
contractExchangeRates: { contractExchangeRates: {
'0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': 2, '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': 2,
'0x1111111111111111111111111111111111111111': 0.1, '0x1111111111111111111111111111111111111111': 0.1,
}, },
}; });
const MOCK_GET_PROVIDER_CONFIG = () => ({ type: 'FAKE_NETWORK' }); const MOCK_GET_PROVIDER_CONFIG = () => ({ type: 'FAKE_NETWORK' });
@ -161,7 +161,7 @@ describe('SwapsController', function () {
networkController: getMockNetworkController(), networkController: getMockNetworkController(),
provider, provider,
getProviderConfig: MOCK_GET_PROVIDER_CONFIG, getProviderConfig: MOCK_GET_PROVIDER_CONFIG,
tokenRatesStore: MOCK_TOKEN_RATES_STORE, getTokenRatesState: MOCK_TOKEN_RATES_STORE,
fetchTradesInfo: fetchTradesInfoStub, fetchTradesInfo: fetchTradesInfoStub,
getCurrentChainId: getCurrentChainIdStub, getCurrentChainId: getCurrentChainIdStub,
getEIP1559GasFeeEstimates: getEIP1559GasFeeEstimatesStub, getEIP1559GasFeeEstimates: getEIP1559GasFeeEstimatesStub,
@ -211,7 +211,7 @@ describe('SwapsController', function () {
networkController, networkController,
provider, provider,
getProviderConfig: MOCK_GET_PROVIDER_CONFIG, getProviderConfig: MOCK_GET_PROVIDER_CONFIG,
tokenRatesStore: MOCK_TOKEN_RATES_STORE, getTokenRatesState: MOCK_TOKEN_RATES_STORE,
fetchTradesInfo: fetchTradesInfoStub, fetchTradesInfo: fetchTradesInfoStub,
getCurrentChainId: getCurrentChainIdStub, getCurrentChainId: getCurrentChainIdStub,
}); });
@ -235,7 +235,7 @@ describe('SwapsController', function () {
networkController, networkController,
provider, provider,
getProviderConfig: MOCK_GET_PROVIDER_CONFIG, getProviderConfig: MOCK_GET_PROVIDER_CONFIG,
tokenRatesStore: MOCK_TOKEN_RATES_STORE, getTokenRatesState: MOCK_TOKEN_RATES_STORE,
fetchTradesInfo: fetchTradesInfoStub, fetchTradesInfo: fetchTradesInfoStub,
getCurrentChainId: getCurrentChainIdStub, getCurrentChainId: getCurrentChainIdStub,
}); });
@ -259,7 +259,7 @@ describe('SwapsController', function () {
networkController, networkController,
provider, provider,
getProviderConfig: MOCK_GET_PROVIDER_CONFIG, getProviderConfig: MOCK_GET_PROVIDER_CONFIG,
tokenRatesStore: MOCK_TOKEN_RATES_STORE, getTokenRatesState: MOCK_TOKEN_RATES_STORE,
fetchTradesInfo: fetchTradesInfoStub, fetchTradesInfo: fetchTradesInfoStub,
getCurrentChainId: getCurrentChainIdStub, getCurrentChainId: getCurrentChainIdStub,
}); });
@ -816,9 +816,10 @@ describe('SwapsController', function () {
.stub(swapsController, '_getERC20Allowance') .stub(swapsController, '_getERC20Allowance')
.resolves(ethers.BigNumber.from(1)); .resolves(ethers.BigNumber.from(1));
swapsController.tokenRatesStore = { swapsController.getTokenRatesState = () => ({
contractExchangeRates: {}, contractExchangeRates: {},
}; });
const [newQuotes, topAggId] = await swapsController.fetchAndSetQuotes( const [newQuotes, topAggId] = await swapsController.fetchAndSetQuotes(
MOCK_FETCH_PARAMS, MOCK_FETCH_PARAMS,
MOCK_FETCH_METADATA, MOCK_FETCH_METADATA,

View File

@ -8,7 +8,7 @@ const Box = process.env.IN_TEST
import log from 'loglevel'; import log from 'loglevel';
import { JsonRpcEngine } from 'json-rpc-engine'; import { JsonRpcEngine } from 'json-rpc-engine';
import providerFromEngine from 'eth-json-rpc-middleware/providerFromEngine'; import { providerFromEngine } from 'eth-json-rpc-middleware';
import Migrator from '../lib/migrator'; import Migrator from '../lib/migrator';
import migrations from '../migrations'; import migrations from '../migrations';
import createOriginMiddleware from '../lib/createOriginMiddleware'; import createOriginMiddleware from '../lib/createOriginMiddleware';

View File

@ -26,6 +26,7 @@ import {
TRANSACTION_TYPES, TRANSACTION_TYPES,
TRANSACTION_ENVELOPE_TYPES, TRANSACTION_ENVELOPE_TYPES,
} from '../../../../shared/constants/transaction'; } from '../../../../shared/constants/transaction';
import { TRANSACTION_ENVELOPE_TYPE_NAMES } from '../../../../ui/helpers/constants/transactions';
import { METAMASK_CONTROLLER_EVENTS } from '../../metamask-controller'; import { METAMASK_CONTROLLER_EVENTS } from '../../metamask-controller';
import { import {
GAS_LIMITS, GAS_LIMITS,
@ -1447,8 +1448,8 @@ export default class TransactionController extends EventEmitter {
sensitiveProperties: { sensitiveProperties: {
status, status,
transaction_envelope_type: isEIP1559Transaction(txMeta) transaction_envelope_type: isEIP1559Transaction(txMeta)
? 'fee-market' ? TRANSACTION_ENVELOPE_TYPE_NAMES.FEE_MARKET
: 'legacy', : TRANSACTION_ENVELOPE_TYPE_NAMES.LEGACY,
first_seen: time, first_seen: time,
gas_limit: gasLimit, gas_limit: gasLimit,
...gasParamsInGwei, ...gasParamsInGwei,

View File

@ -20,6 +20,7 @@ import {
GAS_ESTIMATE_TYPES, GAS_ESTIMATE_TYPES,
GAS_RECOMMENDATIONS, GAS_RECOMMENDATIONS,
} from '../../../../shared/constants/gas'; } from '../../../../shared/constants/gas';
import { TRANSACTION_ENVELOPE_TYPE_NAMES } from '../../../../ui/helpers/constants/transactions';
import { METAMASK_CONTROLLER_EVENTS } from '../../metamask-controller'; import { METAMASK_CONTROLLER_EVENTS } from '../../metamask-controller';
import TransactionController, { TRANSACTION_EVENTS } from '.'; import TransactionController, { TRANSACTION_EVENTS } from '.';
@ -774,7 +775,7 @@ describe('Transaction Controller', function () {
nonce: '0x4b', nonce: '0x4b',
}, },
type: TRANSACTION_TYPES.SIMPLE_SEND, type: TRANSACTION_TYPES.SIMPLE_SEND,
transaction_envelope_type: 'legacy', transaction_envelope_type: TRANSACTION_ENVELOPE_TYPE_NAMES.LEGACY,
origin: 'metamask', origin: 'metamask',
chainId: currentChainId, chainId: currentChainId,
time: 1624408066355, time: 1624408066355,
@ -1578,7 +1579,7 @@ describe('Transaction Controller', function () {
gas_price: '2', gas_price: '2',
gas_limit: '0x7b0d', gas_limit: '0x7b0d',
first_seen: 1624408066355, first_seen: 1624408066355,
transaction_envelope_type: 'legacy', transaction_envelope_type: TRANSACTION_ENVELOPE_TYPE_NAMES.LEGACY,
status: 'unapproved', status: 'unapproved',
}, },
}; };
@ -1625,7 +1626,7 @@ describe('Transaction Controller', function () {
gas_price: '2', gas_price: '2',
gas_limit: '0x7b0d', gas_limit: '0x7b0d',
first_seen: 1624408066355, first_seen: 1624408066355,
transaction_envelope_type: 'legacy', transaction_envelope_type: TRANSACTION_ENVELOPE_TYPE_NAMES.LEGACY,
status: 'unapproved', status: 'unapproved',
}, },
}; };
@ -1674,7 +1675,7 @@ describe('Transaction Controller', function () {
gas_price: '2', gas_price: '2',
gas_limit: '0x7b0d', gas_limit: '0x7b0d',
first_seen: 1624408066355, first_seen: 1624408066355,
transaction_envelope_type: 'legacy', transaction_envelope_type: TRANSACTION_ENVELOPE_TYPE_NAMES.LEGACY,
status: 'unapproved', status: 'unapproved',
}, },
}; };
@ -1731,7 +1732,7 @@ describe('Transaction Controller', function () {
max_priority_fee_per_gas: '2', max_priority_fee_per_gas: '2',
gas_limit: '0x7b0d', gas_limit: '0x7b0d',
first_seen: 1624408066355, first_seen: 1624408066355,
transaction_envelope_type: 'fee-market', transaction_envelope_type: TRANSACTION_ENVELOPE_TYPE_NAMES.FEE_MARKET,
status: 'unapproved', status: 'unapproved',
estimate_suggested: GAS_RECOMMENDATIONS.MEDIUM, estimate_suggested: GAS_RECOMMENDATIONS.MEDIUM,
estimate_used: GAS_RECOMMENDATIONS.HIGH, estimate_used: GAS_RECOMMENDATIONS.HIGH,

View File

@ -1,12 +1,13 @@
import log from 'loglevel'; import log from 'loglevel';
import { METASWAP_CHAINID_API_HOST_MAP } from '../../../shared/constants/swaps'; import { SWAPS_API_V2_BASE_URL } from '../../../shared/constants/swaps';
import { import {
GOERLI_CHAIN_ID, GOERLI_CHAIN_ID,
KOVAN_CHAIN_ID, KOVAN_CHAIN_ID,
MAINNET_CHAIN_ID, MAINNET_CHAIN_ID,
RINKEBY_CHAIN_ID, RINKEBY_CHAIN_ID,
ROPSTEN_CHAIN_ID, ROPSTEN_CHAIN_ID,
MAINNET_NETWORK_ID,
} from '../../../shared/constants/network'; } from '../../../shared/constants/network';
import { SECOND } from '../../../shared/constants/time'; import { SECOND } from '../../../shared/constants/time';
import getFetchWithTimeout from '../../../shared/modules/fetch-with-timeout'; import getFetchWithTimeout from '../../../shared/modules/fetch-with-timeout';
@ -20,7 +21,7 @@ const fetchWithTimeout = getFetchWithTimeout(SECOND * 30);
* @returns String * @returns String
*/ */
const createWyrePurchaseUrl = async (address) => { const createWyrePurchaseUrl = async (address) => {
const fiatOnRampUrlApi = `${METASWAP_CHAINID_API_HOST_MAP[MAINNET_CHAIN_ID]}/fiatOnRampUrl?serviceName=wyre&destinationAddress=${address}`; const fiatOnRampUrlApi = `${SWAPS_API_V2_BASE_URL}/networks/${MAINNET_NETWORK_ID}/fiatOnRampUrl?serviceName=wyre&destinationAddress=${address}`;
const wyrePurchaseUrlFallback = `https://pay.sendwyre.com/purchase?dest=ethereum:${address}&destCurrency=ETH&accountId=AC-7AG3W4XH4N2&paymentMethod=debit-card`; const wyrePurchaseUrlFallback = `https://pay.sendwyre.com/purchase?dest=ethereum:${address}&destCurrency=ETH&accountId=AC-7AG3W4XH4N2&paymentMethod=debit-card`;
try { try {
const response = await fetchWithTimeout(fiatOnRampUrlApi, { const response = await fetchWithTimeout(fiatOnRampUrlApi, {

View File

@ -7,6 +7,7 @@ import {
ROPSTEN_CHAIN_ID, ROPSTEN_CHAIN_ID,
} from '../../../shared/constants/network'; } from '../../../shared/constants/network';
import { TRANSAK_API_KEY } from '../constants/on-ramp'; import { TRANSAK_API_KEY } from '../constants/on-ramp';
import { SWAPS_API_V2_BASE_URL } from '../../../shared/constants/swaps';
import getBuyEthUrl from './buy-eth-url'; import getBuyEthUrl from './buy-eth-url';
const WYRE_ACCOUNT_ID = 'AC-7AG3W4XH4N2'; const WYRE_ACCOUNT_ID = 'AC-7AG3W4XH4N2';
@ -28,8 +29,10 @@ const KOVAN = {
describe('buy-eth-url', function () { describe('buy-eth-url', function () {
it('returns Wyre url with an ETH address for Ethereum mainnet', async function () { it('returns Wyre url with an ETH address for Ethereum mainnet', async function () {
nock('https://api.metaswap.codefi.network') nock(SWAPS_API_V2_BASE_URL)
.get(`/fiatOnRampUrl?serviceName=wyre&destinationAddress=${ETH_ADDRESS}`) .get(
`/networks/1/fiatOnRampUrl?serviceName=wyre&destinationAddress=${ETH_ADDRESS}`,
)
.reply(200, { .reply(200, {
url: `https://pay.sendwyre.com/purchase?accountId=${WYRE_ACCOUNT_ID}&utm_campaign=${WYRE_ACCOUNT_ID}&destCurrency=ETH&utm_medium=widget&paymentMethod=debit-card&reservation=MLZVUF8FMXZUMARJC23B&dest=ethereum%3A${ETH_ADDRESS}&utm_source=checkout`, url: `https://pay.sendwyre.com/purchase?accountId=${WYRE_ACCOUNT_ID}&utm_campaign=${WYRE_ACCOUNT_ID}&destCurrency=ETH&utm_medium=widget&paymentMethod=debit-card&reservation=MLZVUF8FMXZUMARJC23B&dest=ethereum%3A${ETH_ADDRESS}&utm_source=checkout`,
}); });

View File

@ -38,13 +38,14 @@ export default class DecryptMessageManager extends EventEmitter {
* @property {Array} messages Holds all messages that have been created by this DecryptMessageManager * @property {Array} messages Holds all messages that have been created by this DecryptMessageManager
* *
*/ */
constructor() { constructor(opts) {
super(); super();
this.memStore = new ObservableStore({ this.memStore = new ObservableStore({
unapprovedDecryptMsgs: {}, unapprovedDecryptMsgs: {},
unapprovedDecryptMsgCount: 0, unapprovedDecryptMsgCount: 0,
}); });
this.messages = []; this.messages = [];
this.metricsEvent = opts.metricsEvent;
} }
/** /**
@ -237,7 +238,16 @@ export default class DecryptMessageManager extends EventEmitter {
* @param {number} msgId The id of the DecryptMessage to reject. * @param {number} msgId The id of the DecryptMessage to reject.
* *
*/ */
rejectMsg(msgId) { rejectMsg(msgId, reason = undefined) {
if (reason) {
this.metricsEvent({
event: reason,
category: 'Messages',
properties: {
action: 'Decrypt Message Request',
},
});
}
this._setMsgStatus(msgId, 'rejected'); this._setMsgStatus(msgId, 'rejected');
} }

View File

@ -34,13 +34,14 @@ export default class EncryptionPublicKeyManager extends EventEmitter {
* @property {Array} messages Holds all messages that have been created by this EncryptionPublicKeyManager * @property {Array} messages Holds all messages that have been created by this EncryptionPublicKeyManager
* *
*/ */
constructor() { constructor(opts) {
super(); super();
this.memStore = new ObservableStore({ this.memStore = new ObservableStore({
unapprovedEncryptionPublicKeyMsgs: {}, unapprovedEncryptionPublicKeyMsgs: {},
unapprovedEncryptionPublicKeyMsgCount: 0, unapprovedEncryptionPublicKeyMsgCount: 0,
}); });
this.messages = []; this.messages = [];
this.metricsEvent = opts.metricsEvent;
} }
/** /**
@ -226,7 +227,16 @@ export default class EncryptionPublicKeyManager extends EventEmitter {
* @param {number} msgId The id of the EncryptionPublicKey to reject. * @param {number} msgId The id of the EncryptionPublicKey to reject.
* *
*/ */
rejectMsg(msgId) { rejectMsg(msgId, reason = undefined) {
if (reason) {
this.metricsEvent({
event: reason,
category: 'Messages',
properties: {
action: 'Encryption public key Request',
},
});
}
this._setMsgStatus(msgId, 'rejected'); this._setMsgStatus(msgId, 'rejected');
} }

View File

@ -35,13 +35,14 @@ export default class MessageManager extends EventEmitter {
* @property {Array} messages Holds all messages that have been created by this MessageManager * @property {Array} messages Holds all messages that have been created by this MessageManager
* *
*/ */
constructor() { constructor({ metricsEvent }) {
super(); super();
this.memStore = new ObservableStore({ this.memStore = new ObservableStore({
unapprovedMsgs: {}, unapprovedMsgs: {},
unapprovedMsgCount: 0, unapprovedMsgCount: 0,
}); });
this.messages = []; this.messages = [];
this.metricsEvent = metricsEvent;
} }
/** /**
@ -78,9 +79,9 @@ export default class MessageManager extends EventEmitter {
* @returns {promise} after signature has been * @returns {promise} after signature has been
* *
*/ */
addUnapprovedMessageAsync(msgParams, req) { async addUnapprovedMessageAsync(msgParams, req) {
return new Promise((resolve, reject) => { const msgId = this.addUnapprovedMessage(msgParams, req);
const msgId = this.addUnapprovedMessage(msgParams, req); return await new Promise((resolve, reject) => {
// await finished // await finished
this.once(`${msgId}:finished`, (data) => { this.once(`${msgId}:finished`, (data) => {
switch (data.status) { switch (data.status) {
@ -92,6 +93,10 @@ export default class MessageManager extends EventEmitter {
'MetaMask Message Signature: User denied message signature.', 'MetaMask Message Signature: User denied message signature.',
), ),
); );
case 'errored':
return reject(
new Error(`MetaMask Message Signature: ${data.error}`),
);
default: default:
return reject( return reject(
new Error( new Error(
@ -217,10 +222,34 @@ export default class MessageManager extends EventEmitter {
* @param {number} msgId - The id of the Message to reject. * @param {number} msgId - The id of the Message to reject.
* *
*/ */
rejectMsg(msgId) { rejectMsg(msgId, reason = undefined) {
if (reason) {
const msg = this.getMsg(msgId);
this.metricsEvent({
event: reason,
category: 'Transactions',
properties: {
action: 'Sign Request',
type: msg.type,
},
});
}
this._setMsgStatus(msgId, 'rejected'); this._setMsgStatus(msgId, 'rejected');
} }
/**
* Sets a Message status to 'errored' via a call to this._setMsgStatus.
*
* @param {number} msgId - The id of the Message to error
*
*/
errorMessage(msgId, error) {
const msg = this.getMsg(msgId);
msg.error = error;
this._updateMsg(msg);
this._setMsgStatus(msgId, 'errored');
}
/** /**
* Clears all unapproved messages from memory. * Clears all unapproved messages from memory.
*/ */
@ -292,7 +321,7 @@ export default class MessageManager extends EventEmitter {
* @returns {string} A hex string conversion of the buffer data * @returns {string} A hex string conversion of the buffer data
* *
*/ */
function normalizeMsgData(data) { export function normalizeMsgData(data) {
if (data.slice(0, 2) === '0x') { if (data.slice(0, 2) === '0x') {
// data is already hex // data is already hex
return data; return data;

View File

@ -1,4 +1,5 @@
import { strict as assert } from 'assert'; import { strict as assert } from 'assert';
import sinon from 'sinon';
import { TRANSACTION_STATUSES } from '../../../shared/constants/transaction'; import { TRANSACTION_STATUSES } from '../../../shared/constants/transaction';
import MessageManager from './message-manager'; import MessageManager from './message-manager';
@ -6,7 +7,9 @@ describe('Message Manager', function () {
let messageManager; let messageManager;
beforeEach(function () { beforeEach(function () {
messageManager = new MessageManager(); messageManager = new MessageManager({
metricsEvent: sinon.fake(),
});
}); });
describe('#getMsgList', function () { describe('#getMsgList', function () {

View File

@ -1,9 +1,14 @@
import EventEmitter from 'safe-event-emitter';
import ExtensionPlatform from '../platforms/extension'; import ExtensionPlatform from '../platforms/extension';
const NOTIFICATION_HEIGHT = 620; const NOTIFICATION_HEIGHT = 620;
const NOTIFICATION_WIDTH = 360; const NOTIFICATION_WIDTH = 360;
export default class NotificationManager { export const NOTIFICATION_MANAGER_EVENTS = {
POPUP_CLOSED: 'onPopupClosed',
};
export default class NotificationManager extends EventEmitter {
/** /**
* A collection of methods for controlling the showing and hiding of the notification popup. * A collection of methods for controlling the showing and hiding of the notification popup.
* *
@ -12,7 +17,9 @@ export default class NotificationManager {
*/ */
constructor() { constructor() {
super();
this.platform = new ExtensionPlatform(); this.platform = new ExtensionPlatform();
this.platform.addOnRemovedListener(this._onWindowClosed.bind(this));
} }
/** /**
@ -62,6 +69,13 @@ export default class NotificationManager {
} }
} }
_onWindowClosed(windowId) {
if (windowId === this._popupId) {
this._popupId = undefined;
this.emit(NOTIFICATION_MANAGER_EVENTS.POPUP_CLOSED);
}
}
/** /**
* Checks all open MetaMask windows, and returns the first one it finds that is a notification window (i.e. has the * Checks all open MetaMask windows, and returns the first one it finds that is a notification window (i.e. has the
* type 'popup') * type 'popup')

View File

@ -40,13 +40,14 @@ export default class PersonalMessageManager extends EventEmitter {
* @property {Array} messages Holds all messages that have been created by this PersonalMessageManager * @property {Array} messages Holds all messages that have been created by this PersonalMessageManager
* *
*/ */
constructor() { constructor({ metricsEvent }) {
super(); super();
this.memStore = new ObservableStore({ this.memStore = new ObservableStore({
unapprovedPersonalMsgs: {}, unapprovedPersonalMsgs: {},
unapprovedPersonalMsgCount: 0, unapprovedPersonalMsgCount: 0,
}); });
this.messages = []; this.messages = [];
this.metricsEvent = metricsEvent;
} }
/** /**
@ -106,6 +107,9 @@ export default class PersonalMessageManager extends EventEmitter {
), ),
); );
return; return;
case 'errored':
reject(new Error(`MetaMask Message Signature: ${data.error}`));
return;
default: default:
reject( reject(
new Error( new Error(
@ -238,10 +242,34 @@ export default class PersonalMessageManager extends EventEmitter {
* @param {number} msgId - The id of the PersonalMessage to reject. * @param {number} msgId - The id of the PersonalMessage to reject.
* *
*/ */
rejectMsg(msgId) { rejectMsg(msgId, reason = undefined) {
if (reason) {
const msg = this.getMsg(msgId);
this.metricsEvent({
event: reason,
category: 'Transactions',
properties: {
action: 'Sign Request',
type: msg.type,
},
});
}
this._setMsgStatus(msgId, 'rejected'); this._setMsgStatus(msgId, 'rejected');
} }
/**
* Sets a Message status to 'errored' via a call to this._setMsgStatus.
*
* @param {number} msgId - The id of the Message to error
*
*/
errorMessage(msgId, error) {
const msg = this.getMsg(msgId);
msg.error = error;
this._updateMsg(msg);
this._setMsgStatus(msgId, 'errored');
}
/** /**
* Clears all unapproved messages from memory. * Clears all unapproved messages from memory.
*/ */

View File

@ -1,4 +1,5 @@
import { strict as assert } from 'assert'; import { strict as assert } from 'assert';
import sinon from 'sinon';
import { TRANSACTION_STATUSES } from '../../../shared/constants/transaction'; import { TRANSACTION_STATUSES } from '../../../shared/constants/transaction';
import PersonalMessageManager from './personal-message-manager'; import PersonalMessageManager from './personal-message-manager';
@ -6,7 +7,7 @@ describe('Personal Message Manager', function () {
let messageManager; let messageManager;
beforeEach(function () { beforeEach(function () {
messageManager = new PersonalMessageManager(); messageManager = new PersonalMessageManager({ metricsEvent: sinon.fake() });
}); });
describe('#getMsgList', function () { describe('#getMsgList', function () {

View File

@ -1,8 +1,10 @@
import { ethErrors } from 'eth-rpc-errors';
import { UNSUPPORTED_RPC_METHODS } from '../../../../shared/constants/network';
import handlers from './handlers'; import handlers from './handlers';
const handlerMap = handlers.reduce((map, handler) => { const handlerMap = handlers.reduce((map, handler) => {
for (const methodName of handler.methodNames) { for (const methodName of handler.methodNames) {
map.set(methodName, handler.implementation); map.set(methodName, handler);
} }
return map; return map;
}, new Map()); }, new Map());
@ -21,14 +23,41 @@ const handlerMap = handlers.reduce((map, handler) => {
* Eventually, we'll want to extract this middleware into its own package. * Eventually, we'll want to extract this middleware into its own package.
* *
* @param {Object} opts - The middleware options * @param {Object} opts - The middleware options
* @param {Function} opts.sendMetrics - A function for sending a metrics event
* @returns {(req: Object, res: Object, next: Function, end: Function) => void} * @returns {(req: Object, res: Object, next: Function, end: Function) => void}
*/ */
export default function createMethodMiddleware(opts) { export default function createMethodMiddleware(opts) {
return function methodMiddleware(req, res, next, end) { return function methodMiddleware(req, res, next, end) {
if (handlerMap.has(req.method)) { // Reject unsupported methods.
return handlerMap.get(req.method)(req, res, next, end, opts); if (UNSUPPORTED_RPC_METHODS.has(req.method)) {
return end(ethErrors.rpc.methodNotSupported());
} }
const handler = handlerMap.get(req.method);
if (handler) {
const { implementation, hookNames } = handler;
return implementation(req, res, next, end, selectHooks(opts, hookNames));
}
return next(); return next();
}; };
} }
/**
* Returns the subset of the specified `hooks` that are included in the
* `hookNames` object. This is a Principle of Least Authority (POLA) measure
* to ensure that each RPC method implementation only has access to the
* API "hooks" it needs to do its job.
*
* @param {Record<string, unknown>} hooks - The hooks to select from.
* @param {Record<string, true>} hookNames - The names of the hooks to select.
* @returns {Record<string, unknown> | undefined} The selected hooks.
*/
function selectHooks(hooks, hookNames) {
if (hookNames) {
return Object.keys(hookNames).reduce((hookSubset, hookName) => {
hookSubset[hookName] = hooks[hookName];
return hookSubset;
}, {});
}
return undefined;
}

View File

@ -12,6 +12,14 @@ import { CHAIN_ID_TO_NETWORK_ID_MAP } from '../../../../../shared/constants/netw
const addEthereumChain = { const addEthereumChain = {
methodNames: [MESSAGE_TYPE.ADD_ETHEREUM_CHAIN], methodNames: [MESSAGE_TYPE.ADD_ETHEREUM_CHAIN],
implementation: addEthereumChainHandler, implementation: addEthereumChainHandler,
hookNames: {
addCustomRpc: true,
getCurrentChainId: true,
findCustomRpcBy: true,
updateRpcTarget: true,
requestUserApproval: true,
sendMetrics: true,
},
}; };
export default addEthereumChain; export default addEthereumChain;

View File

@ -9,6 +9,9 @@ import { MESSAGE_TYPE } from '../../../../../shared/constants/app';
const getProviderState = { const getProviderState = {
methodNames: [MESSAGE_TYPE.GET_PROVIDER_STATE], methodNames: [MESSAGE_TYPE.GET_PROVIDER_STATE],
implementation: getProviderStateHandler, implementation: getProviderStateHandler,
hookNames: {
getProviderState: true,
},
}; };
export default getProviderState; export default getProviderState;

View File

@ -10,6 +10,11 @@ import { MESSAGE_TYPE } from '../../../../../shared/constants/app';
const logWeb3ShimUsage = { const logWeb3ShimUsage = {
methodNames: [MESSAGE_TYPE.LOG_WEB3_SHIM_USAGE], methodNames: [MESSAGE_TYPE.LOG_WEB3_SHIM_USAGE],
implementation: logWeb3ShimUsageHandler, implementation: logWeb3ShimUsageHandler,
hookNames: {
sendMetrics: true,
getWeb3ShimUsageState: true,
setWeb3ShimUsageRecorded: true,
},
}; };
export default logWeb3ShimUsage; export default logWeb3ShimUsage;

View File

@ -15,6 +15,13 @@ import {
const switchEthereumChain = { const switchEthereumChain = {
methodNames: [MESSAGE_TYPE.SWITCH_ETHEREUM_CHAIN], methodNames: [MESSAGE_TYPE.SWITCH_ETHEREUM_CHAIN],
implementation: switchEthereumChainHandler, implementation: switchEthereumChainHandler,
hookNames: {
getCurrentChainId: true,
findCustomRpcBy: true,
setProviderType: true,
updateRpcTarget: true,
requestUserApproval: true,
},
}; };
export default switchEthereumChain; export default switchEthereumChain;

View File

@ -3,6 +3,9 @@ import { MESSAGE_TYPE } from '../../../../../shared/constants/app';
const watchAsset = { const watchAsset = {
methodNames: [MESSAGE_TYPE.WATCH_ASSET, MESSAGE_TYPE.WATCH_ASSET_LEGACY], methodNames: [MESSAGE_TYPE.WATCH_ASSET, MESSAGE_TYPE.WATCH_ASSET_LEGACY],
implementation: watchAssetHandler, implementation: watchAssetHandler,
hookNames: {
handleWatchAssetRequest: true,
},
}; };
export default watchAsset; export default watchAsset;

View File

@ -32,7 +32,7 @@ export default class TypedMessageManager extends EventEmitter {
/** /**
* Controller in charge of managing - storing, adding, removing, updating - TypedMessage. * Controller in charge of managing - storing, adding, removing, updating - TypedMessage.
*/ */
constructor({ getCurrentChainId }) { constructor({ getCurrentChainId, metricEvents }) {
super(); super();
this._getCurrentChainId = getCurrentChainId; this._getCurrentChainId = getCurrentChainId;
this.memStore = new ObservableStore({ this.memStore = new ObservableStore({
@ -40,6 +40,7 @@ export default class TypedMessageManager extends EventEmitter {
unapprovedTypedMessagesCount: 0, unapprovedTypedMessagesCount: 0,
}); });
this.messages = []; this.messages = [];
this.metricEvents = metricEvents;
} }
/** /**
@ -301,7 +302,19 @@ export default class TypedMessageManager extends EventEmitter {
* @param {number} msgId - The id of the TypedMessage to reject. * @param {number} msgId - The id of the TypedMessage to reject.
* *
*/ */
rejectMsg(msgId) { rejectMsg(msgId, reason = undefined) {
if (reason) {
const msg = this.getMsg(msgId);
this.metricsEvent({
event: reason,
category: 'Transactions',
properties: {
action: 'Sign Request',
version: msg.msgParams.version,
type: msg.type,
},
});
}
this._setMsgStatus(msgId, 'rejected'); this._setMsgStatus(msgId, 'rejected');
} }

View File

@ -17,6 +17,7 @@ describe('Typed Message Manager', function () {
beforeEach(async function () { beforeEach(async function () {
typedMessageManager = new TypedMessageManager({ typedMessageManager = new TypedMessageManager({
getCurrentChainId: sinon.fake.returns('0x1'), getCurrentChainId: sinon.fake.returns('0x1'),
metricsEvent: sinon.fake(),
}); });
msgParamsV1 = { msgParamsV1 = {

View File

@ -7,7 +7,7 @@ import { debounce } from 'lodash';
import createEngineStream from 'json-rpc-middleware-stream/engineStream'; import createEngineStream from 'json-rpc-middleware-stream/engineStream';
import createFilterMiddleware from 'eth-json-rpc-filters'; import createFilterMiddleware from 'eth-json-rpc-filters';
import createSubscriptionManager from 'eth-json-rpc-filters/subscriptionManager'; import createSubscriptionManager from 'eth-json-rpc-filters/subscriptionManager';
import providerAsMiddleware from 'eth-json-rpc-middleware/providerAsMiddleware'; import { providerAsMiddleware } from 'eth-json-rpc-middleware';
import KeyringController from 'eth-keyring-controller'; import KeyringController from 'eth-keyring-controller';
import { Mutex } from 'await-semaphore'; import { Mutex } from 'await-semaphore';
import { stripHexPrefix } from 'ethereumjs-util'; import { stripHexPrefix } from 'ethereumjs-util';
@ -17,6 +17,7 @@ import LedgerBridgeKeyring from '@metamask/eth-ledger-bridge-keyring';
import LatticeKeyring from 'eth-lattice-keyring'; import LatticeKeyring from 'eth-lattice-keyring';
import EthQuery from 'eth-query'; import EthQuery from 'eth-query';
import nanoid from 'nanoid'; import nanoid from 'nanoid';
import { ethErrors } from 'eth-rpc-errors';
import { captureException } from '@sentry/browser'; import { captureException } from '@sentry/browser';
import { import {
AddressBookController, AddressBookController,
@ -29,6 +30,9 @@ import {
TokenListController, TokenListController,
TokensController, TokensController,
TokenRatesController, TokenRatesController,
CollectiblesController,
AssetsContractController,
CollectibleDetectionController,
} from '@metamask/controllers'; } from '@metamask/controllers';
import { TRANSACTION_STATUSES } from '../../shared/constants/transaction'; import { TRANSACTION_STATUSES } from '../../shared/constants/transaction';
import { import {
@ -61,7 +65,7 @@ import AlertController from './controllers/alert';
import OnboardingController from './controllers/onboarding'; import OnboardingController from './controllers/onboarding';
import ThreeBoxController from './controllers/threebox'; import ThreeBoxController from './controllers/threebox';
import IncomingTransactionsController from './controllers/incoming-transactions'; import IncomingTransactionsController from './controllers/incoming-transactions';
import MessageManager from './lib/message-manager'; import MessageManager, { normalizeMsgData } from './lib/message-manager';
import DecryptMessageManager from './lib/decrypt-message-manager'; import DecryptMessageManager from './lib/decrypt-message-manager';
import EncryptionPublicKeyManager from './lib/encryption-public-key-manager'; import EncryptionPublicKeyManager from './lib/encryption-public-key-manager';
import PersonalMessageManager from './lib/personal-message-manager'; import PersonalMessageManager from './lib/personal-message-manager';
@ -175,6 +179,57 @@ export default class MetamaskController extends EventEmitter {
state: initState.TokensController, state: initState.TokensController,
}); });
this.assetsContractController = new AssetsContractController();
this.collectiblesController = new CollectiblesController({
onPreferencesStateChange: this.preferencesController.store.subscribe.bind(
this.preferencesController.store,
),
onNetworkStateChange: this.networkController.store.subscribe.bind(
this.networkController.store,
),
getAssetName: this.assetsContractController.getAssetName.bind(
this.assetsContractController,
),
getAssetSymbol: this.assetsContractController.getAssetSymbol.bind(
this.assetsContractController,
),
getCollectibleTokenURI: this.assetsContractController.getCollectibleTokenURI.bind(
this.assetsContractController,
),
getOwnerOf: this.assetsContractController.getOwnerOf.bind(
this.assetsContractController,
),
balanceOfERC1155Collectible: this.assetsContractController.balanceOfERC1155Collectible.bind(
this.assetsContractController,
),
uriERC1155Collectible: this.assetsContractController.uriERC1155Collectible.bind(
this.assetsContractController,
),
});
process.env.COLLECTIBLES_V1 &&
(this.collectibleDetectionController = new CollectibleDetectionController(
{
onCollectiblesStateChange: (listener) =>
this.collectiblesController.subscribe(listener),
onPreferencesStateChange: this.preferencesController.store.subscribe.bind(
this.preferencesController.store,
),
onNetworkStateChange: this.networkController.store.subscribe.bind(
this.networkController.store,
),
getOpenSeaApiKey: () => this.collectiblesController.openSeaApiKey,
getBalancesInSingleCall: this.assetsContractController.getBalancesInSingleCall.bind(
this.assetsContractController,
),
addCollectible: this.collectiblesController.addCollectible.bind(
this.collectiblesController,
),
getCollectiblesState: () => this.collectiblesController.state,
},
));
this.metaMetricsController = new MetaMetricsController({ this.metaMetricsController = new MetaMetricsController({
segment, segment,
preferencesStore: this.preferencesController.store, preferencesStore: this.preferencesController.store,
@ -526,14 +581,33 @@ export default class MetamaskController extends EventEmitter {
} }
}); });
this.networkController.lookupNetwork(); this.networkController.lookupNetwork();
this.messageManager = new MessageManager(); this.messageManager = new MessageManager({
this.personalMessageManager = new PersonalMessageManager(); metricsEvent: this.metaMetricsController.trackEvent.bind(
this.decryptMessageManager = new DecryptMessageManager(); this.metaMetricsController,
this.encryptionPublicKeyManager = new EncryptionPublicKeyManager(); ),
});
this.personalMessageManager = new PersonalMessageManager({
metricsEvent: this.metaMetricsController.trackEvent.bind(
this.metaMetricsController,
),
});
this.decryptMessageManager = new DecryptMessageManager({
metricsEvent: this.metaMetricsController.trackEvent.bind(
this.metaMetricsController,
),
});
this.encryptionPublicKeyManager = new EncryptionPublicKeyManager({
metricsEvent: this.metaMetricsController.trackEvent.bind(
this.metaMetricsController,
),
});
this.typedMessageManager = new TypedMessageManager({ this.typedMessageManager = new TypedMessageManager({
getCurrentChainId: this.networkController.getCurrentChainId.bind( getCurrentChainId: this.networkController.getCurrentChainId.bind(
this.networkController, this.networkController,
), ),
metricsEvent: this.metaMetricsController.trackEvent.bind(
this.metaMetricsController,
),
}); });
this.swapsController = new SwapsController({ this.swapsController = new SwapsController({
@ -545,7 +619,7 @@ export default class MetamaskController extends EventEmitter {
getProviderConfig: this.networkController.getProviderConfig.bind( getProviderConfig: this.networkController.getProviderConfig.bind(
this.networkController, this.networkController,
), ),
tokenRatesStore: this.tokenRatesController.state, getTokenRatesState: () => this.tokenRatesController.state,
getCurrentChainId: this.networkController.getCurrentChainId.bind( getCurrentChainId: this.networkController.getCurrentChainId.bind(
this.networkController, this.networkController,
), ),
@ -592,6 +666,7 @@ export default class MetamaskController extends EventEmitter {
GasFeeController: this.gasFeeController, GasFeeController: this.gasFeeController,
TokenListController: this.tokenListController, TokenListController: this.tokenListController,
TokensController: this.tokensController, TokensController: this.tokensController,
CollectiblesController: this.collectiblesController,
}); });
this.memStore = new ComposableObservableStore({ this.memStore = new ComposableObservableStore({
@ -626,6 +701,7 @@ export default class MetamaskController extends EventEmitter {
GasFeeController: this.gasFeeController, GasFeeController: this.gasFeeController,
TokenListController: this.tokenListController, TokenListController: this.tokenListController,
TokensController: this.tokensController, TokensController: this.tokensController,
CollectiblesController: this.collectiblesController,
}, },
controllerMessenger: this.controllerMessenger, controllerMessenger: this.controllerMessenger,
}); });
@ -807,6 +883,7 @@ export default class MetamaskController extends EventEmitter {
threeBoxController, threeBoxController,
txController, txController,
tokensController, tokensController,
collectiblesController,
} = this; } = this;
return { return {
@ -924,6 +1001,26 @@ export default class MetamaskController extends EventEmitter {
this.preferencesController.setDismissSeedBackUpReminder, this.preferencesController.setDismissSeedBackUpReminder,
this.preferencesController, this.preferencesController,
), ),
setAdvancedGasFee: nodeify(
preferencesController.setAdvancedGasFee,
preferencesController,
),
// CollectiblesController
addCollectible: nodeify(
collectiblesController.addCollectible,
collectiblesController,
),
removeAndIgnoreCollectible: nodeify(
collectiblesController.removeAndIgnoreCollectible,
collectiblesController,
),
removeCollectible: nodeify(
collectiblesController.removeCollectible,
collectiblesController,
),
// AddressController // AddressController
setAddressBook: nodeify( setAddressBook: nodeify(
@ -985,9 +1082,7 @@ export default class MetamaskController extends EventEmitter {
), ),
createCancelTransaction: nodeify(this.createCancelTransaction, this), createCancelTransaction: nodeify(this.createCancelTransaction, this),
createSpeedUpTransaction: nodeify(this.createSpeedUpTransaction, this), createSpeedUpTransaction: nodeify(this.createSpeedUpTransaction, this),
isNonceTaken: nodeify(txController.isNonceTaken, txController),
estimateGas: nodeify(this.estimateGas, this), estimateGas: nodeify(this.estimateGas, this),
getPendingNonce: nodeify(this.getPendingNonce, this),
getNextNonce: nodeify(this.getNextNonce, this), getNextNonce: nodeify(this.getNextNonce, this),
addUnapprovedTransaction: nodeify( addUnapprovedTransaction: nodeify(
txController.addUnapprovedTransaction, txController.addUnapprovedTransaction,
@ -1071,13 +1166,6 @@ export default class MetamaskController extends EventEmitter {
permissionsController.approvePermissionsRequest, permissionsController.approvePermissionsRequest,
permissionsController, permissionsController,
), ),
clearPermissions: permissionsController.clearPermissions.bind(
permissionsController,
),
getApprovedAccounts: nodeify(
permissionsController.getAccounts,
permissionsController,
),
rejectPermissionsRequest: nodeify( rejectPermissionsRequest: nodeify(
permissionsController.rejectPermissionsRequest, permissionsController.rejectPermissionsRequest,
permissionsController, permissionsController,
@ -1232,6 +1320,14 @@ export default class MetamaskController extends EventEmitter {
this.detectTokensController.detectNewTokens, this.detectTokensController.detectNewTokens,
this.detectTokensController, this.detectTokensController,
), ),
// DetectCollectibleController
detectCollectibles: process.env.COLLECTIBLES_V1
? nodeify(
this.collectibleDetectionController.detectCollectibles,
this.collectibleDetectionController,
)
: null,
}; };
} }
@ -1829,14 +1925,22 @@ export default class MetamaskController extends EventEmitter {
* @param {Object} msgParams - The params passed to eth_sign. * @param {Object} msgParams - The params passed to eth_sign.
* @param {Function} cb - The callback function called with the signature. * @param {Function} cb - The callback function called with the signature.
*/ */
newUnsignedMessage(msgParams, req) { async newUnsignedMessage(msgParams, req) {
const promise = this.messageManager.addUnapprovedMessageAsync( const data = normalizeMsgData(msgParams.data);
msgParams, let promise;
req, // 64 hex + "0x" at the beginning
); // This is needed because Ethereum's EcSign works only on 32 byte numbers
this.sendUpdate(); // For 67 length see: https://github.com/MetaMask/metamask-extension/pull/12679/files#r749479607
this.opts.showUserConfirmation(); if (data.length === 66 || data.length === 67) {
return promise; promise = this.messageManager.addUnapprovedMessageAsync(msgParams, req);
this.sendUpdate();
this.opts.showUserConfirmation();
} else {
throw ethErrors.rpc.invalidParams(
'eth_sign requires 32 byte message hash',
);
}
return await promise;
} }
/** /**
@ -1845,24 +1949,23 @@ export default class MetamaskController extends EventEmitter {
* @param {Object} msgParams - The params passed to eth_call. * @param {Object} msgParams - The params passed to eth_call.
* @returns {Promise<Object>} Full state update. * @returns {Promise<Object>} Full state update.
*/ */
signMessage(msgParams) { async signMessage(msgParams) {
log.info('MetaMaskController - signMessage'); log.info('MetaMaskController - signMessage');
const msgId = msgParams.metamaskId; const msgId = msgParams.metamaskId;
try {
// sets the status op the message to 'approved' // sets the status op the message to 'approved'
// and removes the metamaskId for signing // and removes the metamaskId for signing
return this.messageManager const cleanMsgParams = await this.messageManager.approveMessage(
.approveMessage(msgParams) msgParams,
.then((cleanMsgParams) => { );
// signs the message const rawSig = await this.keyringController.signMessage(cleanMsgParams);
return this.keyringController.signMessage(cleanMsgParams); this.messageManager.setMsgStatusSigned(msgId, rawSig);
}) return this.getState();
.then((rawSig) => { } catch (error) {
// tells the listener that the message has been signed log.info('MetaMaskController - eth_sign failed', error);
// and can be returned to the dapp this.messageManager.errorMessage(msgId, error);
this.messageManager.setMsgStatusSigned(msgId, rawSig); throw error;
return this.getState(); }
});
} }
/** /**
@ -1909,23 +2012,27 @@ export default class MetamaskController extends EventEmitter {
* @param {Object} msgParams - The params of the message to sign & return to the Dapp. * @param {Object} msgParams - The params of the message to sign & return to the Dapp.
* @returns {Promise<Object>} A full state update. * @returns {Promise<Object>} A full state update.
*/ */
signPersonalMessage(msgParams) { async signPersonalMessage(msgParams) {
log.info('MetaMaskController - signPersonalMessage'); log.info('MetaMaskController - signPersonalMessage');
const msgId = msgParams.metamaskId; const msgId = msgParams.metamaskId;
// sets the status op the message to 'approved' // sets the status op the message to 'approved'
// and removes the metamaskId for signing // and removes the metamaskId for signing
return this.personalMessageManager try {
.approveMessage(msgParams) const cleanMsgParams = await this.personalMessageManager.approveMessage(
.then((cleanMsgParams) => { msgParams,
// signs the message );
return this.keyringController.signPersonalMessage(cleanMsgParams); const rawSig = await this.keyringController.signPersonalMessage(
}) cleanMsgParams,
.then((rawSig) => { );
// tells the listener that the message has been signed // tells the listener that the message has been signed
// and can be returned to the dapp // and can be returned to the dapp
this.personalMessageManager.setMsgStatusSigned(msgId, rawSig); this.personalMessageManager.setMsgStatusSigned(msgId, rawSig);
return this.getState(); return this.getState();
}); } catch (error) {
log.info('MetaMaskController - eth_personalSign failed', error);
this.personalMessageManager.errorMessage(msgId, error);
throw error;
}
} }
/** /**

View File

@ -835,7 +835,8 @@ describe('MetaMaskController', function () {
let msgParams, metamaskMsgs, messages, msgId; let msgParams, metamaskMsgs, messages, msgId;
const address = '0xc42edfcc21ed14dda456aa0756c153f7985d8813'; const address = '0xc42edfcc21ed14dda456aa0756c153f7985d8813';
const data = '0x43727970746f6b697474696573'; const data =
'0x0000000000000000000000000000000000000043727970746f6b697474696573';
beforeEach(async function () { beforeEach(async function () {
sandbox.stub(metamaskController, 'getBalance'); sandbox.stub(metamaskController, 'getBalance');
@ -885,6 +886,19 @@ describe('MetaMaskController', function () {
assert.equal(messages[0].status, TRANSACTION_STATUSES.REJECTED); assert.equal(messages[0].status, TRANSACTION_STATUSES.REJECTED);
}); });
it('checks message length', async function () {
msgParams = {
from: address,
data: '0xDEADBEEF',
};
try {
await metamaskController.newUnsignedMessage(msgParams);
} catch (error) {
assert.equal(error.message, 'eth_sign requires 32 byte message hash');
}
});
it('errors when signing a message', async function () { it('errors when signing a message', async function () {
try { try {
await metamaskController.signMessage(messages[0].msgParams); await metamaskController.signMessage(messages[0].msgParams);

View File

@ -162,6 +162,10 @@ export default class ExtensionPlatform {
} }
} }
addOnRemovedListener(listener) {
extension.windows.onRemoved.addListener(listener);
}
getAllWindows() { getAllWindows() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
extension.windows.getAll((windows) => { extension.windows.getAll((windows) => {

View File

@ -358,10 +358,14 @@ function createFactoredBuild({
// lavamoat will add lavapack but it will be removed by bify-module-groups // lavamoat will add lavapack but it will be removed by bify-module-groups
// we will re-add it later by installing a lavapack runtime // we will re-add it later by installing a lavapack runtime
const lavamoatOpts = { const lavamoatOpts = {
policy: path.resolve(__dirname, '../../lavamoat/browserify/policy.json'), policy: path.resolve(
__dirname,
`../../lavamoat/browserify/${buildType}/policy.json`,
),
policyName: buildType,
policyOverride: path.resolve( policyOverride: path.resolve(
__dirname, __dirname,
'../../lavamoat/browserify/policy-override.json', `../../lavamoat/browserify/${buildType}/policy-override.json`,
), ),
writeAutoPolicy: process.env.WRITE_AUTO_POLICY, writeAutoPolicy: process.env.WRITE_AUTO_POLICY,
}; };
@ -456,7 +460,7 @@ function createFactoredBuild({
groupSet, groupSet,
commonSet, commonSet,
browserPlatforms, browserPlatforms,
useLavamoat: false, useLavamoat: true,
}); });
break; break;
} }

View File

@ -41,11 +41,16 @@ class RemoveFencedCodeTransform extends Transform {
// stream, immediately before the "end" event is emitted. // stream, immediately before the "end" event is emitted.
// It applies the transform to the concatenated file contents. // It applies the transform to the concatenated file contents.
_flush(end) { _flush(end) {
const [fileContent, didModify] = removeFencedCode( let fileContent, didModify;
this.filePath, try {
this.buildType, [fileContent, didModify] = removeFencedCode(
Buffer.concat(this._fileBuffers).toString('utf8'), this.filePath,
); this.buildType,
Buffer.concat(this._fileBuffers).toString('utf8'),
);
} catch (error) {
return end(error);
}
const pushAndEnd = () => { const pushAndEnd = () => {
this.push(fileContent); this.push(fileContent);
@ -53,12 +58,11 @@ class RemoveFencedCodeTransform extends Transform {
}; };
if (this.shouldLintTransformedFiles && didModify) { if (this.shouldLintTransformedFiles && didModify) {
lintTransformedFile(fileContent, this.filePath) return lintTransformedFile(fileContent, this.filePath)
.then(pushAndEnd) .then(pushAndEnd)
.catch((error) => end(error)); .catch((error) => end(error));
} else {
pushAndEnd();
} }
return pushAndEnd();
} }
} }

View File

@ -161,6 +161,28 @@ describe('build/transforms/remove-fenced-code', () => {
}); });
}); });
it('handles error during code fence removal or parsing', async () => {
const fileContent = getMinimalFencedCode().concat(
'///: END:ONLY_INCLUDE_IN',
);
const stream = createRemoveFencedCodeTransform('main')(mockJsFileName);
await new Promise((resolve) => {
stream.on('error', (error) => {
expect(error.message).toStrictEqual(
expect.stringContaining(
'A valid fence consists of two fence lines, but the file contains an uneven number, "3", of fence lines.',
),
);
expect(lintTransformedFileMock).toHaveBeenCalledTimes(0);
resolve();
});
stream.end(fileContent);
});
});
it('handles transformed file lint failure', async () => { it('handles transformed file lint failure', async () => {
lintTransformedFileMock.mockImplementationOnce(() => lintTransformedFileMock.mockImplementationOnce(() =>
Promise.reject(new Error('lint failure')), Promise.reject(new Error('lint failure')),

View File

@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -e
set -u
set -o pipefail
# Generate LavaMoat policies for the extension background script for each build
# type.
# ATTN: This may tax your device when running it locally.
concurrently --kill-others-on-fail -n main,beta,flask \
"WRITE_AUTO_POLICY=1 yarn dist" \
"WRITE_AUTO_POLICY=1 yarn dist --build-type beta" \
"WRITE_AUTO_POLICY=1 yarn dist --build-type flask"

View File

@ -216,7 +216,12 @@ async function verifyEnglishLocale() {
} }
// never consider these messages as unused // never consider these messages as unused
const messageExceptions = ['appName', 'appDescription']; const messageExceptions = [
'appName',
'appNameBeta',
'appNameFlask',
'appDescription',
];
const englishMessages = Object.keys(englishLocale); const englishMessages = Object.keys(englishLocale);
const unusedMessages = englishMessages.filter( const unusedMessages = englishMessages.filter(

View File

@ -325,6 +325,7 @@
"@ethersproject/bignumber": true, "@ethersproject/bignumber": true,
"@ethersproject/bytes": true, "@ethersproject/bytes": true,
"@ethersproject/keccak256": true, "@ethersproject/keccak256": true,
"@ethersproject/logger": true,
"@ethersproject/sha2": true, "@ethersproject/sha2": true,
"@ethersproject/strings": true "@ethersproject/strings": true
} }
@ -525,6 +526,7 @@
"ethjs-util": true, "ethjs-util": true,
"events": true, "events": true,
"human-standard-collectible-abi": true, "human-standard-collectible-abi": true,
"human-standard-multi-collectible-abi": true,
"human-standard-token-abi": true, "human-standard-token-abi": true,
"immer": true, "immer": true,
"isomorphic-fetch": true, "isomorphic-fetch": true,
@ -1555,11 +1557,15 @@
}, },
"eth-json-rpc-middleware": { "eth-json-rpc-middleware": {
"globals": { "globals": {
"URL": true,
"btoa": true,
"console.error": true, "console.error": true,
"fetch": true, "fetch": true,
"setTimeout": true "setTimeout": true
}, },
"packages": { "packages": {
"@metamask/safe-event-emitter": true,
"browser-resolve": true,
"btoa": true, "btoa": true,
"clone": true, "clone": true,
"eth-rpc-errors": true, "eth-rpc-errors": true,

View File

@ -0,0 +1,55 @@
{
"resources": {
"browser-resolve": {
"packages": {
"core-js": true
}
},
"babel-runtime": {
"packages": {
"@babel/runtime": true
}
},
"node-fetch": {
"globals": {
"fetch": true
}
},
"lodash": {
"globals": {
"setTimeout": true,
"clearTimeout": true
}
},
"@ethersproject/random": {
"globals": {
"crypto.getRandomValues": true
}
},
"browser-passworder": {
"globals": {
"crypto": true
}
},
"randombytes": {
"globals": {
"crypto.getRandomValues": true
}
},
"extensionizer": {
"globals": {
"console": true
}
},
"web3": {
"globals": {
"XMLHttpRequest": true
}
},
"storage": {
"globals": {
"localStorage": true
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,55 @@
{
"resources": {
"browser-resolve": {
"packages": {
"core-js": true
}
},
"babel-runtime": {
"packages": {
"@babel/runtime": true
}
},
"node-fetch": {
"globals": {
"fetch": true
}
},
"lodash": {
"globals": {
"setTimeout": true,
"clearTimeout": true
}
},
"@ethersproject/random": {
"globals": {
"crypto.getRandomValues": true
}
},
"browser-passworder": {
"globals": {
"crypto": true
}
},
"randombytes": {
"globals": {
"crypto.getRandomValues": true
}
},
"extensionizer": {
"globals": {
"console": true
}
},
"web3": {
"globals": {
"XMLHttpRequest": true
}
},
"storage": {
"globals": {
"localStorage": true
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -12,7 +12,7 @@
"start": "yarn build:dev dev", "start": "yarn build:dev dev",
"start:lavamoat": "yarn build dev", "start:lavamoat": "yarn build dev",
"dist": "yarn build prod", "dist": "yarn build prod",
"build": "lavamoat development/build/index.js", "build": "yarn lavamoat:build",
"build:dev": "node development/build/index.js", "build:dev": "node development/build/index.js",
"start:test": "yarn build testDev", "start:test": "yarn build testDev",
"benchmark:chrome": "SELENIUM_BROWSER=chrome node test/e2e/benchmark.js", "benchmark:chrome": "SELENIUM_BROWSER=chrome node test/e2e/benchmark.js",
@ -41,8 +41,9 @@
"test:coverage:path": "nyc --check-coverage yarn test:unit:path", "test:coverage:path": "nyc --check-coverage yarn test:unit:path",
"ganache:start": "./development/run-ganache.sh", "ganache:start": "./development/run-ganache.sh",
"sentry:publish": "node ./development/sentry-publish.js", "sentry:publish": "node ./development/sentry-publish.js",
"lint": "prettier --check '**/*.json' && eslint . --ext js,snap --cache && yarn lint:styles", "lint:prettier": "prettier '**/*.json'",
"lint:fix": "prettier --write '**/*.json' && eslint . --ext js --cache --fix", "lint": "yarn lint:prettier --check '**/*.json' && eslint . --ext js,snap --cache && yarn lint:styles",
"lint:fix": "yarn lint:prettier --write '**/*.json' && eslint . --ext js --cache --fix",
"lint:changed": "{ git ls-files --others --exclude-standard ; git diff-index --name-only --diff-filter=d HEAD ; } | grep --regexp='[.]js$' | tr '\\n' '\\0' | xargs -0 eslint", "lint:changed": "{ git ls-files --others --exclude-standard ; git diff-index --name-only --diff-filter=d HEAD ; } | grep --regexp='[.]js$' | tr '\\n' '\\0' | xargs -0 eslint",
"lint:changed:fix": "{ git ls-files --others --exclude-standard ; git diff-index --name-only --diff-filter=d HEAD ; } | grep --regexp='[.]js$' | tr '\\n' '\\0' | xargs -0 eslint --fix", "lint:changed:fix": "{ git ls-files --others --exclude-standard ; git diff-index --name-only --diff-filter=d HEAD ; } | grep --regexp='[.]js$' | tr '\\n' '\\0' | xargs -0 eslint --fix",
"lint:changelog": "auto-changelog validate", "lint:changelog": "auto-changelog validate",
@ -63,9 +64,10 @@
"storybook:deploy": "storybook-to-ghpages --existing-output-dir storybook-build --remote storybook --branch master", "storybook:deploy": "storybook-to-ghpages --existing-output-dir storybook-build --remote storybook --branch master",
"update-changelog": "auto-changelog update", "update-changelog": "auto-changelog update",
"generate:migration": "./development/generate-migration.sh", "generate:migration": "./development/generate-migration.sh",
"lavamoat:build:auto": "lavamoat ./development/build/index.js --writeAutoPolicy", "lavamoat:build": "lavamoat development/build/index.js --policy lavamoat/build-system/policy.json --policyOverride lavamoat/build-system/policy-override.json",
"lavamoat:debug:build": "lavamoat ./development/build/index.js --writeAutoPolicyDebug", "lavamoat:build:auto": "yarn lavamoat:build --writeAutoPolicy",
"lavamoat:background:auto": "WRITE_AUTO_POLICY=1 yarn build prod", "lavamoat:debug:build": "yarn lavamoat:build --writeAutoPolicyDebug --policydebug lavamoat/build-system/policy-debug.json",
"lavamoat:background:auto": "./development/generate-lavamoat-policies.sh",
"lavamoat:auto": "yarn lavamoat:build:auto && yarn lavamoat:background:auto" "lavamoat:auto": "yarn lavamoat:build:auto && yarn lavamoat:background:auto"
}, },
"resolutions": { "resolutions": {
@ -91,7 +93,8 @@
"netmask": "^2.0.1", "netmask": "^2.0.1",
"pubnub/superagent-proxy": "^3.0.0", "pubnub/superagent-proxy": "^3.0.0",
"pull-ws": "^3.3.2", "pull-ws": "^3.3.2",
"ws": "^7.4.6" "ws": "^7.4.6",
"json-schema": "^0.4.0"
}, },
"dependencies": { "dependencies": {
"3box": "^1.10.2", "3box": "^1.10.2",
@ -105,7 +108,7 @@
"@fortawesome/fontawesome-free": "^5.13.0", "@fortawesome/fontawesome-free": "^5.13.0",
"@material-ui/core": "^4.11.0", "@material-ui/core": "^4.11.0",
"@metamask/contract-metadata": "^1.28.0", "@metamask/contract-metadata": "^1.28.0",
"@metamask/controllers": "^17.0.0", "@metamask/controllers": "^20.0.0",
"@metamask/eth-ledger-bridge-keyring": "^0.10.0", "@metamask/eth-ledger-bridge-keyring": "^0.10.0",
"@metamask/eth-token-tracker": "^3.0.1", "@metamask/eth-token-tracker": "^3.0.1",
"@metamask/etherscan-link": "^2.1.0", "@metamask/etherscan-link": "^2.1.0",
@ -135,7 +138,7 @@
"eth-ens-namehash": "^2.0.8", "eth-ens-namehash": "^2.0.8",
"eth-json-rpc-filters": "^4.2.1", "eth-json-rpc-filters": "^4.2.1",
"eth-json-rpc-infura": "^5.1.0", "eth-json-rpc-infura": "^5.1.0",
"eth-json-rpc-middleware": "^6.0.0", "eth-json-rpc-middleware": "^8.0.0",
"eth-keyring-controller": "^6.2.0", "eth-keyring-controller": "^6.2.0",
"eth-lattice-keyring": "^0.4.0", "eth-lattice-keyring": "^0.4.0",
"eth-method-registry": "^2.0.0", "eth-method-registry": "^2.0.0",
@ -221,7 +224,7 @@
"@babel/preset-react": "^7.0.0", "@babel/preset-react": "^7.0.0",
"@babel/register": "^7.5.5", "@babel/register": "^7.5.5",
"@lavamoat/allow-scripts": "^1.0.6", "@lavamoat/allow-scripts": "^1.0.6",
"@lavamoat/lavapack": "^2.0.3", "@lavamoat/lavapack": "^2.0.4",
"@metamask/auto-changelog": "^2.1.0", "@metamask/auto-changelog": "^2.1.0",
"@metamask/eslint-config": "^6.0.0", "@metamask/eslint-config": "^6.0.0",
"@metamask/eslint-config-jest": "^6.0.0", "@metamask/eslint-config-jest": "^6.0.0",

View File

@ -30,6 +30,17 @@ export const GAS_RECOMMENDATIONS = {
HIGH: 'high', HIGH: 'high',
}; };
/**
* These represent types of gas estimation
*/
export const PRIORITY_LEVELS = {
LOW: 'low',
MEDIUM: 'medium',
HIGH: 'high',
CUSTOM: 'custom',
DAPP_SUGGESTED: 'dappSuggested',
};
/** /**
* Represents the user customizing their gas preference * Represents the user customizing their gas preference
*/ */

View File

@ -140,3 +140,7 @@ export const METAMETRICS_BACKGROUND_PAGE_OBJECT = {
* @property {() => void} identify - Identify an anonymous user. We do not * @property {() => void} identify - Identify an anonymous user. We do not
* currently use this method. * currently use this method.
*/ */
export const REJECT_NOTFICIATION_CLOSE = 'Cancel Via Notification Close';
export const REJECT_NOTFICIATION_CLOSE_SIG =
'Cancel Sig Request Via Notification Close';

View File

@ -161,3 +161,13 @@ export const CHAIN_ID_TO_GAS_LIMIT_BUFFER_MAP = {
[OPTIMISM_CHAIN_ID]: 1, [OPTIMISM_CHAIN_ID]: 1,
[OPTIMISM_TESTNET_CHAIN_ID]: 1, [OPTIMISM_TESTNET_CHAIN_ID]: 1,
}; };
/**
* Ethereum JSON-RPC methods that are known to exist but that we intentionally
* do not support.
*/
export const UNSUPPORTED_RPC_METHODS = new Set([
// This is implemented later in our middleware stack specifically, in
// eth-json-rpc-middleware but our UI does not support it.
'eth_signTransaction',
]);

View File

@ -160,6 +160,10 @@
"toNickname": "" "toNickname": ""
}, },
"useTokenDetection": true, "useTokenDetection": true,
"advancedGasFee": {
"maxBaseFee": "1.5",
"priorityFee": "2"
},
"tokenList": { "tokenList": {
"0x2260fac5e5542a773aa44fbcfedf7c193bc2c599": { "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599": {
"address": "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599", "address": "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599",

View File

@ -27,38 +27,72 @@ describe('Metamask Import UI', function () {
async ({ driver }) => { async ({ driver }) => {
await driver.navigate(); await driver.navigate();
// clicks the continue button on the welcome screen if (process.env.ONBOARDING_V2 === '1') {
await driver.findElement('.welcome-page__header'); // welcome
await driver.clickElement({ await driver.clickElement('[data-testid="onboarding-import-wallet"]');
text: enLocaleMessages.getStarted.message,
tag: 'button',
});
// clicks the "Import Wallet" option // metrics
await driver.clickElement({ text: 'Import wallet', tag: 'button' }); await driver.clickElement('[data-testid="metametrics-no-thanks"]');
// clicks the "No thanks" option on the metametrics opt-in screen // import with recovery phrase
await driver.clickElement('.btn-secondary'); await driver.fill('[data-testid="import-srp-text"]', testSeedPhrase);
await driver.clickElement('[data-testid="import-srp-confirm"]');
// Import Secret Recovery Phrase // create password
await driver.fill( await driver.fill(
'input[placeholder="Paste Secret Recovery Phrase from clipboard"]', '[data-testid="create-password-new"]',
testSeedPhrase, 'correct horse battery staple',
); );
await driver.fill(
'[data-testid="create-password-confirm"]',
'correct horse battery staple',
);
await driver.clickElement('[data-testid="create-password-terms"]');
await driver.clickElement('[data-testid="create-password-import"]');
await driver.fill('#password', 'correct horse battery staple'); // complete
await driver.fill('#confirm-password', 'correct horse battery staple'); await driver.clickElement('[data-testid="onboarding-complete-done"]');
await driver.clickElement('.first-time-flow__terms'); // pin extension
await driver.clickElement('[data-testid="pin-extension-next"]');
await driver.clickElement('[data-testid="pin-extension-done"]');
} else {
// clicks the continue button on the welcome screen
await driver.findElement('.welcome-page__header');
await driver.clickElement({
text: enLocaleMessages.getStarted.message,
tag: 'button',
});
await driver.clickElement({ text: 'Import', tag: 'button' }); // clicks the "Import Wallet" option
await driver.clickElement({ text: 'Import wallet', tag: 'button' });
// clicks through the success screen // clicks the "No thanks" option on the metametrics opt-in screen
await driver.findElement({ text: 'Congratulations', tag: 'div' }); await driver.clickElement('.btn-secondary');
await driver.clickElement({
text: enLocaleMessages.endOfFlowMessage10.message, // Import Secret Recovery Phrase
tag: 'button', await driver.fill(
}); 'input[placeholder="Paste Secret Recovery Phrase from clipboard"]',
testSeedPhrase,
);
await driver.fill('#password', 'correct horse battery staple');
await driver.fill(
'#confirm-password',
'correct horse battery staple',
);
await driver.clickElement('.first-time-flow__terms');
await driver.clickElement({ text: 'Import', tag: 'button' });
// clicks through the success screen
await driver.findElement({ text: 'Congratulations', tag: 'div' });
await driver.clickElement({
text: enLocaleMessages.endOfFlowMessage10.message,
tag: 'button',
});
}
// Show account information // Show account information
await driver.clickElement( await driver.clickElement(
@ -233,10 +267,15 @@ describe('Metamask Import UI', function () {
// should remove the account // should remove the account
await driver.clickElement({ text: 'Remove', tag: 'button' }); await driver.clickElement({ text: 'Remove', tag: 'button' });
const currentActiveAccountName = await driver.findElement( // Wait until selected account switches away from removed account to first account
'.selected-account__name', await driver.waitForSelector(
{
css: '.selected-account__name',
text: 'Account 1',
},
{ timeout: 10000 },
); );
assert.equal(await currentActiveAccountName.getText(), 'Account 1');
await driver.delay(regularDelayMs); await driver.delay(regularDelayMs);
await driver.clickElement('.account-menu__icon'); await driver.clickElement('.account-menu__icon');

View File

@ -16,50 +16,6 @@ describe('Metamask Responsive UI', function () {
async ({ driver }) => { async ({ driver }) => {
await driver.navigate(); await driver.navigate();
// clicks the continue button on the welcome screen
await driver.findElement('.welcome-page__header');
await driver.clickElement({
text: enLocaleMessages.getStarted.message,
tag: 'button',
});
await driver.delay(tinyDelayMs);
// clicks the "Create New Wallet" option
await driver.clickElement({ text: 'Create a Wallet', tag: 'button' });
// clicks the "I Agree" option on the metametrics opt-in screen
await driver.clickElement('.btn-primary');
// accepts a secure password
await driver.fill(
'.first-time-flow__form #create-password',
'correct horse battery staple',
);
await driver.fill(
'.first-time-flow__form #confirm-password',
'correct horse battery staple',
);
await driver.clickElement('.first-time-flow__checkbox');
await driver.clickElement('.first-time-flow__form button');
// renders the Secret Recovery Phrase intro screen
await driver.clickElement('.seed-phrase-intro__left button');
// reveals the Secret Recovery Phrase
await driver.clickElement(
'.reveal-seed-phrase__secret-blocker .reveal-seed-phrase__reveal-button',
);
const revealedSeedPhrase = await driver.findElement(
'.reveal-seed-phrase__secret-words',
);
const seedPhrase = await revealedSeedPhrase.getText();
assert.equal(seedPhrase.split(' ').length, 12);
await driver.clickElement({
text: enLocaleMessages.next.message,
tag: 'button',
});
async function clickWordAndWait(word) { async function clickWordAndWait(word) {
await driver.clickElement( await driver.clickElement(
`[data-testid="seed-phrase-sorted"] [data-testid="draggable-seed-${word}"]`, `[data-testid="seed-phrase-sorted"] [data-testid="draggable-seed-${word}"]`,
@ -67,26 +23,126 @@ describe('Metamask Responsive UI', function () {
await driver.delay(tinyDelayMs); await driver.delay(tinyDelayMs);
} }
// can retype the Secret Recovery Phrase if (process.env.ONBOARDING_V2 === '1') {
const words = seedPhrase.split(' '); // welcome
for (const word of words) { await driver.clickElement('[data-testid="onboarding-create-wallet"]');
await clickWordAndWait(word);
// metrics
await driver.clickElement('[data-testid="metametrics-no-thanks"]');
// create password
await driver.fill(
'[data-testid="create-password-new"]',
'correct horse battery staple',
);
await driver.fill(
'[data-testid="create-password-confirm"]',
'correct horse battery staple',
);
await driver.clickElement('[data-testid="create-password-terms"]');
await driver.clickElement('[data-testid="create-password-wallet"]');
// secure wallet
await driver.clickElement(
'[data-testid="secure-wallet-recommended"]',
);
// review
await driver.clickElement('[data-testid="recovery-phrase-reveal"]');
const chipTwo = await (
await driver.findElement('[data-testid="recovery-phrase-chip-2"]')
).getText();
const chipThree = await (
await driver.findElement('[data-testid="recovery-phrase-chip-3"]')
).getText();
const chipSeven = await (
await driver.findElement('[data-testid="recovery-phrase-chip-7"]')
).getText();
await driver.clickElement('[data-testid="recovery-phrase-next"]');
// confirm
await driver.fill('[data-testid="recovery-phrase-input-2"]', chipTwo);
await driver.fill(
'[data-testid="recovery-phrase-input-3"]',
chipThree,
);
await driver.fill(
'[data-testid="recovery-phrase-input-7"]',
chipSeven,
);
await driver.clickElement('[data-testid="recovery-phrase-confirm"]');
// complete
await driver.clickElement('[data-testid="onboarding-complete-done"]');
// pin extension
await driver.clickElement('[data-testid="pin-extension-next"]');
await driver.clickElement('[data-testid="pin-extension-done"]');
} else {
// clicks the continue button on the welcome screen
await driver.findElement('.welcome-page__header');
await driver.clickElement({
text: enLocaleMessages.getStarted.message,
tag: 'button',
});
await driver.delay(tinyDelayMs);
// clicks the "Create New Wallet" option
await driver.clickElement({ text: 'Create a Wallet', tag: 'button' });
// clicks the "I Agree" option on the metametrics opt-in screen
await driver.clickElement('.btn-primary');
// accepts a secure password
await driver.fill(
'.first-time-flow__form #create-password',
'correct horse battery staple',
);
await driver.fill(
'.first-time-flow__form #confirm-password',
'correct horse battery staple',
);
await driver.clickElement('.first-time-flow__checkbox');
await driver.clickElement('.first-time-flow__form button');
// renders the Secret Recovery Phrase intro screen
await driver.clickElement('.seed-phrase-intro__left button');
// reveals the Secret Recovery Phrase
await driver.clickElement(
'.reveal-seed-phrase__secret-blocker .reveal-seed-phrase__reveal-button',
);
const revealedSeedPhrase = await driver.findElement(
'.reveal-seed-phrase__secret-words',
);
const seedPhrase = await revealedSeedPhrase.getText();
assert.equal(seedPhrase.split(' ').length, 12);
await driver.clickElement({
text: enLocaleMessages.next.message,
tag: 'button',
});
// can retype the Secret Recovery Phrase
const words = seedPhrase.split(' ');
for (const word of words) {
await clickWordAndWait(word);
}
await driver.clickElement({ text: 'Confirm', tag: 'button' });
// clicks through the success screen
await driver.findElement({ text: 'Congratulations', tag: 'div' });
await driver.clickElement({
text: enLocaleMessages.endOfFlowMessage10.message,
tag: 'button',
});
} }
await driver.clickElement({ text: 'Confirm', tag: 'button' });
// clicks through the success screen // assert balance
await driver.findElement({ text: 'Congratulations', tag: 'div' }); const balance = await driver.findElement(
await driver.clickElement({ '[data-testid="wallet-balance"]',
text: enLocaleMessages.endOfFlowMessage10.message, );
tag: 'button', assert.ok(/^0\sETH$/u.test(await balance.getText()));
});
// Show account information
// balance renders
await driver.waitForSelector({
css: '[data-testid="eth-overview__primary-currency"]',
text: '0 ETH',
});
}, },
); );
}); });

View File

@ -1,17 +1,19 @@
const { strict: assert } = require('assert'); const { strict: assert } = require('assert');
const { errorCodes } = require('eth-rpc-errors');
const { withFixtures } = require('../helpers'); const { withFixtures } = require('../helpers');
describe('MetaMask', function () { describe('MetaMask', function () {
const ganacheOptions = {
accounts: [
{
secretKey:
'0x7C9529A67102755B7E6102D6D950AC5D5863C98713805CEC576B945B15B71EAC',
balance: 25000000000000000000,
},
],
};
it('provider should inform dapp when switching networks', async function () { it('provider should inform dapp when switching networks', async function () {
const ganacheOptions = {
accounts: [
{
secretKey:
'0x7C9529A67102755B7E6102D6D950AC5D5863C98713805CEC576B945B15B71EAC',
balance: 25000000000000000000,
},
],
};
await withFixtures( await withFixtures(
{ {
dapp: true, dapp: true,
@ -62,4 +64,48 @@ describe('MetaMask', function () {
}, },
); );
}); });
it('should reject unsupported methods', async function () {
await withFixtures(
{
dapp: true,
failOnConsoleError: false,
fixtures: 'connected-state',
ganacheOptions,
title: this.test.title,
},
async ({ driver }) => {
await driver.navigate();
await driver.fill('#password', 'correct horse battery staple');
await driver.press('#password', driver.Key.ENTER);
await driver.openNewPage('http://127.0.0.1:8080/');
for (const unsupportedMethod of ['eth_signTransaction']) {
assert.equal(
await driver.executeAsyncScript(`
const webDriverCallback = arguments[arguments.length - 1];
window.ethereum.request({ method: '${unsupportedMethod}' })
.then(() => {
console.error('The unsupported method "${unsupportedMethod}" was not rejected.');
webDriverCallback(false);
})
.catch((error) => {
if (error.code === ${errorCodes.rpc.methodNotSupported}) {
webDriverCallback(true);
}
console.error(
'The unsupported method "${unsupportedMethod}" was rejected with an unexpected error.',
error,
);
webDriverCallback(false);
})
`),
true,
`The unsupported method "${unsupportedMethod}" should be rejected by the provider.`,
);
}
},
);
});
}); });

View File

@ -29,6 +29,10 @@ function wrapElementWithAPI(element, driver) {
return element; return element;
} }
/**
* For Selenium WebDriver API documentation, see:
* https://www.selenium.dev/selenium/docs/api/javascript/module/selenium-webdriver/index_exports_WebDriver.html
*/
class Driver { class Driver {
/** /**
* @param {!ThenableWebDriver} driver - A {@code WebDriver} instance * @param {!ThenableWebDriver} driver - A {@code WebDriver} instance
@ -49,6 +53,10 @@ class Driver {
}; };
} }
async executeAsyncScript(script, ...args) {
return this.driver.executeAsyncScript(script, args);
}
async executeScript(script, ...args) { async executeScript(script, ...args) {
return this.driver.executeScript(script, args); return this.driver.executeScript(script, args);
} }

View File

@ -1,6 +1,5 @@
import { JsonRpcEngine } from 'json-rpc-engine'; import { JsonRpcEngine, createScaffoldMiddleware } from 'json-rpc-engine';
import scaffoldMiddleware from 'eth-json-rpc-middleware/scaffold'; import { providerAsMiddleware } from 'eth-json-rpc-middleware';
import providerAsMiddleware from 'eth-json-rpc-middleware/providerAsMiddleware';
import GanacheCore from 'ganache-core'; import GanacheCore from 'ganache-core';
export function getTestSeed() { export function getTestSeed() {
@ -45,7 +44,7 @@ export function providerFromEngine(engine) {
export function createTestProviderTools(opts = {}) { export function createTestProviderTools(opts = {}) {
const engine = createEngineForTestData(); const engine = createEngineForTestData();
// handle provided hooks // handle provided hooks
engine.push(scaffoldMiddleware(opts.scaffold || {})); engine.push(createScaffoldMiddleware(opts.scaffold || {}));
// handle block tracker methods // handle block tracker methods
engine.push( engine.push(
providerAsMiddleware( providerAsMiddleware(

View File

@ -13,6 +13,8 @@
@import 'connected-status-indicator/index'; @import 'connected-status-indicator/index';
@import 'edit-gas-display/index'; @import 'edit-gas-display/index';
@import 'edit-gas-display-education/index'; @import 'edit-gas-display-education/index';
@import 'edit-gas-fee-popover/index';
@import 'edit-gas-fee-popover/edit-gas-item/index';
@import 'gas-customization/gas-modal-page-container/index'; @import 'gas-customization/gas-modal-page-container/index';
@import 'gas-customization/gas-price-button-group/index'; @import 'gas-customization/gas-price-button-group/index';
@import 'gas-customization/index'; @import 'gas-customization/index';

View File

@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import Box from '../../ui/box'; import Box from '../../ui/box';
import Button from '../../ui/button'; import Button from '../../ui/button';
import Typography from '../../ui/typography/typography'; import Typography from '../../ui/typography/typography';
import NewCollectiblesNotice from '../new-collectibles-notice';
import { import {
COLORS, COLORS,
TYPOGRAPHY, TYPOGRAPHY,
@ -15,6 +16,7 @@ import { useI18nContext } from '../../../hooks/useI18nContext';
export default function CollectiblesList({ onAddNFT }) { export default function CollectiblesList({ onAddNFT }) {
const collectibles = []; const collectibles = [];
const newNFTsDetected = true;
const t = useI18nContext(); const t = useI18nContext();
return ( return (
@ -22,7 +24,8 @@ export default function CollectiblesList({ onAddNFT }) {
{collectibles.length > 0 ? ( {collectibles.length > 0 ? (
<span>{JSON.stringify(collectibles)}</span> <span>{JSON.stringify(collectibles)}</span>
) : ( ) : (
<Box padding={[4, 0, 4, 0]}> <Box padding={[6, 12, 6, 12]}>
{newNFTsDetected ? <NewCollectiblesNotice /> : null}
<Box justifyContent={JUSTIFY_CONTENT.CENTER}> <Box justifyContent={JUSTIFY_CONTENT.CENTER}>
<img src="./images/no-nfts.svg" /> <img src="./images/no-nfts.svg" />
</Box> </Box>

View File

@ -10,6 +10,14 @@ import ConfirmPageContainer, {
ConfirmPageContainerNavigation, ConfirmPageContainerNavigation,
} from '.'; } from '.';
jest.mock('../../../store/actions', () => ({
disconnectGasFeeEstimatePoller: jest.fn(),
getGasFeeEstimatesAndStartPolling: jest
.fn()
.mockImplementation(() => Promise.resolve()),
addPollingTokenToAppState: jest.fn(),
}));
describe('Confirm Page Container Container Test', () => { describe('Confirm Page Container Container Test', () => {
let wrapper; let wrapper;
@ -31,6 +39,8 @@ describe('Confirm Page Container Container Test', () => {
selectedAddress: '0xd8f6a2ffb0fc5952d16c9768b71cfd35b6399aa5', selectedAddress: '0xd8f6a2ffb0fc5952d16c9768b71cfd35b6399aa5',
addressBook: [], addressBook: [],
chainId: 'test', chainId: 'test',
identities: [],
featureFlags: {},
}, },
}; };

View File

@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import classnames from 'classnames'; import classnames from 'classnames';
import { Tabs, Tab } from '../../../ui/tabs'; import { Tabs, Tab } from '../../../ui/tabs';
import ErrorMessage from '../../../ui/error-message'; import ErrorMessage from '../../../ui/error-message';
import ActionableMessage from '../../../ui/actionable-message/actionable-message';
import { PageContainerFooter } from '../../../ui/page-container'; import { PageContainerFooter } from '../../../ui/page-container';
import { ConfirmPageContainerSummary, ConfirmPageContainerWarning } from '.'; import { ConfirmPageContainerSummary, ConfirmPageContainerWarning } from '.';
@ -17,6 +18,7 @@ export default class ConfirmPageContainerContent extends Component {
detailsComponent: PropTypes.node, detailsComponent: PropTypes.node,
errorKey: PropTypes.string, errorKey: PropTypes.string,
errorMessage: PropTypes.string, errorMessage: PropTypes.string,
hasSimulationError: PropTypes.bool,
hideSubtitle: PropTypes.bool, hideSubtitle: PropTypes.bool,
identiconAddress: PropTypes.string, identiconAddress: PropTypes.string,
nonce: PropTypes.string, nonce: PropTypes.string,
@ -31,8 +33,10 @@ export default class ConfirmPageContainerContent extends Component {
onCancel: PropTypes.func, onCancel: PropTypes.func,
cancelText: PropTypes.string, cancelText: PropTypes.string,
onSubmit: PropTypes.func, onSubmit: PropTypes.func,
onConfirmAnyways: PropTypes.func,
submitText: PropTypes.string, submitText: PropTypes.string,
disabled: PropTypes.bool, disabled: PropTypes.bool,
hideConfirmAnyways: PropTypes.bool,
unapprovedTxCount: PropTypes.number, unapprovedTxCount: PropTypes.number,
rejectNText: PropTypes.string, rejectNText: PropTypes.string,
hideTitle: PropTypes.boolean, hideTitle: PropTypes.boolean,
@ -71,6 +75,7 @@ export default class ConfirmPageContainerContent extends Component {
action, action,
errorKey, errorKey,
errorMessage, errorMessage,
hasSimulationError,
title, title,
titleComponent, titleComponent,
subtitleComponent, subtitleComponent,
@ -91,14 +96,32 @@ export default class ConfirmPageContainerContent extends Component {
origin, origin,
ethGasPriceWarning, ethGasPriceWarning,
hideTitle, hideTitle,
onConfirmAnyways,
hideConfirmAnyways,
} = this.props; } = this.props;
const primaryAction = hideConfirmAnyways
? null
: {
label: this.context.t('tryAnywayOption'),
onClick: onConfirmAnyways,
};
return ( return (
<div className="confirm-page-container-content"> <div className="confirm-page-container-content">
{warning ? <ConfirmPageContainerWarning warning={warning} /> : null} {warning ? <ConfirmPageContainerWarning warning={warning} /> : null}
{ethGasPriceWarning && ( {ethGasPriceWarning && (
<ConfirmPageContainerWarning warning={ethGasPriceWarning} /> <ConfirmPageContainerWarning warning={ethGasPriceWarning} />
)} )}
{hasSimulationError && (
<div className="confirm-page-container-content__error-container">
<ActionableMessage
type="danger"
primaryAction={primaryAction}
message={this.context.t('simulationErrorMessage')}
/>
</div>
)}
<ConfirmPageContainerSummary <ConfirmPageContainerSummary
className={classnames({ className={classnames({
'confirm-page-container-summary--border': 'confirm-page-container-summary--border':
@ -115,7 +138,7 @@ export default class ConfirmPageContainerContent extends Component {
hideTitle={hideTitle} hideTitle={hideTitle}
/> />
{this.renderContent()} {this.renderContent()}
{(errorKey || errorMessage) && ( {(errorKey || errorMessage) && !hasSimulationError && (
<div className="confirm-page-container-content__error-container"> <div className="confirm-page-container-content__error-container">
<ErrorMessage errorMessage={errorMessage} errorKey={errorKey} /> <ErrorMessage errorMessage={errorMessage} errorKey={errorKey} />
</div> </div>

View File

@ -0,0 +1,124 @@
import { fireEvent } from '@testing-library/react';
import React from 'react';
import configureMockStore from 'redux-mock-store';
import { renderWithProvider } from '../../../../../test/lib/render-helpers';
import { TRANSACTION_ERROR_KEY } from '../../../../helpers/constants/error-keys';
import ConfirmPageContainerContent from './confirm-page-container-content.component';
describe('Confirm Page Container Content', () => {
const mockStore = {
metamask: {
provider: {
type: 'test',
},
},
};
const store = configureMockStore()(mockStore);
let props = {};
beforeEach(() => {
const mockOnCancel = jest.fn();
const mockOnCancelAll = jest.fn();
const mockOnSubmit = jest.fn();
const mockOnConfirmAnyways = jest.fn();
props = {
action: ' Withdraw Stake',
errorMessage: null,
errorKey: null,
hasSimulationError: true,
onCancelAll: mockOnCancelAll,
onCancel: mockOnCancel,
cancelText: 'Reject',
onSubmit: mockOnSubmit,
onConfirmAnyways: mockOnConfirmAnyways,
submitText: 'Confirm',
disabled: true,
origin: 'http://localhost:4200',
hideTitle: false,
};
});
it('render ConfirmPageContainer component with simulation error', async () => {
const { queryByText, getByText } = renderWithProvider(
<ConfirmPageContainerContent {...props} />,
store,
);
expect(
queryByText('Transaction Error. Exception thrown in contract code.'),
).not.toBeInTheDocument();
expect(
queryByText(
'This transaction is expected to fail. Trying to execute it is expected to be expensive but fail, and is not recommended.',
),
).toBeInTheDocument();
expect(queryByText('I will try anyway')).toBeInTheDocument();
const confirmButton = getByText('Confirm');
expect(getByText('Confirm').closest('button')).toBeDisabled();
fireEvent.click(confirmButton);
expect(props.onSubmit).toHaveBeenCalledTimes(0);
const iWillTryButton = getByText('I will try anyway');
fireEvent.click(iWillTryButton);
expect(props.onConfirmAnyways).toHaveBeenCalledTimes(1);
const cancelButton = getByText('Reject');
fireEvent.click(cancelButton);
expect(props.onCancel).toHaveBeenCalledTimes(1);
});
it('render ConfirmPageContainer component with another error', async () => {
props.hasSimulationError = false;
props.disabled = true;
props.errorKey = TRANSACTION_ERROR_KEY;
const { queryByText, getByText } = renderWithProvider(
<ConfirmPageContainerContent {...props} />,
store,
);
expect(
queryByText(
'This transaction is expected to fail. Trying to execute it is expected to be expensive but fail, and is not recommended.',
),
).not.toBeInTheDocument();
expect(queryByText('I will try anyway')).not.toBeInTheDocument();
expect(getByText('Confirm').closest('button')).toBeDisabled();
expect(
getByText('Transaction Error. Exception thrown in contract code.'),
).toBeInTheDocument();
const cancelButton = getByText('Reject');
fireEvent.click(cancelButton);
expect(props.onCancel).toHaveBeenCalledTimes(1);
});
it('render ConfirmPageContainer component with no errors', async () => {
props.hasSimulationError = false;
props.disabled = false;
const { queryByText, getByText } = renderWithProvider(
<ConfirmPageContainerContent {...props} />,
store,
);
expect(
queryByText(
'This transaction is expected to fail. Trying to execute it is expected to be expensive but fail, and is not recommended.',
),
).not.toBeInTheDocument();
expect(
queryByText('Transaction Error. Exception thrown in contract code.'),
).not.toBeInTheDocument();
expect(queryByText('I will try anyway')).not.toBeInTheDocument();
const confirmButton = getByText('Confirm');
fireEvent.click(confirmButton);
expect(props.onSubmit).toHaveBeenCalledTimes(1);
const cancelButton = getByText('Reject');
fireEvent.click(cancelButton);
expect(props.onCancel).toHaveBeenCalledTimes(1);
});
});

View File

@ -4,15 +4,20 @@ import SenderToRecipient from '../../ui/sender-to-recipient';
import { PageContainerFooter } from '../../ui/page-container'; import { PageContainerFooter } from '../../ui/page-container';
import EditGasPopover from '../edit-gas-popover'; import EditGasPopover from '../edit-gas-popover';
import { EDIT_GAS_MODES } from '../../../../shared/constants/gas'; import { EDIT_GAS_MODES } from '../../../../shared/constants/gas';
import { GasFeeContextProvider } from '../../../contexts/gasFee';
import ErrorMessage from '../../ui/error-message'; import ErrorMessage from '../../ui/error-message';
import { TRANSACTION_TYPES } from '../../../../shared/constants/transaction'; import { TRANSACTION_TYPES } from '../../../../shared/constants/transaction';
import Dialog from '../../ui/dialog'; import Dialog from '../../ui/dialog';
import EditGasFeePopover from '../edit-gas-fee-popover/edit-gas-fee-popover';
import { import {
ConfirmPageContainerHeader, ConfirmPageContainerHeader,
ConfirmPageContainerContent, ConfirmPageContainerContent,
ConfirmPageContainerNavigation, ConfirmPageContainerNavigation,
} from '.'; } from '.';
// eslint-disable-next-line prefer-destructuring
const EIP_1559_V2 = process.env.EIP_1559_V2;
export default class ConfirmPageContainer extends Component { export default class ConfirmPageContainer extends Component {
static contextTypes = { static contextTypes = {
t: PropTypes.func, t: PropTypes.func,
@ -135,102 +140,107 @@ export default class ConfirmPageContainer extends Component {
currentTransaction.txParams?.value === '0x0'; currentTransaction.txParams?.value === '0x0';
return ( return (
<div className="page-container"> <GasFeeContextProvider transaction={currentTransaction}>
<ConfirmPageContainerNavigation <div className="page-container">
totalTx={totalTx} <ConfirmPageContainerNavigation
positionOfCurrentTx={positionOfCurrentTx} totalTx={totalTx}
nextTxId={nextTxId} positionOfCurrentTx={positionOfCurrentTx}
prevTxId={prevTxId} nextTxId={nextTxId}
showNavigation={showNavigation} prevTxId={prevTxId}
onNextTx={(txId) => onNextTx(txId)} showNavigation={showNavigation}
firstTx={firstTx} onNextTx={(txId) => onNextTx(txId)}
lastTx={lastTx} firstTx={firstTx}
ofText={ofText} lastTx={lastTx}
requestsWaitingText={requestsWaitingText} ofText={ofText}
/> requestsWaitingText={requestsWaitingText}
<ConfirmPageContainerHeader />
showEdit={showEdit} <ConfirmPageContainerHeader
onEdit={() => onEdit()} showEdit={showEdit}
showAccountInHeader={showAccountInHeader} onEdit={() => onEdit()}
accountAddress={fromAddress} showAccountInHeader={showAccountInHeader}
> accountAddress={fromAddress}
{hideSenderToRecipient ? null : ( >
<SenderToRecipient {hideSenderToRecipient ? null : (
senderName={fromName} <SenderToRecipient
senderAddress={fromAddress} senderName={fromName}
recipientName={toName} senderAddress={fromAddress}
recipientAddress={toAddress} recipientName={toName}
recipientEns={toEns} recipientAddress={toAddress}
recipientNickname={toNickname} recipientEns={toEns}
recipientNickname={toNickname}
/>
)}
</ConfirmPageContainerHeader>
<div>
{showAddToAddressDialog && (
<Dialog
type="message"
className="send__dialog"
onClick={() => showAddToAddressBookModal()}
>
{this.context.t('newAccountDetectedDialogMessage')}
</Dialog>
)}
</div>
{contentComponent || (
<ConfirmPageContainerContent
action={action}
title={title}
titleComponent={titleComponent}
subtitleComponent={subtitleComponent}
hideSubtitle={hideSubtitle}
detailsComponent={detailsComponent}
dataComponent={dataComponent}
errorMessage={errorMessage}
errorKey={errorKey}
identiconAddress={identiconAddress}
nonce={nonce}
warning={warning}
onCancelAll={onCancelAll}
onCancel={onCancel}
cancelText={this.context.t('reject')}
onSubmit={onSubmit}
submitText={this.context.t('confirm')}
disabled={disabled}
unapprovedTxCount={unapprovedTxCount}
rejectNText={this.context.t('rejectTxsN', [unapprovedTxCount])}
origin={origin}
ethGasPriceWarning={ethGasPriceWarning}
hideTitle={hideTitle}
/> />
)} )}
</ConfirmPageContainerHeader> {shouldDisplayWarning && (
<div> <div className="confirm-approve-content__warning">
{showAddToAddressDialog && ( <ErrorMessage errorKey={errorKey} />
<Dialog </div>
type="message" )}
className="send__dialog" {contentComponent && (
onClick={() => showAddToAddressBookModal()} <PageContainerFooter
onCancel={onCancel}
cancelText={this.context.t('reject')}
onSubmit={onSubmit}
submitText={this.context.t('confirm')}
disabled={disabled}
> >
{this.context.t('newAccountDetectedDialogMessage')} {unapprovedTxCount > 1 && (
</Dialog> <a onClick={onCancelAll}>
{this.context.t('rejectTxsN', [unapprovedTxCount])}
</a>
)}
</PageContainerFooter>
)}
{editingGas && !EIP_1559_V2 && (
<EditGasPopover
mode={EDIT_GAS_MODES.MODIFY_IN_PLACE}
onClose={handleCloseEditGas}
transaction={currentTransaction}
/>
)}
{editingGas && EIP_1559_V2 && (
<EditGasFeePopover onClose={handleCloseEditGas} />
)} )}
</div> </div>
{contentComponent || ( </GasFeeContextProvider>
<ConfirmPageContainerContent
action={action}
title={title}
titleComponent={titleComponent}
subtitleComponent={subtitleComponent}
hideSubtitle={hideSubtitle}
detailsComponent={detailsComponent}
dataComponent={dataComponent}
errorMessage={errorMessage}
errorKey={errorKey}
identiconAddress={identiconAddress}
nonce={nonce}
warning={warning}
onCancelAll={onCancelAll}
onCancel={onCancel}
cancelText={this.context.t('reject')}
onSubmit={onSubmit}
submitText={this.context.t('confirm')}
disabled={disabled}
unapprovedTxCount={unapprovedTxCount}
rejectNText={this.context.t('rejectTxsN', [unapprovedTxCount])}
origin={origin}
ethGasPriceWarning={ethGasPriceWarning}
hideTitle={hideTitle}
/>
)}
{shouldDisplayWarning && (
<div className="confirm-approve-content__warning">
<ErrorMessage errorKey={errorKey} />
</div>
)}
{contentComponent && (
<PageContainerFooter
onCancel={onCancel}
cancelText={this.context.t('reject')}
onSubmit={onSubmit}
submitText={this.context.t('confirm')}
disabled={disabled}
>
{unapprovedTxCount > 1 && (
<a onClick={onCancelAll}>
{this.context.t('rejectTxsN', [unapprovedTxCount])}
</a>
)}
</PageContainerFooter>
)}
{editingGas && (
<EditGasPopover
mode={EDIT_GAS_MODES.MODIFY_IN_PLACE}
onClose={handleCloseEditGas}
transaction={currentTransaction}
/>
)}
</div>
); );
} }
} }

View File

@ -0,0 +1,68 @@
import React from 'react';
import PropTypes from 'prop-types';
import { PRIORITY_LEVELS } from '../../../../shared/constants/gas';
import { useI18nContext } from '../../../hooks/useI18nContext';
import Popover from '../../ui/popover';
import I18nValue from '../../ui/i18n-value';
import LoadingHeartBeat from '../../ui/loading-heartbeat';
import EditGasItem from './edit-gas-item';
const EditGasFeePopover = ({ onClose }) => {
const t = useI18nContext();
return (
<Popover
title={t('editGasFeeModalTitle')}
onClose={onClose}
className="edit-gas-fee-popover"
>
<>
{process.env.IN_TEST === 'true' ? null : <LoadingHeartBeat />}
<div className="edit-gas-fee-popover__wrapper">
<div className="edit-gas-fee-popover__content">
<div className="edit-gas-fee-popover__content__header">
<span className="edit-gas-fee-popover__content__header-option">
<I18nValue messageKey="gasOption" />
</span>
<span className="edit-gas-fee-popover__content__header-time">
<I18nValue messageKey="time" />
</span>
<span className="edit-gas-fee-popover__content__header-max-fee">
<I18nValue messageKey="maxFee" />
</span>
</div>
<EditGasItem
priorityLevel={PRIORITY_LEVELS.LOW}
onClose={onClose}
/>
<EditGasItem
priorityLevel={PRIORITY_LEVELS.MEDIUM}
onClose={onClose}
/>
<EditGasItem
priorityLevel={PRIORITY_LEVELS.HIGH}
onClose={onClose}
/>
<div className="edit-gas-fee-popover__content__separator" />
<EditGasItem
priorityLevel={PRIORITY_LEVELS.DAPP_SUGGESTED}
onClose={onClose}
/>
<EditGasItem
priorityLevel={PRIORITY_LEVELS.CUSTOM}
onClose={onClose}
/>
</div>
</div>
</>
</Popover>
);
};
EditGasFeePopover.propTypes = {
onClose: PropTypes.func,
};
export default EditGasFeePopover;

View File

@ -0,0 +1,95 @@
import React from 'react';
import { screen } from '@testing-library/react';
import { renderWithProvider } from '../../../../test/lib/render-helpers';
import { ETH } from '../../../helpers/constants/common';
import configureStore from '../../../store/store';
import { GasFeeContextProvider } from '../../../contexts/gasFee';
import EditGasFeePopover from './edit-gas-fee-popover';
jest.mock('../../../store/actions', () => ({
disconnectGasFeeEstimatePoller: jest.fn(),
getGasFeeEstimatesAndStartPolling: jest
.fn()
.mockImplementation(() => Promise.resolve()),
addPollingTokenToAppState: jest.fn(),
}));
const MOCK_FEE_ESTIMATE = {
low: {
minWaitTimeEstimate: 360000,
maxWaitTimeEstimate: 300000,
suggestedMaxPriorityFeePerGas: '3',
suggestedMaxFeePerGas: '53',
},
medium: {
minWaitTimeEstimate: 30000,
maxWaitTimeEstimate: 60000,
suggestedMaxPriorityFeePerGas: '7',
suggestedMaxFeePerGas: '70',
},
high: {
minWaitTimeEstimate: 15000,
maxWaitTimeEstimate: 15000,
suggestedMaxPriorityFeePerGas: '10',
suggestedMaxFeePerGas: '100',
},
estimatedBaseFee: '50',
};
const renderComponent = () => {
const store = configureStore({
metamask: {
nativeCurrency: ETH,
provider: {},
cachedBalances: {},
accounts: {
'0xAddress': {
address: '0xAddress',
balance: '0x176e5b6f173ebe66',
},
},
selectedAddress: '0xAddress',
featureFlags: { advancedInlineGas: true },
gasFeeEstimates: MOCK_FEE_ESTIMATE,
},
});
return renderWithProvider(
<GasFeeContextProvider transaction={{ txParams: { gas: '0x5208' } }}>
<EditGasFeePopover />
</GasFeeContextProvider>,
store,
);
};
describe('EditGasFeePopover', () => {
it('should renders low / medium / high options', () => {
renderComponent();
expect(screen.queryByText('🐢')).toBeInTheDocument();
expect(screen.queryByText('🦊')).toBeInTheDocument();
expect(screen.queryByText('🦍')).toBeInTheDocument();
expect(screen.queryByText('🌐')).toBeInTheDocument();
expect(screen.queryByText('⚙')).toBeInTheDocument();
expect(screen.queryByText('Low')).toBeInTheDocument();
expect(screen.queryByText('Market')).toBeInTheDocument();
expect(screen.queryByText('Aggressive')).toBeInTheDocument();
expect(screen.queryByText('Site')).toBeInTheDocument();
expect(screen.queryByText('Advanced')).toBeInTheDocument();
});
it('should show time estimates', () => {
renderComponent();
expect(screen.queryAllByText('5 min')).toHaveLength(2);
expect(screen.queryByText('15 sec')).toBeInTheDocument();
});
it('should show gas fee estimates', () => {
renderComponent();
expect(screen.queryByTitle('0.001113 ETH')).toBeInTheDocument();
expect(screen.queryByTitle('0.00147 ETH')).toBeInTheDocument();
expect(screen.queryByTitle('0.0021 ETH')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,150 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import { getMaximumGasTotalInHexWei } from '../../../../../shared/modules/gas.utils';
import { PRIORITY_LEVELS } from '../../../../../shared/constants/gas';
import { PRIORITY_LEVEL_ICON_MAP } from '../../../../helpers/constants/gas';
import { PRIMARY } from '../../../../helpers/constants/common';
import {
decGWEIToHexWEI,
decimalToHex,
hexWEIToDecGWEI,
} from '../../../../helpers/utils/conversions.util';
import { getAdvancedGasFeeValues } from '../../../../selectors';
import { toHumanReadableTime } from '../../../../helpers/utils/util';
import { useGasFeeContext } from '../../../../contexts/gasFee';
import { useI18nContext } from '../../../../hooks/useI18nContext';
import I18nValue from '../../../ui/i18n-value';
import InfoTooltip from '../../../ui/info-tooltip';
import UserPreferencedCurrencyDisplay from '../../user-preferenced-currency-display';
import { useCustomTimeEstimate } from './useCustomTimeEstimate';
const EditGasItem = ({ priorityLevel, onClose }) => {
const {
estimateUsed,
gasFeeEstimates,
gasLimit,
maxFeePerGas: maxFeePerGasValue,
maxPriorityFeePerGas: maxPriorityFeePerGasValue,
updateTransactionUsingGasFeeEstimates,
transaction: { dappSuggestedGasFees },
} = useGasFeeContext();
const t = useI18nContext();
const advancedGasFeeValues = useSelector(getAdvancedGasFeeValues);
let maxFeePerGas;
let maxPriorityFeePerGas;
let minWaitTime;
if (gasFeeEstimates[priorityLevel]) {
maxFeePerGas = gasFeeEstimates[priorityLevel].suggestedMaxFeePerGas;
} else if (
priorityLevel === PRIORITY_LEVELS.DAPP_SUGGESTED &&
dappSuggestedGasFees
) {
maxFeePerGas = hexWEIToDecGWEI(dappSuggestedGasFees.maxFeePerGas);
maxPriorityFeePerGas = hexWEIToDecGWEI(
dappSuggestedGasFees.maxPriorityFeePerGas,
);
} else if (priorityLevel === PRIORITY_LEVELS.CUSTOM) {
if (estimateUsed === PRIORITY_LEVELS.CUSTOM) {
maxFeePerGas = maxFeePerGasValue;
maxPriorityFeePerGas = maxPriorityFeePerGasValue;
} else if (advancedGasFeeValues) {
maxFeePerGas =
gasFeeEstimates.estimatedBaseFee *
parseFloat(advancedGasFeeValues.maxBaseFee);
maxPriorityFeePerGas = advancedGasFeeValues.priorityFee;
}
}
const { waitTimeEstimate } = useCustomTimeEstimate({
gasFeeEstimates,
maxFeePerGas,
maxPriorityFeePerGas,
});
if (gasFeeEstimates[priorityLevel]) {
minWaitTime =
priorityLevel === PRIORITY_LEVELS.HIGH
? gasFeeEstimates?.high.minWaitTimeEstimate
: gasFeeEstimates?.low.maxWaitTimeEstimate;
} else {
minWaitTime = waitTimeEstimate;
}
const hexMaximumTransactionFee = maxFeePerGas
? getMaximumGasTotalInHexWei({
gasLimit: decimalToHex(gasLimit),
maxFeePerGas: decGWEIToHexWEI(maxFeePerGas),
})
: null;
const onOptionSelect = () => {
if (priorityLevel !== PRIORITY_LEVELS.CUSTOM) {
updateTransactionUsingGasFeeEstimates(priorityLevel);
}
// todo: open advance modal if priorityLevel is custom
onClose();
};
return (
<div
className={classNames('edit-gas-item', {
'edit-gas-item-selected': priorityLevel === estimateUsed,
'edit-gas-item-disabled':
priorityLevel === PRIORITY_LEVELS.DAPP_SUGGESTED &&
!dappSuggestedGasFees,
})}
role="button"
onClick={onOptionSelect}
>
<span className="edit-gas-item__name">
<span
className={`edit-gas-item__icon edit-gas-item__icon-${priorityLevel}`}
>
{PRIORITY_LEVEL_ICON_MAP[priorityLevel]}
</span>
<I18nValue
messageKey={
priorityLevel === PRIORITY_LEVELS.DAPP_SUGGESTED
? 'dappSuggestedShortLabel'
: priorityLevel
}
/>
</span>
<span
className={`edit-gas-item__time-estimate edit-gas-item__time-estimate-${priorityLevel}`}
>
{minWaitTime
? minWaitTime && toHumanReadableTime(t, minWaitTime)
: '--'}
</span>
<span
className={`edit-gas-item__fee-estimate edit-gas-item__fee-estimate-${priorityLevel}`}
>
{hexMaximumTransactionFee ? (
<UserPreferencedCurrencyDisplay
key="editGasSubTextFeeAmount"
type={PRIMARY}
value={hexMaximumTransactionFee}
/>
) : (
'--'
)}
</span>
<span className="edit-gas-item__tooltip">
<InfoTooltip position="top" />
</span>
</div>
);
};
EditGasItem.propTypes = {
priorityLevel: PropTypes.string,
onClose: PropTypes.func,
};
export default EditGasItem;

View File

@ -0,0 +1,138 @@
import React from 'react';
import { screen } from '@testing-library/react';
import { renderWithProvider } from '../../../../../test/lib/render-helpers';
import { ETH } from '../../../../helpers/constants/common';
import configureStore from '../../../../store/store';
import { GasFeeContextProvider } from '../../../../contexts/gasFee';
import EditGasItem from './edit-gas-item';
jest.mock('../../../../store/actions', () => ({
disconnectGasFeeEstimatePoller: jest.fn(),
getGasFeeEstimatesAndStartPolling: jest
.fn()
.mockImplementation(() => Promise.resolve()),
addPollingTokenToAppState: jest.fn(),
getGasFeeTimeEstimate: jest
.fn()
.mockImplementation(() => Promise.resolve('unknown')),
}));
const MOCK_FEE_ESTIMATE = {
low: {
minWaitTimeEstimate: 360000,
maxWaitTimeEstimate: 300000,
suggestedMaxPriorityFeePerGas: '3',
suggestedMaxFeePerGas: '53',
},
medium: {
minWaitTimeEstimate: 30000,
maxWaitTimeEstimate: 60000,
suggestedMaxPriorityFeePerGas: '7',
suggestedMaxFeePerGas: '70',
},
high: {
minWaitTimeEstimate: 15000,
maxWaitTimeEstimate: 15000,
suggestedMaxPriorityFeePerGas: '10',
suggestedMaxFeePerGas: '100',
},
estimatedBaseFee: '50',
};
const DAPP_SUGGESTED_ESTIMATE = {
maxFeePerGas: '0x59682f10',
maxPriorityFeePerGas: '0x59682f00',
};
const renderComponent = (props, transactionProps, gasFeeContextProps) => {
const store = configureStore({
metamask: {
nativeCurrency: ETH,
provider: {},
cachedBalances: {},
accounts: {
'0xAddress': {
address: '0xAddress',
balance: '0x176e5b6f173ebe66',
},
},
selectedAddress: '0xAddress',
featureFlags: { advancedInlineGas: true },
gasFeeEstimates: MOCK_FEE_ESTIMATE,
advancedGasFee: {
maxBaseFee: '1.5',
priorityFee: '2',
},
},
});
return renderWithProvider(
<GasFeeContextProvider
transaction={{ txParams: { gas: '0x5208' }, ...transactionProps }}
{...gasFeeContextProps}
>
<EditGasItem priorityLevel="low" {...props} />
</GasFeeContextProvider>,
store,
);
};
describe('EditGasItem', () => {
it('should renders low gas estimate option for priorityLevel low', () => {
renderComponent({ priorityLevel: 'low' });
expect(screen.queryByText('🐢')).toBeInTheDocument();
expect(screen.queryByText('Low')).toBeInTheDocument();
expect(screen.queryByText('5 min')).toBeInTheDocument();
expect(screen.queryByTitle('0.001113 ETH')).toBeInTheDocument();
});
it('should renders market gas estimate option for priorityLevel medium', () => {
renderComponent({ priorityLevel: 'medium' });
expect(screen.queryByText('🦊')).toBeInTheDocument();
expect(screen.queryByText('Market')).toBeInTheDocument();
expect(screen.queryByText('5 min')).toBeInTheDocument();
expect(screen.queryByTitle('0.00147 ETH')).toBeInTheDocument();
});
it('should renders aggressive gas estimate option for priorityLevel high', () => {
renderComponent({ priorityLevel: 'high' });
expect(screen.queryByText('🦍')).toBeInTheDocument();
expect(screen.queryByText('Aggressive')).toBeInTheDocument();
expect(screen.queryByText('15 sec')).toBeInTheDocument();
expect(screen.queryByTitle('0.0021 ETH')).toBeInTheDocument();
});
it('should highlight option is priorityLevel is currently selected', () => {
renderComponent({ priorityLevel: 'high' }, { userFeeLevel: 'high' });
expect(
document.getElementsByClassName('edit-gas-item-selected'),
).toHaveLength(1);
});
it('should renders site gas estimate option for priorityLevel dappSuggested', () => {
renderComponent(
{ priorityLevel: 'dappSuggested' },
{ dappSuggestedGasFees: DAPP_SUGGESTED_ESTIMATE },
);
expect(screen.queryByText('🌐')).toBeInTheDocument();
expect(screen.queryByText('Site')).toBeInTheDocument();
expect(screen.queryByTitle('0.0000315 ETH')).toBeInTheDocument();
});
it('should disable site gas estimate option for is transaction does not have dappSuggestedGasFees', async () => {
renderComponent({ priorityLevel: 'dappSuggested' });
expect(
document.getElementsByClassName('edit-gas-item-disabled'),
).toHaveLength(1);
});
it('should renders advance gas estimate option for priorityLevel custom', () => {
renderComponent({ priorityLevel: 'custom' });
expect(screen.queryByText('⚙')).toBeInTheDocument();
expect(screen.queryByText('Advanced')).toBeInTheDocument();
// below value of custom gas fee estimate is default obtained from state.metamask.advancedGasFee
expect(screen.queryByTitle('0.001575 ETH')).toBeInTheDocument();
});
});

View File

@ -0,0 +1 @@
export { default } from './edit-gas-item';

View File

@ -0,0 +1,68 @@
.edit-gas-item {
border-radius: 24px;
color: $ui-4;
cursor: pointer;
font-size: 12px;
display: flex;
align-items: center;
margin: 12px 0;
padding: 4px 12px;
height: 32px;
&--selected {
background-color: $ui-1;
}
&-disabled {
cursor: default;
}
&__name {
display: inline-flex;
align-items: center;
color: $ui-black;
font-size: 12px;
font-weight: bold;
width: 40%;
}
&__icon {
margin-right: 4px;
&-custom {
font-size: 20px;
line-height: 0;
}
}
&__time-estimate {
display: inline-block;
width: 20%;
}
&__fee-estimate {
display: inline-block;
width: 30%;
white-space: nowrap;
}
&__tooltip {
display: inline-block;
text-align: right;
width: 10%;
.info-tooltip {
display: inline-block;
}
}
&__time-estimate-low,
&__fee-estimate-high {
color: $secondary-1;
}
&__time-estimate-medium,
&__time-estimate-high {
color: $success-3;
}
}

View File

@ -0,0 +1,83 @@
import { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import BigNumber from 'bignumber.js';
import { GAS_ESTIMATE_TYPES } from '../../../../../shared/constants/gas';
import {
getGasEstimateType,
getIsGasEstimatesLoading,
} from '../../../../ducks/metamask/metamask';
import { getGasFeeTimeEstimate } from '../../../../store/actions';
export const useCustomTimeEstimate = ({
gasFeeEstimates,
maxFeePerGas,
maxPriorityFeePerGas,
}) => {
const gasEstimateType = useSelector(getGasEstimateType);
const isGasEstimatesLoading = useSelector(getIsGasEstimatesLoading);
const [customEstimatedTime, setCustomEstimatedTime] = useState(null);
const returnNoEstimates =
isGasEstimatesLoading ||
gasEstimateType !== GAS_ESTIMATE_TYPES.FEE_MARKET ||
!maxPriorityFeePerGas;
// If the user has chosen a value lower than the low gas fee estimate,
// We'll need to use the useEffect hook below to make a call to calculate
// the time to show
const isUnknownLow =
gasFeeEstimates?.low &&
Number(maxPriorityFeePerGas) <
Number(gasFeeEstimates.low.suggestedMaxPriorityFeePerGas);
useEffect(() => {
if (
isGasEstimatesLoading ||
gasEstimateType !== GAS_ESTIMATE_TYPES.FEE_MARKET ||
!maxPriorityFeePerGas
)
return;
if (isUnknownLow) {
// getGasFeeTimeEstimate requires parameters in string format
getGasFeeTimeEstimate(
new BigNumber(maxPriorityFeePerGas, 10).toString(10),
new BigNumber(maxFeePerGas, 10).toString(10),
).then((result) => {
setCustomEstimatedTime(result);
});
}
}, [
gasEstimateType,
isUnknownLow,
isGasEstimatesLoading,
maxFeePerGas,
maxPriorityFeePerGas,
returnNoEstimates,
]);
if (returnNoEstimates) {
return {};
}
const { low = {}, medium = {}, high = {} } = gasFeeEstimates;
let waitTimeEstimate = '';
if (
isUnknownLow &&
customEstimatedTime &&
customEstimatedTime !== 'unknown' &&
customEstimatedTime?.upperTimeBound !== 'unknown'
) {
waitTimeEstimate = Number(customEstimatedTime?.upperTimeBound);
} else if (
Number(maxPriorityFeePerGas) >= Number(medium.suggestedMaxPriorityFeePerGas)
) {
waitTimeEstimate = high.minWaitTimeEstimate;
} else {
waitTimeEstimate = low.maxWaitTimeEstimate;
}
return { waitTimeEstimate };
};

View File

@ -0,0 +1 @@
export { default } from './edit-gas-fee-popover';

View File

@ -0,0 +1,40 @@
.edit-gas-fee-popover {
@media screen and (min-width: $break-large) {
max-height: 84vh;
}
&__wrapper {
border-top: 1px solid $ui-grey;
}
&__content {
padding: 16px 12px;
&__header {
color: $ui-4;
font-size: 10px;
font-weight: 700;
margin: 0 12px;
&-option {
display: inline-block;
width: 40%;
}
&-time {
display: inline-block;
width: 20%;
}
&-max-fee {
display: inline-block;
width: 30%;
}
}
&__separator {
border-top: 1px solid $ui-grey;
margin: 8px 12px;
}
}
}

View File

@ -24,6 +24,7 @@ import InfoTooltip from '../../ui/info-tooltip/info-tooltip';
import { getGasFeeTimeEstimate } from '../../../store/actions'; import { getGasFeeTimeEstimate } from '../../../store/actions';
import { GAS_FORM_ERRORS } from '../../../helpers/constants/gas'; import { GAS_FORM_ERRORS } from '../../../helpers/constants/gas';
import { useGasFeeContext } from '../../../contexts/gasFee';
// Once we reach this second threshold, we switch to minutes as a unit // Once we reach this second threshold, we switch to minutes as a unit
const SECOND_CUTOFF = 90; const SECOND_CUTOFF = 90;
@ -49,6 +50,7 @@ export default function GasTiming({
const [customEstimatedTime, setCustomEstimatedTime] = useState(null); const [customEstimatedTime, setCustomEstimatedTime] = useState(null);
const t = useContext(I18nContext); const t = useContext(I18nContext);
const { estimateUsed } = useGasFeeContext();
// If the user has chosen a value lower than the low gas fee estimate, // If the user has chosen a value lower than the low gas fee estimate,
// We'll need to use the useEffect hook below to make a call to calculate // We'll need to use the useEffect hook below to make a call to calculate
@ -94,12 +96,17 @@ export default function GasTiming({
previousIsUnknownLow, previousIsUnknownLow,
]); ]);
const unknownProcessingTimeText = ( let unknownProcessingTimeText;
<> if (EIP_1559_V2) {
{t('editGasTooLow')}{' '} unknownProcessingTimeText = t('editGasTooLow');
<InfoTooltip position="top" contentText={t('editGasTooLowTooltip')} /> } else {
</> unknownProcessingTimeText = (
); <>
{t('editGasTooLow')}{' '}
<InfoTooltip position="top" contentText={t('editGasTooLowTooltip')} />
</>
);
}
if ( if (
gasWarnings?.maxPriorityFee === GAS_FORM_ERRORS.MAX_PRIORITY_FEE_TOO_LOW || gasWarnings?.maxPriorityFee === GAS_FORM_ERRORS.MAX_PRIORITY_FEE_TOO_LOW ||
@ -148,8 +155,9 @@ export default function GasTiming({
]); ]);
} }
} else { } else {
attitude = 'negative'; if (!EIP_1559_V2 || estimateUsed === 'low') {
attitude = 'negative';
}
// If the user has chosen a value less than our low estimate, // If the user has chosen a value less than our low estimate,
// calculate a potential wait time // calculate a potential wait time
if (isUnknownLow) { if (isUnknownLow) {
@ -191,7 +199,8 @@ export default function GasTiming({
<Typography <Typography
variant={TYPOGRAPHY.H7} variant={TYPOGRAPHY.H7}
className={classNames('gas-timing', { className={classNames('gas-timing', {
[`gas-timing--${attitude}`]: attitude, [`gas-timing--${attitude}`]: attitude && !EIP_1559_V2,
[`gas-timing--${attitude}-V2`]: attitude && EIP_1559_V2,
})} })}
> >
{text} {text}

View File

@ -14,6 +14,11 @@
font-weight: bold; font-weight: bold;
} }
&--negative-V2 {
color: $secondary-1;
font-weight: bold;
}
.info-tooltip { .info-tooltip {
display: inline-block; display: inline-block;
margin-inline-start: 4px; margin-inline-start: 4px;

View File

@ -84,9 +84,7 @@ export default class AccountDetailsModal extends Component {
? this.context.t('blockExplorerView', [ ? this.context.t('blockExplorerView', [
getURLHostName(rpcPrefs.blockExplorerUrl), getURLHostName(rpcPrefs.blockExplorerUrl),
]) ])
: this.context.t('viewOnEtherscan', [ : this.context.t('etherscanViewOn')}
this.context.t('blockExplorerAccountAction'),
])}
</Button> </Button>
{exportPrivateKeyFeatureEnabled ? ( {exportPrivateKeyFeatureEnabled ? (

View File

@ -8,13 +8,13 @@
& &__button { & &__button {
margin-top: 17px; margin-top: 17px;
padding: 10px 22px; padding: 10px 22px;
width: 286px; width: 284px;
} }
&__divider { &__divider {
width: 100%; width: 100%;
height: 1px; height: 1px;
margin: 19px 0 8px 0; margin: 16px 0 8px 0;
background-color: $alto; background-color: $alto;
} }

View File

@ -64,7 +64,7 @@ const accountModalStyle = {
margin: '0 auto', margin: '0 auto',
}, },
laptopModalStyle: { laptopModalStyle: {
width: '360px', width: '335px',
// top: 'calc(33% + 45px)', // top: 'calc(33% + 45px)',
boxShadow: 'rgba(0, 0, 0, 0.15) 0px 2px 2px 2px', boxShadow: 'rgba(0, 0, 0, 0.15) 0px 2px 2px 2px',
borderRadius: '4px', borderRadius: '4px',

View File

@ -0,0 +1 @@
export { default } from './new-collectibles-notice.component';

View File

@ -0,0 +1,56 @@
import React from 'react';
import Box from '../../ui/box';
import Dialog from '../../ui/dialog';
import Typography from '../../ui/typography/typography';
import {
COLORS,
TYPOGRAPHY,
TEXT_ALIGN,
FONT_WEIGHT,
DISPLAY,
} from '../../../helpers/constants/design-system';
import { useI18nContext } from '../../../hooks/useI18nContext';
export default function NewCollectiblesNotice() {
const t = useI18nContext();
return (
<Box marginBottom={8}>
<Dialog type="message">
<Box display={DISPLAY.FLEX}>
<Box paddingTop={2}>
<i style={{ fontSize: '1rem' }} className="fa fa-info-circle" />
</Box>
<Box paddingLeft={4}>
<Typography
color={COLORS.BLACK}
align={TEXT_ALIGN.LEFT}
variant={TYPOGRAPHY.Paragraph}
fontWeight={FONT_WEIGHT.BOLD}
>
{t('newNFTsDetected')}
</Typography>
<Typography
color={COLORS.BLACK}
align={TEXT_ALIGN.LEFT}
variant={TYPOGRAPHY.Paragraph}
boxProps={{ marginBottom: 4 }}
>
{t('newNFTsDetectedInfo')}
</Typography>
<a
href="#"
onClick={(e) => {
e.preventDefault();
console.log('show preference popover');
}}
style={{ fontSize: '.9rem' }}
>
{t('selectNFTPrivacyPreference')}
</a>
</Box>
</Box>
</Dialog>
</Box>
);
}

View File

@ -5,11 +5,7 @@ import classnames from 'classnames';
import { ObjectInspector } from 'react-inspector'; import { ObjectInspector } from 'react-inspector';
import LedgerInstructionField from '../ledger-instruction-field'; import LedgerInstructionField from '../ledger-instruction-field';
import { import { MESSAGE_TYPE } from '../../../../shared/constants/app';
ENVIRONMENT_TYPE_NOTIFICATION,
MESSAGE_TYPE,
} from '../../../../shared/constants/app';
import { getEnvironmentType } from '../../../../app/scripts/lib/util';
import Identicon from '../../ui/identicon'; import Identicon from '../../ui/identicon';
import AccountListItem from '../account-list-item'; import AccountListItem from '../account-list-item';
import { conversionUtil } from '../../../../shared/modules/conversion.utils'; import { conversionUtil } from '../../../../shared/modules/conversion.utils';
@ -39,42 +35,13 @@ export default class SignatureRequestOriginal extends Component {
domainMetadata: PropTypes.object, domainMetadata: PropTypes.object,
hardwareWalletRequiresConnection: PropTypes.bool, hardwareWalletRequiresConnection: PropTypes.bool,
isLedgerWallet: PropTypes.bool, isLedgerWallet: PropTypes.bool,
nativeCurrency: PropTypes.string.isRequired,
}; };
state = { state = {
fromAccount: this.props.fromAccount, fromAccount: this.props.fromAccount,
}; };
componentDidMount = () => {
if (getEnvironmentType() === ENVIRONMENT_TYPE_NOTIFICATION) {
window.addEventListener('beforeunload', this._beforeUnload);
}
};
componentWillUnmount = () => {
this._removeBeforeUnload();
};
_beforeUnload = (event) => {
const { clearConfirmTransaction, cancel } = this.props;
const { metricsEvent } = this.context;
metricsEvent({
eventOpts: {
category: 'Transactions',
action: 'Sign Request',
name: 'Cancel Sig Request Via Notification Close',
},
});
clearConfirmTransaction();
cancel(event);
};
_removeBeforeUnload = () => {
if (getEnvironmentType() === ENVIRONMENT_TYPE_NOTIFICATION) {
window.removeEventListener('beforeunload', this._beforeUnload);
}
};
renderHeader = () => { renderHeader = () => {
return ( return (
<div className="request-signature__header"> <div className="request-signature__header">
@ -108,12 +75,12 @@ export default class SignatureRequestOriginal extends Component {
}; };
renderBalance = () => { renderBalance = () => {
const { conversionRate } = this.props; const { conversionRate, nativeCurrency } = this.props;
const { const {
fromAccount: { balance }, fromAccount: { balance },
} = this.state; } = this.state;
const balanceInEther = conversionUtil(balance, { const balanceInBaseAsset = conversionUtil(balance, {
fromNumericBase: 'hex', fromNumericBase: 'hex',
toNumericBase: 'dec', toNumericBase: 'dec',
fromDenomination: 'WEI', fromDenomination: 'WEI',
@ -127,7 +94,7 @@ export default class SignatureRequestOriginal extends Component {
{`${this.context.t('balance')}:`} {`${this.context.t('balance')}:`}
</div> </div>
<div className="request-signature__balance-value"> <div className="request-signature__balance-value">
{`${balanceInEther} ETH`} {`${balanceInBaseAsset} ${nativeCurrency}`}
</div> </div>
</div> </div>
); );
@ -300,7 +267,6 @@ export default class SignatureRequestOriginal extends Component {
large large
className="request-signature__footer__cancel-button" className="request-signature__footer__cancel-button"
onClick={async (event) => { onClick={async (event) => {
this._removeBeforeUnload();
await cancel(event); await cancel(event);
metricsEvent({ metricsEvent({
eventOpts: { eventOpts: {
@ -325,7 +291,6 @@ export default class SignatureRequestOriginal extends Component {
className="request-signature__footer__sign-button" className="request-signature__footer__sign-button"
disabled={hardwareWalletRequiresConnection} disabled={hardwareWalletRequiresConnection}
onClick={async (event) => { onClick={async (event) => {
this._removeBeforeUnload();
await sign(event); await sign(event);
metricsEvent({ metricsEvent({
eventOpts: { eventOpts: {

View File

@ -13,7 +13,10 @@ import {
import { getAccountByAddress } from '../../../helpers/utils/util'; import { getAccountByAddress } from '../../../helpers/utils/util';
import { clearConfirmTransaction } from '../../../ducks/confirm-transaction/confirm-transaction.duck'; import { clearConfirmTransaction } from '../../../ducks/confirm-transaction/confirm-transaction.duck';
import { getMostRecentOverviewPage } from '../../../ducks/history/history'; import { getMostRecentOverviewPage } from '../../../ducks/history/history';
import { isAddressLedger } from '../../../ducks/metamask/metamask'; import {
isAddressLedger,
getNativeCurrency,
} from '../../../ducks/metamask/metamask';
import SignatureRequestOriginal from './signature-request-original.component'; import SignatureRequestOriginal from './signature-request-original.component';
function mapStateToProps(state, ownProps) { function mapStateToProps(state, ownProps) {
@ -34,6 +37,7 @@ function mapStateToProps(state, ownProps) {
mostRecentOverviewPage: getMostRecentOverviewPage(state), mostRecentOverviewPage: getMostRecentOverviewPage(state),
hardwareWalletRequiresConnection, hardwareWalletRequiresConnection,
isLedgerWallet, isLedgerWallet,
nativeCurrency: getNativeCurrency(state),
// not passed to component // not passed to component
allAccounts: accountsWithSendEtherInfoSelector(state), allAccounts: accountsWithSendEtherInfoSelector(state),
domainMetadata: getDomainMetadata(state), domainMetadata: getDomainMetadata(state),

View File

@ -1,12 +1,10 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { getEnvironmentType } from '../../../../app/scripts/lib/util';
import Identicon from '../../ui/identicon'; import Identicon from '../../ui/identicon';
import LedgerInstructionField from '../ledger-instruction-field'; import LedgerInstructionField from '../ledger-instruction-field';
import Header from './signature-request-header'; import Header from './signature-request-header';
import Footer from './signature-request-footer'; import Footer from './signature-request-footer';
import Message from './signature-request-message'; import Message from './signature-request-message';
import { ENVIRONMENT_TYPE_NOTIFICATION } from './signature-request.constants';
export default class SignatureRequest extends PureComponent { export default class SignatureRequest extends PureComponent {
static propTypes = { static propTypes = {
@ -17,7 +15,6 @@ export default class SignatureRequest extends PureComponent {
name: PropTypes.string, name: PropTypes.string,
}).isRequired, }).isRequired,
isLedgerWallet: PropTypes.bool, isLedgerWallet: PropTypes.bool,
clearConfirmTransaction: PropTypes.func.isRequired,
cancel: PropTypes.func.isRequired, cancel: PropTypes.func.isRequired,
sign: PropTypes.func.isRequired, sign: PropTypes.func.isRequired,
hardwareWalletRequiresConnection: PropTypes.func.isRequired, hardwareWalletRequiresConnection: PropTypes.func.isRequired,
@ -28,33 +25,6 @@ export default class SignatureRequest extends PureComponent {
metricsEvent: PropTypes.func, metricsEvent: PropTypes.func,
}; };
componentDidMount() {
if (getEnvironmentType() === ENVIRONMENT_TYPE_NOTIFICATION) {
window.addEventListener('beforeunload', this._beforeUnload);
}
}
_beforeUnload = (event) => {
const {
clearConfirmTransaction,
cancel,
txData: { type },
} = this.props;
const { metricsEvent } = this.context;
metricsEvent({
eventOpts: {
category: 'Transactions',
action: 'Sign Request',
name: 'Cancel Sig Request Via Notification Close',
},
customVariables: {
type,
},
});
clearConfirmTransaction();
cancel(event);
};
formatWallet(wallet) { formatWallet(wallet) {
return `${wallet.slice(0, 8)}...${wallet.slice( return `${wallet.slice(0, 8)}...${wallet.slice(
wallet.length - 8, wallet.length - 8,
@ -79,7 +49,6 @@ export default class SignatureRequest extends PureComponent {
const { metricsEvent } = this.context; const { metricsEvent } = this.context;
const onSign = (event) => { const onSign = (event) => {
window.removeEventListener('beforeunload', this._beforeUnload);
sign(event); sign(event);
metricsEvent({ metricsEvent({
eventOpts: { eventOpts: {
@ -95,7 +64,6 @@ export default class SignatureRequest extends PureComponent {
}; };
const onCancel = (event) => { const onCancel = (event) => {
window.removeEventListener('beforeunload', this._beforeUnload);
cancel(event); cancel(event);
metricsEvent({ metricsEvent({
eventOpts: { eventOpts: {

View File

@ -1,5 +1,4 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { clearConfirmTransaction } from '../../../ducks/confirm-transaction/confirm-transaction.duck';
import { import {
accountsWithSendEtherInfoSelector, accountsWithSendEtherInfoSelector,
doesAddressRequireLedgerHidConnection, doesAddressRequireLedgerHidConnection,
@ -28,12 +27,6 @@ function mapStateToProps(state, ownProps) {
}; };
} }
function mapDispatchToProps(dispatch) {
return {
clearConfirmTransaction: () => dispatch(clearConfirmTransaction()),
};
}
function mergeProps(stateProps, dispatchProps, ownProps) { function mergeProps(stateProps, dispatchProps, ownProps) {
const { const {
allAccounts, allAccounts,
@ -83,8 +76,4 @@ function mergeProps(stateProps, dispatchProps, ownProps) {
}; };
} }
export default connect( export default connect(mapStateToProps, null, mergeProps)(SignatureRequest);
mapStateToProps,
mapDispatchToProps,
mergeProps,
)(SignatureRequest);

View File

@ -15,4 +15,55 @@
text-transform: uppercase; text-transform: uppercase;
} }
} }
&-edit-V2 {
margin-bottom: 10px;
display: flex;
align-items: baseline;
justify-content: flex-end;
padding-top: 20px;
button {
@include H7;
display: flex;
align-items: baseline;
color: $primary-1;
background: transparent;
border: 0;
padding-inline-end: 0;
white-space: pre;
}
i {
color: $primary-1;
margin-right: 2px;
}
&__icon {
font-size: 1rem;
}
&__label {
font-size: 12px;
margin-right: 8px;
}
.info-tooltip {
align-self: center;
margin-left: 6px;
}
&__tooltip {
p {
color: $Grey-500;
}
b {
color: $neutral-black;
display: inline-block;
min-width: 60%;
}
}
}
} }

View File

@ -1,12 +1,73 @@
import React, { useContext } from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { I18nContext } from '../../../contexts/i18n'; import { useGasFeeContext } from '../../../contexts/gasFee';
import InfoTooltip from '../../ui/info-tooltip/info-tooltip';
import Typography from '../../ui/typography/typography';
import TransactionDetailItem from '../transaction-detail-item/transaction-detail-item.component'; import TransactionDetailItem from '../transaction-detail-item/transaction-detail-item.component';
import { COLORS } from '../../../helpers/constants/design-system';
import { PRIORITY_LEVEL_ICON_MAP } from '../../../helpers/constants/gas';
import { useI18nContext } from '../../../hooks/useI18nContext';
export default function TransactionDetail({ rows = [], onEdit }) { export default function TransactionDetail({ rows = [], onEdit }) {
const t = useContext(I18nContext); // eslint-disable-next-line prefer-destructuring
const EIP_1559_V2 = process.env.EIP_1559_V2;
const t = useI18nContext();
const {
gasLimit,
estimateUsed,
maxFeePerGas,
maxPriorityFeePerGas,
transaction,
} = useGasFeeContext();
if (EIP_1559_V2 && estimateUsed) {
return (
<div className="transaction-detail">
<div className="transaction-detail-edit-V2">
<button onClick={onEdit}>
<span className="transaction-detail-edit-V2__icon">
{`${PRIORITY_LEVEL_ICON_MAP[estimateUsed]} `}
</span>
<span className="transaction-detail-edit-V2__label">
{t(estimateUsed)}
</span>
<i className="fas fa-chevron-right asset-list-item__chevron-right" />
</button>
{estimateUsed === 'custom' && onEdit && (
<button onClick={onEdit}>{t('edit')}</button>
)}
{estimateUsed === 'dappSuggested' && (
<InfoTooltip
contentText={
<div className="transaction-detail-edit-V2__tooltip">
<Typography fontSize="12px" color={COLORS.GREY}>
{t('dappSuggestedTooltip', [transaction.origin])}
</Typography>
<Typography fontSize="12px">
<b>{t('maxBaseFee')}</b>
{maxFeePerGas}
</Typography>
<Typography fontSize="12px">
<b>{t('maxPriorityFee')}</b>
{maxPriorityFeePerGas}
</Typography>
<Typography fontSize="12px">
<b>{t('gasLimit')}</b>
{gasLimit}
</Typography>
</div>
}
position="top"
/>
)}
</div>
<div className="transaction-detail-rows">{rows}</div>
</div>
);
}
return ( return (
<div className="transaction-detail"> <div className="transaction-detail">

View File

@ -0,0 +1,94 @@
import React from 'react';
import { screen } from '@testing-library/react';
import { ETH } from '../../../helpers/constants/common';
import { GasFeeContextProvider } from '../../../contexts/gasFee';
import { renderWithProvider } from '../../../../test/jest';
import configureStore from '../../../store/store';
import TransactionDetail from './transaction-detail.component';
jest.mock('../../../store/actions', () => ({
disconnectGasFeeEstimatePoller: jest.fn(),
getGasFeeEstimatesAndStartPolling: jest
.fn()
.mockImplementation(() => Promise.resolve()),
addPollingTokenToAppState: jest.fn(),
}));
const render = (props) => {
const store = configureStore({
metamask: {
nativeCurrency: ETH,
preferences: {
useNativeCurrencyAsPrimaryCurrency: true,
},
provider: {},
cachedBalances: {},
accounts: {
'0xAddress': {
address: '0xAddress',
balance: '0x176e5b6f173ebe66',
},
},
selectedAddress: '0xAddress',
featureFlags: { advancedInlineGas: true },
},
});
return renderWithProvider(
<GasFeeContextProvider {...props}>
<TransactionDetail
onEdit={() => {
console.log('on edit');
}}
rows={[]}
{...props}
/>
</GasFeeContextProvider>,
store,
);
};
describe('TransactionDetail', () => {
beforeEach(() => {
process.env.EIP_1559_V2 = true;
});
afterEach(() => {
process.env.EIP_1559_V2 = false;
});
it('should render edit link with text low if low gas estimates are selected', () => {
render({ transaction: { userFeeLevel: 'low' } });
expect(screen.queryByText('🐢')).toBeInTheDocument();
expect(screen.queryByText('Low')).toBeInTheDocument();
});
it('should render edit link with text markey if medium gas estimates are selected', () => {
render({ transaction: { userFeeLevel: 'medium' } });
expect(screen.queryByText('🦊')).toBeInTheDocument();
expect(screen.queryByText('Market')).toBeInTheDocument();
});
it('should render edit link with text agressive if high gas estimates are selected', () => {
render({ transaction: { userFeeLevel: 'high' } });
expect(screen.queryByText('🦍')).toBeInTheDocument();
expect(screen.queryByText('Aggressive')).toBeInTheDocument();
});
it('should render edit link with text Site suggested if site suggested estimated are used', () => {
render({
transaction: {
dappSuggestedGasFees: { maxFeePerGas: 1, maxPriorityFeePerGas: 1 },
txParams: { maxFeePerGas: 1, maxPriorityFeePerGas: 1 },
},
});
expect(screen.queryByText('🌐')).toBeInTheDocument();
expect(screen.queryByText('Site suggested')).toBeInTheDocument();
expect(document.getElementsByClassName('info-tooltip')).toHaveLength(1);
});
it('should render edit link with text advance if custom gas estimates are used', () => {
render({
defaultEstimateToUse: 'custom',
});
expect(screen.queryByText('⚙')).toBeInTheDocument();
expect(screen.queryByText('Advanced')).toBeInTheDocument();
expect(screen.queryByText('Edit')).toBeInTheDocument();
});
});

View File

@ -26,6 +26,7 @@ export default function ActionableMessage({
type = 'default', type = 'default',
useIcon = false, useIcon = false,
iconFillColor = '', iconFillColor = '',
roundedButtons,
}) { }) {
const actionableMessageClassName = classnames( const actionableMessageClassName = classnames(
'actionable-message', 'actionable-message',
@ -35,6 +36,9 @@ export default function ActionableMessage({
{ 'actionable-message--with-icon': useIcon }, { 'actionable-message--with-icon': useIcon },
); );
const onlyOneAction =
(primaryAction && !secondaryAction) || (secondaryAction && !primaryAction);
return ( return (
<div className={actionableMessageClassName}> <div className={actionableMessageClassName}>
{useIcon ? <InfoTooltipIcon fillColor={iconFillColor} /> : null} {useIcon ? <InfoTooltipIcon fillColor={iconFillColor} /> : null}
@ -47,12 +51,19 @@ export default function ActionableMessage({
)} )}
<div className="actionable-message__message">{message}</div> <div className="actionable-message__message">{message}</div>
{(primaryAction || secondaryAction) && ( {(primaryAction || secondaryAction) && (
<div className="actionable-message__actions"> <div
className={classnames('actionable-message__actions', {
'actionable-message__actions--single': onlyOneAction,
})}
>
{primaryAction && ( {primaryAction && (
<button <button
className={classnames( className={classnames(
'actionable-message__action', 'actionable-message__action',
'actionable-message__action--primary', 'actionable-message__action--primary',
{
'actionable-message__action--rounded': roundedButtons,
},
)} )}
onClick={primaryAction.onClick} onClick={primaryAction.onClick}
> >
@ -64,6 +75,9 @@ export default function ActionableMessage({
className={classnames( className={classnames(
'actionable-message__action', 'actionable-message__action',
'actionable-message__action--secondary', 'actionable-message__action--secondary',
{
'actionable-message__action--rounded': roundedButtons,
},
)} )}
onClick={secondaryAction.onClick} onClick={secondaryAction.onClick}
> >
@ -92,4 +106,5 @@ ActionableMessage.propTypes = {
infoTooltipText: PropTypes.string, infoTooltipText: PropTypes.string,
useIcon: PropTypes.bool, useIcon: PropTypes.bool,
iconFillColor: PropTypes.string, iconFillColor: PropTypes.string,
roundedButtons: PropTypes.bool,
}; };

View File

@ -38,14 +38,22 @@
&__actions { &__actions {
display: flex; display: flex;
width: 80%; width: 80%;
justify-content: space-evenly; justify-content: flex-end;
align-items: center; align-items: center;
margin-top: 10px; margin-top: 10px;
color: $Blue-600; color: $Blue-600;
&--single {
width: 100%;
}
} }
&__action { &__action {
font-weight: bold; font-weight: bold;
&--rounded {
border-radius: 8px;
}
} }
&__info-tooltip-wrapper { &__info-tooltip-wrapper {

View File

@ -0,0 +1,42 @@
import { Story, Canvas, ArgsTable } from '@storybook/addon-docs';
import Card from '.';
# Card
Cards are used to group related content or actions together.
<Canvas>
<Story id="ui-components-ui-card-card-stories-js--default-story" />
</Canvas>
## Component API
The `Card` component extends the `Box` component. See the `Box` component for an extended list of props.
<ArgsTable of={Card} />
## Usage
The following describes the props and example usage for this component.
### Padding, Border and Background Color
The Card component has a set of default props that should meet most card use cases. There is a strong recommendation to not overwrite these to ensure our cards stay consistent across the app.
That being said all props can be overwritten if necessary.
```jsx
import { COLORS } from '../../../helpers/constants/design-system';
// To remove the border
<Card border={false} />
// All border related props of the Box component will work
// To remove or change padding
<Card padding={0} />
// All padding related props of the Box component will work
// To change the background color
<Card backgroundColor={COLORS.UI4} />
```

View File

@ -1,23 +0,0 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
export default class Card extends PureComponent {
static propTypes = {
className: PropTypes.string,
overrideClassName: PropTypes.bool,
title: PropTypes.string,
children: PropTypes.node,
};
render() {
const { className, overrideClassName, title } = this.props;
return (
<div className={classnames({ card: !overrideClassName }, className)}>
<div className="card__title">{title}</div>
{this.props.children}
</div>
);
}
}

View File

@ -1,21 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import Card from './card.component';
describe('Card Component', () => {
it('should render a card with a title and child element', () => {
const wrapper = shallow(
<Card title="Test" className="card-test-class">
<div className="child-test-class">Child</div>
</Card>,
);
expect(wrapper.hasClass('card-test-class')).toStrictEqual(true);
const title = wrapper.find('.card__title');
expect(title).toHaveLength(1);
expect(title.text()).toStrictEqual('Test');
const child = wrapper.find('.child-test-class');
expect(child).toHaveLength(1);
expect(child.text()).toStrictEqual('Child');
});
});

View File

@ -0,0 +1,60 @@
import React from 'react';
import PropTypes from 'prop-types';
import Box from '../box';
import {
BORDER_STYLE,
COLORS,
SIZES,
} from '../../../helpers/constants/design-system';
const Card = ({
border = true,
padding = 4,
backgroundColor = COLORS.WHITE,
children,
...props
}) => {
const defaultBorderProps = {
borderColor: border && COLORS.UI2,
borderRadius: border && SIZES.MD,
borderStyle: border && BORDER_STYLE.SOLID,
};
return (
<Box
{...{
padding,
backgroundColor,
...defaultBorderProps,
...props,
}}
>
{children}
</Box>
);
};
Card.propTypes = {
/**
* Whether the Card has a border or not.
* Defaults to true
*/
border: PropTypes.bool,
/**
* Padding of the Card component accepts number or an array of 2 numbers.
* Defaults to 4 (16px)
*/
padding: Box.propTypes.padding,
/**
* The background color of the card
* Defaults to COLORS.WHITE
*/
backgroundColor: Box.propTypes.backgroundColor,
/**
* The Card component accepts all Box component props
*/
...Box.propTypes,
};
export default Card;

View File

@ -0,0 +1,169 @@
import React from 'react';
import {
ALIGN_ITEMS,
BLOCK_SIZES,
BORDER_STYLE,
COLORS,
DISPLAY,
JUSTIFY_CONTENT,
TEXT_ALIGN,
} from '../../../helpers/constants/design-system';
import README from './README.mdx';
import Card from '.';
const sizeOptions = [undefined, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
export default {
title: 'UI/Card',
id: __filename,
component: Card,
parameters: {
docs: {
page: README,
},
},
argTypes: {
children: { control: 'text' },
border: {
control: 'boolean',
},
borderStyle: {
control: {
type: 'select',
},
options: Object.values(BORDER_STYLE),
},
borderWidth: {
control: {
type: 'select',
},
options: [...sizeOptions],
},
borderColor: {
control: {
type: 'select',
},
options: Object.values(COLORS),
},
backgroundColor: {
control: {
type: 'select',
},
options: Object.values(COLORS),
},
width: {
control: {
type: 'select',
},
options: Object.values(BLOCK_SIZES),
},
height: {
control: {
type: 'select',
},
options: Object.values(BLOCK_SIZES),
},
textAlign: {
control: {
type: 'select',
},
options: Object.values(TEXT_ALIGN),
},
margin: {
control: {
type: 'select',
},
options: [...sizeOptions],
},
marginTop: {
control: {
type: 'select',
},
options: [...sizeOptions],
},
marginRight: {
control: {
type: 'select',
},
options: [...sizeOptions],
},
marginBottom: {
control: {
type: 'select',
},
options: [...sizeOptions],
},
marginLeft: {
control: {
type: 'select',
},
options: [...sizeOptions],
},
padding: {
control: {
type: 'select',
},
options: [...sizeOptions],
},
paddingTop: {
control: {
type: 'select',
},
options: [...sizeOptions],
},
paddingRight: {
control: {
type: 'select',
},
options: [...sizeOptions],
},
paddingBottom: {
control: {
type: 'select',
},
options: [...sizeOptions],
},
paddingLeft: {
control: {
type: 'select',
},
options: [...sizeOptions],
},
display: {
control: {
type: 'select',
},
options: Object.values(DISPLAY),
},
justifyContent: {
control: {
type: 'select',
},
options: Object.values(JUSTIFY_CONTENT),
},
alignItems: {
control: {
type: 'select',
},
options: Object.values(ALIGN_ITEMS),
},
},
args: {
children: 'Card children',
},
};
export const DefaultStory = (args) => <Card {...args}>{args.children}</Card>;
DefaultStory.storyName = 'Default';
DefaultStory.args = {
padding: 4,
border: true,
borderWidth: 1,
borderColor: COLORS.UI2,
borderStyle: BORDER_STYLE.SOLID,
backgroundColor: COLORS.WHITE,
display: DISPLAY.BLOCK,
};

Some files were not shown because too many files have changed in this diff Show More