diff --git a/.circleci/config.yml b/.circleci/config.yml index c6ca6e21e..1f6300816 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -660,8 +660,8 @@ jobs: - attach_workspace: at: . - run: - name: test:coverage - command: yarn test:coverage + name: test:coverage:mocha + command: yarn test:coverage:mocha - run: name: test:coverage:jest command: yarn test:coverage:jest diff --git a/.eslintrc.js b/.eslintrc.js index 17b766601..61084919a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -22,6 +22,7 @@ module.exports = { ignorePatterns: [ '!.eslintrc.js', + '!.mocharc.js', 'node_modules/**', 'dist/**', 'builds/**', @@ -86,6 +87,27 @@ module.exports = { 'node/no-process-env': 'off', + // TODO: remove this override + 'padding-line-between-statements': [ + 'error', + { + blankLine: 'always', + prev: 'directive', + next: '*', + }, + { + blankLine: 'any', + prev: 'directive', + next: 'directive', + }, + // Disabled temporarily to reduce conflicts while PR queue is large + // { + // blankLine: 'always', + // prev: ['multiline-block-like', 'multiline-expression'], + // next: ['multiline-block-like', 'multiline-expression'], + // }, + ], + // TODO: re-enable these rules 'node/no-sync': 'off', 'node/no-unpublished-import': 'off', @@ -136,8 +158,11 @@ module.exports = { 'ui/__mocks__/*.js', 'shared/**/*.test.js', 'development/**/*.test.js', + 'app/scripts/lib/**/*.test.js', 'app/scripts/migrations/*.test.js', 'app/scripts/platforms/*.test.js', + 'app/scripts/controllers/network/**/*.test.js', + 'app/scripts/controllers/permissions/*.test.js', ], extends: ['@metamask/eslint-config-mocha'], rules: { @@ -160,8 +185,11 @@ module.exports = { 'ui/__mocks__/*.js', 'shared/**/*.test.js', 'development/**/*.test.js', + 'app/scripts/lib/**/*.test.js', 'app/scripts/migrations/*.test.js', 'app/scripts/platforms/*.test.js', + 'app/scripts/controllers/network/**/*.test.js', + 'app/scripts/controllers/permissions/*.test.js', ], extends: ['@metamask/eslint-config-jest'], rules: { @@ -184,7 +212,9 @@ module.exports = { { files: [ '.eslintrc.js', + '.mocharc.js', 'babel.config.js', + 'jest.config.js', 'nyc.config.js', 'stylelint.config.js', 'app/scripts/lockdown-run.js', @@ -195,7 +225,6 @@ module.exports = { 'test/setup.js', 'test/helpers/protect-intrinsics-helpers.js', 'test/lib/wait-until-called.js', - 'jest.config.js', ], parserOptions: { sourceType: 'script', diff --git a/.github/workflows/crowdin_action.yml b/.github/workflows/crowdin_action.yml new file mode 100644 index 000000000..fce3edce5 --- /dev/null +++ b/.github/workflows/crowdin_action.yml @@ -0,0 +1,31 @@ +name: Crowdin Action + +permissions: + contents: write + pull-requests: write + +on: + push: + branches: + - develop + schedule: + - cron: "0 */12 * * *" + +jobs: + synchronize-with-crowdin: + runs-on: ubuntu-latest + + steps: + + - name: Checkout + uses: actions/checkout@v2 + + - name: crowdin action + uses: crowdin/github-action@d0622816ed4f4744db27d04374b2cef6867f7bed + with: + upload_translations: true + download_translations: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} diff --git a/.metamaskrc.dist b/.metamaskrc.dist index 60aecdf18..da42a6508 100644 --- a/.metamaskrc.dist +++ b/.metamaskrc.dist @@ -2,3 +2,7 @@ PASSWORD=METAMASK PASSWORD INFURA_PROJECT_ID=00000000000 SEGMENT_WRITE_KEY= +ONBOARDING_V2= +EIP_1559_V2= +SWAPS_USE_DEV_APIS= +COLLECTIBLES_V1= diff --git a/.mocharc.js b/.mocharc.js index 83776be4f..f0e374691 100644 --- a/.mocharc.js +++ b/.mocharc.js @@ -1,7 +1,13 @@ module.exports = { // TODO: Remove the `exit` setting, it can hide broken tests. exit: true, - ignore: ['./app/scripts/migrations/*.test.js', './app/scripts/platforms/*.test.js'], + ignore: [ + './app/scripts/lib/**/*.test.js', + './app/scripts/migrations/*.test.js', + './app/scripts/platforms/*.test.js', + './app/scripts/controllers/network/**/*.test.js', + './app/scripts/controllers/permissions/*.test.js', + ], recursive: true, require: ['test/env.js', 'test/setup.js'], -} +}; diff --git a/.mocharc.lax.js b/.mocharc.lax.js deleted file mode 100644 index 8921b4a3f..000000000 --- a/.mocharc.lax.js +++ /dev/null @@ -1,5 +0,0 @@ -const baseConfig = require('./.mocharc'); - -module.exports = Object.assign({}, baseConfig, { - ignore: [...baseConfig.ignore, './app/scripts/controllers/permissions/*.test.js'] -}); diff --git a/.storybook/images/catnip-spicywright.png b/.storybook/images/catnip-spicywright.png new file mode 100644 index 000000000..ed0379e08 Binary files /dev/null and b/.storybook/images/catnip-spicywright.png differ diff --git a/.storybook/initial-states/approval-screens/token-approval.js b/.storybook/initial-states/approval-screens/token-approval.js index f03990ea5..08aa375d7 100644 --- a/.storybook/initial-states/approval-screens/token-approval.js +++ b/.storybook/initial-states/approval-screens/token-approval.js @@ -46,11 +46,11 @@ export const currentNetworkTxListSample = { ] } -export const domainMetadata = { +export const subjectMetadata = { "https://metamask.github.io": { + "origin": "https://metamask.github.io", "name": "E2E Test Dapp", - "icon": "https://metamask.github.io/test-dapp/metamask-fox.svg", - "lastUpdated": 1620723443380, - "host": "metamask.github.io" + "iconUrl": "https://metamask.github.io/test-dapp/metamask-fox.svg", + "subjectType": "website" } } \ No newline at end of file diff --git a/.storybook/manager.js b/.storybook/manager.js new file mode 100644 index 000000000..3f32fb0ba --- /dev/null +++ b/.storybook/manager.js @@ -0,0 +1,6 @@ +import { addons } from '@storybook/addons'; +import MetaMaskStorybookTheme from './metamask-storybook-theme'; + +addons.setConfig({ + theme: MetaMaskStorybookTheme, +}); diff --git a/.storybook/metamask-storybook-theme.js b/.storybook/metamask-storybook-theme.js new file mode 100644 index 000000000..6623f4fe0 --- /dev/null +++ b/.storybook/metamask-storybook-theme.js @@ -0,0 +1,14 @@ +// .storybook/YourTheme.js + +import { create } from '@storybook/theming'; +import logo from '../app/images/logo/metamask-logo-horizontal.svg'; + +export default create({ + base: 'light', + brandTitle: 'MetaMask Storybook', + brandImage: logo, + + // Typography + fontBase: 'Euclid, Roboto, Helvetica, Arial, sans-serif', + fontCode: 'Inconsolata, monospace', +}); diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html new file mode 100644 index 000000000..4ac472636 --- /dev/null +++ b/.storybook/preview-head.html @@ -0,0 +1,27 @@ + + + + + diff --git a/.storybook/preview.js b/.storybook/preview.js index 71fa49be3..9e6fe2206 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -1,7 +1,6 @@ import React, { useEffect } from 'react'; import { addDecorator, addParameters } from '@storybook/react'; import { action } from '@storybook/addon-actions'; -import { withKnobs } from '@storybook/addon-knobs'; import { Provider } from 'react-redux'; import configureStore from '../ui/store/store'; import '../ui/css/index.scss'; @@ -13,6 +12,7 @@ import testData from './test-data.js'; import { Router } from 'react-router-dom'; import { createBrowserHistory } from 'history'; import { _setBackgroundConnection } from '../ui/store/actions'; +import MetaMaskStorybookTheme from './metamask-storybook-theme'; addParameters({ backgrounds: { @@ -22,6 +22,14 @@ addParameters({ { name: 'dark', value: '#333333' }, ], }, + docs: { + theme: MetaMaskStorybookTheme, + }, + options: { + storySort: { + order: ['Getting Started', 'Components', ['UI', 'App'], 'Pages'], + }, + }, }); export const globalTypes = { @@ -77,5 +85,4 @@ const metamaskDecorator = (story, context) => { ); }; -addDecorator(withKnobs); addDecorator(metamaskDecorator); diff --git a/.storybook/test-data.js b/.storybook/test-data.js index 398ef19b1..3f732bc90 100644 --- a/.storybook/test-data.js +++ b/.storybook/test-data.js @@ -1013,34 +1013,25 @@ const state = { goerli: null, mainnet: 10902989, }, - permissionsRequests: [], - permissionsDescriptions: {}, - domains: { + subjects: { 'https://app.uniswap.org': { - permissions: [ - { - '@context': ['https://github.com/MetaMask/rpc-cap'], + permissions: { + 'eth_accounts': { invoker: 'https://app.uniswap.org', parentCapability: 'eth_accounts', id: 'a7342e4b-beae-4525-a36c-c0635fd03359', date: 1620710693178, caveats: [ { - type: 'limitResponseLength', - value: 1, - name: 'primaryAccountOnly', - }, - { - type: 'filterResponse', + type: 'restrictReturnedAccounts', value: ['0x64a845a5b02460acf8a3d84503b0d68d028b4bb4'], - name: 'exposedAccounts', }, ], }, - ], + }, }, }, - permissionsLog: [ + permissionActivityLog: [ { id: 522690215, method: 'eth_accounts', @@ -1171,7 +1162,7 @@ const state = { success: true, }, ], - permissionsHistory: { + permissionHistory: { 'https://metamask.github.io': { eth_accounts: { lastApproved: 1620710693213, @@ -1181,18 +1172,18 @@ const state = { }, }, }, - domainMetadata: { + subjectMetadata: { 'https://metamask.github.io': { name: 'E2E Test Dapp', - icon: 'https://metamask.github.io/test-dapp/metamask-fox.svg', - lastUpdated: 1620723443380, - host: 'metamask.github.io', + origin: 'https://metamask.github.io', + iconUrl: 'https://metamask.github.io/test-dapp/metamask-fox.svg', + subjectType: 'website', }, 'https://app.uniswap.org': { name: 'Uniswap', - icon: './UNI.png', - lastUpdated: 1620723443380, - host: 'app.uniswap.org', + origin: 'https://app.uniswap.org', + iconUrl: './UNI.png', + subjectType: 'website', }, }, threeBoxSyncingAllowed: false, diff --git a/README.md b/README.md index 76ce67bda..289b93534 100644 --- a/README.md +++ b/README.md @@ -33,13 +33,22 @@ See the [build system readme](./development/build/README.md) for build system us To start a development build (e.g. with logging and file watching) run `yarn start`. -To start the [React DevTools](https://github.com/facebook/react-devtools) and [Redux DevTools Extension](https://github.com/reduxjs/redux-devtools/tree/main/extension) - alongside the app, use `yarn start:dev`. - - React DevTools will open in a separate window; no browser extension is required - - Redux DevTools will need to be installed as a browser extension. Open the Redux Remote Devtools to access Redux state logs. This can be done by either right clicking within the web browser to bring up the context menu, expanding the Redux DevTools panel and clicking Open Remote DevTools OR clicking the Redux DevTools extension icon and clicking Open Remote DevTools. - - You will also need to check the "Use custom (local) server" checkbox in the Remote DevTools Settings, using the default server configuration (host `localhost`, port `8000`, secure connection checkbox unchecked) +#### React and Redux DevTools -[Test site](https://metamask.github.io/test-dapp/) can be used to execute different user flows. +To start the [React DevTools](https://github.com/facebook/react-devtools), run `yarn devtools:react` with a development build installed in a browser. This will open in a separate window; no browser extension is required. + +To start the [Redux DevTools Extension](https://github.com/reduxjs/redux-devtools/tree/main/extension): +- Install the package `remotedev-server` globally (e.g. `yarn global add remotedev-server`) +- Install the Redux Devtools extension. +- Open the Redux DevTools extension and check the "Use custom (local) server" checkbox in the Remote DevTools Settings, using the default server configuration (host `localhost`, port `8000`, secure connection checkbox unchecked). + +Then run the command `yarn devtools:redux` with a development build installed in a browser. This will enable you to use the Redux DevTools extension to inspect MetaMask. + +To create a development build and run both of these tools simultaneously, run `yarn start:dev`. + +#### Test Dapp + +[This test site](https://metamask.github.io/test-dapp/) can be used to execute different user flows. ### Running Unit Tests and Linting @@ -61,6 +70,7 @@ Whenever you change dependencies (adding, removing, or updating, either in `pack * `yarn.lock`: * Run `yarn setup` again after your changes to ensure `yarn.lock` has been properly updated. + * Run `yarn yarn-deduplicate` to remove duplicate dependencies from the lockfile. * 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. * Unfortunately, `yarn allow-scripts auto` will behave inconsistently on different platforms. macOS and Windows users may see extraneous changes relating to optional dependencies. diff --git a/app/_locales/de/messages.json b/app/_locales/de/messages.json index 88a3b99c7..6ff4aea11 100644 --- a/app/_locales/de/messages.json +++ b/app/_locales/de/messages.json @@ -736,7 +736,7 @@ "message": "Zurücksetzen" }, "resetAccount": { - "message": "Account zurücksetzten" + "message": "Account zurücksetzen" }, "resetAccountDescription": { "message": "Durch das Zurücksetzen Ihres Kontos wird Ihr Transaktionsverlauf gelöscht." diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 5fd049c23..9739fcf7c 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -73,6 +73,10 @@ "accountName": { "message": "Account Name" }, + "accountNameDuplicate": { + "message": "This account name already exists", + "description": "This is an error message shown when the user enters a new account name that matches an existing account name" + }, "accountOptions": { "message": "Account Options" }, @@ -139,9 +143,6 @@ "addNFT": { "message": "Add NFT" }, - "addNFTLowerCase": { - "message": "add NFT" - }, "addNetwork": { "message": "Add Network" }, @@ -167,7 +168,7 @@ "message": "Advanced" }, "advancedBaseGasFeeToolTip": { - "message": "Any difference between your max base fee and the current base fee will be refunded after completion." + "message": "When your transaction gets included in the block, any difference between your max base fee and the actual base fee will be refunded. Total amount is calculated as max base fee (in GWEI) * gas limit." }, "advancedGasFeeModalTitle": { "message": "Advanced gas fee" @@ -320,6 +321,9 @@ "balanceOutdated": { "message": "Balance may be outdated" }, + "baseFee": { + "message": "Base fee" + }, "basic": { "message": "Basic" }, @@ -565,6 +569,9 @@ "contract": { "message": "Contract" }, + "contractAddress": { + "message": "Contract address" + }, "contractAddressError": { "message": "You are sending tokens to the token's contract address. This may result in the loss of these tokens." }, @@ -637,6 +644,10 @@ "customGas": { "message": "Customize Gas" }, + "customGasSettingToolTipMessage": { + "message": "Use $1 to customise the gas price. This can be confusing if you aren’t familiar. Interact at your own risk.", + "description": "$1 is key 'advanced' (text: 'Advanced') separated here so that it can be passed in with bold fontweight" + }, "customGasSubTitle": { "message": "Increasing fee may decrease processing times, but it is not guaranteed." }, @@ -649,6 +660,10 @@ "dappSuggested": { "message": "Site suggested" }, + "dappSuggestedGasSettingToolTipMessage": { + "message": "$1 has suggested this price.", + "description": "$1 is url for the dapp that has suggested gas settings" + }, "dappSuggestedShortLabel": { "message": "Site" }, @@ -706,6 +721,9 @@ "depositEther": { "message": "Deposit Ether" }, + "description": { + "message": "Description" + }, "details": { "message": "Details" }, @@ -799,12 +817,28 @@ "editGasLimitOutOfBounds": { "message": "Gas limit must be at least $1" }, + "editGasLimitOutOfBoundsV2": { + "message": "Gas limit must be greater than $1 and less than $2", + "description": "$1 is the minimum limit for gas and $2 is the maximum limit" + }, "editGasLimitTooltip": { "message": "Gas limit is the maximum units of gas you are willing to use. Units of gas are a multiplier to “Max priority fee” and “Max fee”." }, "editGasLow": { "message": "Low" }, + "editGasMaxBaseFeeGWEIImbalance": { + "message": "Max base fee cannot be lower than priority fee" + }, + "editGasMaxBaseFeeHigh": { + "message": "Max base fee is higher than necessary" + }, + "editGasMaxBaseFeeLow": { + "message": "Max base fee is low for current network conditions" + }, + "editGasMaxBaseFeeMultiplierImbalance": { + "message": "Multiplier is low relative to Priority fee" + }, "editGasMaxFeeHigh": { "message": "Max fee is higher than necessary" }, @@ -820,12 +854,21 @@ "editGasMaxPriorityFeeBelowMinimum": { "message": "Max priority fee must be greater than 0 GWEI" }, + "editGasMaxPriorityFeeBelowMinimumV2": { + "message": "Priority fee must be greater than 0." + }, "editGasMaxPriorityFeeHigh": { "message": "Max priority fee is higher than necessary. You may pay more than needed." }, + "editGasMaxPriorityFeeHighV2": { + "message": "Priority fee is higher than necessary. You may pay more than needed" + }, "editGasMaxPriorityFeeLow": { "message": "Max priority fee is low for current network conditions" }, + "editGasMaxPriorityFeeLowV2": { + "message": "Priority fee is low for current network conditions" + }, "editGasMaxPriorityFeeTooltip": { "message": "Max priority fee (aka “miner tip”) goes directly to miners and incentivizes them to prioritize your transaction. You’ll most often pay your max setting" }, @@ -872,6 +915,9 @@ "editPermission": { "message": "Edit Permission" }, + "enableAutoDetect": { + "message": " Enable Autodetect" + }, "enableFromSettings": { "message": " Enable it from Settings." }, @@ -993,7 +1039,7 @@ "message": "Backup gas price is provided as the main gas estimation service is unavailable right now." }, "eth_accounts": { - "message": "View the addresses of your permitted accounts (required)", + "message": "See address, account balance, activity and initiate transactions", "description": "The description for the `eth_accounts` permission" }, "ethereumPublicAddress": { @@ -1071,6 +1117,18 @@ "flaskExperimentalText5": { "message": "Using Flask gives you much greater discretion in using the power of MetaMask, and that discretion is yours. Do you accept these risks as well as extra responsibility for your wallet's safety?" }, + "flaskSnapSettingsCardButtonCta": { + "message": "See details", + "description": "Call to action a user can take to see more information about the Snap that is installed" + }, + "flaskSnapSettingsCardDateAddedOn": { + "message": "Added on", + "description": "Start of the sentence describing when and where snap was added" + }, + "flaskSnapSettingsCardFrom": { + "message": "from", + "description": "Part of the sentence describing when and where snap was added" + }, "followUsOnTwitter": { "message": "Follow us on Twitter" }, @@ -1116,6 +1174,9 @@ "message": "Gas limit must be at least $1", "description": "$1 is the custom gas limit, in decimal." }, + "gasLimitV2": { + "message": "Gas limit" + }, "gasOption": { "message": "Gas option" }, @@ -1250,6 +1311,16 @@ "high": { "message": "Aggressive" }, + "highGasSettingToolTipDialog": { + "message": "High probability, even in volatile markets" + }, + "highGasSettingToolTipMessage": { + "message": "Use $1 to cover surges in network traffic due to things like popular NFT drops.", + "description": "$1 is key 'high' (text: 'Aggressive') separated here so that it can be passed in with bold fontweight" + }, + "highLowercase": { + "message": "high" + }, "history": { "message": "History" }, @@ -1289,6 +1360,9 @@ "importMyWallet": { "message": "Import My Wallet" }, + "importNFTs": { + "message": "Import NFTs" + }, "importTokenQuestion": { "message": "Import token?" }, @@ -1424,6 +1498,9 @@ "learnMore": { "message": "learn more" }, + "learnMoreUpperCase": { + "message": "Learn more" + }, "learnScamRisk": { "message": "scams and security risks." }, @@ -1481,6 +1558,9 @@ "likeToImportTokens": { "message": "Would you like to import these tokens?" }, + "link": { + "message": "Link" + }, "links": { "message": "Links" }, @@ -1505,6 +1585,13 @@ "low": { "message": "Low" }, + "lowGasSettingToolTipMessage": { + "message": "Use $1 to wait for a cheaper price. Time estimates are much less accurate as prices are somewhat unpredicible.", + "description": "$1 is key 'low' separated here so that it can be passed in with bold fontweight" + }, + "lowLowercase": { + "message": "low" + }, "lowPriorityMessage": { "message": "Future transactions will queue after this one. This price was last seen was some time ago." }, @@ -1533,6 +1620,10 @@ "medium": { "message": "Market" }, + "mediumGasSettingToolTipMessage": { + "message": "Use $1 for fast processing at current market price.", + "description": "$1 is key 'medium' (text: 'Market') separated here so that it can be passed in with bold fontweight" + }, "memo": { "message": "memo" }, @@ -1694,6 +1785,17 @@ "networkStatus": { "message": "Network status" }, + "networkStatusBaseFeeTooltip": { + "message": "The base fee is set by the network and changes every 13-14 seconds. Our $1 and $2 options account for sudden increases.", + "description": "$1 and $2 are bold text for Medium and Aggressive respectively." + }, + "networkStatusPriorityFeeTooltip": { + "message": "Range of priority fees (aka “miner tip”). This goes to miners and incentivizes them to prioritize your transaction." + }, + "networkStatusStabilityFeeTooltip": { + "message": "Gas fees are $1 relative to the past 72 hours.", + "description": "$1 is networks stability value - stable, low, high" + }, "networkURL": { "message": "Network URL" }, @@ -1729,10 +1831,10 @@ "message": "New Contract" }, "newNFTsDetected": { - "message": "New NFTs detected" + "message": "New! NFT detection" }, "newNFTsDetectedInfo": { - "message": "One or more new NFTs were detected in your wallet." + "message": "Allow MetaMask to automatically detect NFTs from Opensea and display in your MetaMask wallet." }, "newNetworkAdded": { "message": "“$1” was successfully added!" @@ -1774,6 +1876,9 @@ "noAlreadyHaveSeed": { "message": "No, I already have a Secret Recovery Phrase" }, + "noConversionDateAvailable": { + "message": "No Currency Conversion Date Available" + }, "noConversionRateAvailable": { "message": "No Conversion Rate Available" }, @@ -2034,15 +2139,9 @@ "message": "You have (1) pending transaction.", "description": "$1 is count of pending transactions" }, - "permissionCheckedIconDescription": { - "message": "You have approved this permission" - }, "permissionRequest": { "message": "Permission request" }, - "permissionUncheckedIconDescription": { - "message": "You have not approved this permission" - }, "permissions": { "message": "Permissions" }, @@ -2067,6 +2166,9 @@ "message": "Select native to prioritize displaying values in the native currency of the chain (e.g. ETH). Select Fiat to prioritize displaying values in your selected fiat currency." }, "priorityFee": { + "message": "Priority fee" + }, + "priorityFeeProperCase": { "message": "Priority Fee" }, "privacyMsg": { @@ -2172,6 +2274,9 @@ "removeAccountDescription": { "message": "This account will be removed from your wallet. Please make sure you have the original Secret Recovery Phrase or private key for this imported account before continuing. You can import or create accounts again from the account drop-down. " }, + "removeNFT": { + "message": "Remove NFT" + }, "requestsAwaitingAcknowledgement": { "message": "requests waiting to be acknowledged" }, @@ -2342,7 +2447,7 @@ "message": "Select a higher gas fee to accelerate the processing of your transaction.*" }, "selectAccounts": { - "message": "Select account(s)" + "message": "Select the account(s) to use on this site" }, "selectAll": { "message": "Select all" @@ -2360,7 +2465,7 @@ "message": "Select HD Path" }, "selectNFTPrivacyPreference": { - "message": "Select NFT privacy preference" + "message": "Turn on NFT detection in Settings" }, "selectPathHelp": { "message": "If you don't see the accounts you expect, try switching the HD path." @@ -2490,6 +2595,9 @@ "somethingWentWrong": { "message": "Oops! Something went wrong." }, + "source": { + "message": "Source" + }, "speedUp": { "message": "Speed Up" }, @@ -2530,6 +2638,9 @@ "stable": { "message": "Stable" }, + "stableLowercase": { + "message": "stable" + }, "stateLogError": { "message": "Error in retrieving state logs." }, @@ -2813,6 +2924,12 @@ "swapSourceInfo": { "message": "We search multiple liquidity sources (exchanges, aggregators and professional market makers) to find the best rates and lowest network fees." }, + "swapSuggested": { + "message": "Swap suggested" + }, + "swapSuggestedGasSettingToolTipMessage": { + "message": "Swaps are complex and time sensitive transactions. We recommend this gas fee for a good balance between cost and confidence of a successful Swap." + }, "swapSwapFrom": { "message": "Swap from" }, @@ -3267,6 +3384,9 @@ "message": "View $1 on Etherscan", "description": "$1 is the action type. e.g (Account, Transaction, Swap)" }, + "viewOnOpensea": { + "message": "View on Opensea" + }, "viewinExplorer": { "message": "View $1 in Explorer", "description": "$1 is the action type. e.g (Account, Transaction, Swap)" diff --git a/app/_locales/es/messages.json b/app/_locales/es/messages.json index 195bff8c7..11283e228 100644 --- a/app/_locales/es/messages.json +++ b/app/_locales/es/messages.json @@ -1343,12 +1343,6 @@ "pending": { "message": "Pendiente" }, - "permissionCheckedIconDescription": { - "message": "Aprobó este permiso" - }, - "permissionUncheckedIconDescription": { - "message": "No aprobó este permiso" - }, "permissions": { "message": "Permisos" }, diff --git a/app/_locales/es_419/messages.json b/app/_locales/es_419/messages.json index 195bff8c7..11283e228 100644 --- a/app/_locales/es_419/messages.json +++ b/app/_locales/es_419/messages.json @@ -1343,12 +1343,6 @@ "pending": { "message": "Pendiente" }, - "permissionCheckedIconDescription": { - "message": "Aprobó este permiso" - }, - "permissionUncheckedIconDescription": { - "message": "No aprobó este permiso" - }, "permissions": { "message": "Permisos" }, diff --git a/app/_locales/hi/messages.json b/app/_locales/hi/messages.json index 29d19a07a..a7c0858a9 100644 --- a/app/_locales/hi/messages.json +++ b/app/_locales/hi/messages.json @@ -1343,12 +1343,6 @@ "pending": { "message": "लंबित" }, - "permissionCheckedIconDescription": { - "message": "आपने इस अनुमति को अनुमोदित कर दिया है" - }, - "permissionUncheckedIconDescription": { - "message": "आपने इस अनुमति को अनुमोदित नहीं किया है" - }, "permissions": { "message": "अनुमतियाँ" }, diff --git a/app/_locales/id/messages.json b/app/_locales/id/messages.json index eeee31329..5dc68ae22 100644 --- a/app/_locales/id/messages.json +++ b/app/_locales/id/messages.json @@ -1343,12 +1343,6 @@ "pending": { "message": "Tunda" }, - "permissionCheckedIconDescription": { - "message": "Anda telah menyetujui izin ini" - }, - "permissionUncheckedIconDescription": { - "message": "Anda belum menyetujui izin ini" - }, "permissions": { "message": "Izin" }, diff --git a/app/_locales/it/messages.json b/app/_locales/it/messages.json index 2e3c5720b..27ab35f59 100644 --- a/app/_locales/it/messages.json +++ b/app/_locales/it/messages.json @@ -1102,12 +1102,6 @@ "pending": { "message": "in corso" }, - "permissionCheckedIconDescription": { - "message": "Hai approvato questo permesso" - }, - "permissionUncheckedIconDescription": { - "message": "Non hai approvato questo permesso" - }, "permissions": { "message": "Permessi" }, diff --git a/app/_locales/ja/messages.json b/app/_locales/ja/messages.json index 7c74d8f5f..f46031c5c 100644 --- a/app/_locales/ja/messages.json +++ b/app/_locales/ja/messages.json @@ -1343,12 +1343,6 @@ "pending": { "message": "処理" }, - "permissionCheckedIconDescription": { - "message": "この許可の承認が完了しました。" - }, - "permissionUncheckedIconDescription": { - "message": "この許可の承認が完了していません。" - }, "permissions": { "message": "許可" }, diff --git a/app/_locales/ko/messages.json b/app/_locales/ko/messages.json index 167e3f48d..366c534c8 100644 --- a/app/_locales/ko/messages.json +++ b/app/_locales/ko/messages.json @@ -1343,12 +1343,6 @@ "pending": { "message": "보류 중" }, - "permissionCheckedIconDescription": { - "message": "이 권한을 승인했습니다." - }, - "permissionUncheckedIconDescription": { - "message": "이 권한을 승인하지 않았습니다." - }, "permissions": { "message": "권한" }, diff --git a/app/_locales/ph/messages.json b/app/_locales/ph/messages.json index d1d5110aa..bb6f279c4 100644 --- a/app/_locales/ph/messages.json +++ b/app/_locales/ph/messages.json @@ -1343,12 +1343,6 @@ "pending": { "message": "Nakabinbin" }, - "permissionCheckedIconDescription": { - "message": "Inaprubahan mo ang pahintulot na ito" - }, - "permissionUncheckedIconDescription": { - "message": "Hindi mo inaprubahan ang pahintulot na ito" - }, "permissions": { "message": "Mga Pahintulot" }, diff --git a/app/_locales/pt_BR/messages.json b/app/_locales/pt_BR/messages.json index 7300e2ee7..040e64ab4 100644 --- a/app/_locales/pt_BR/messages.json +++ b/app/_locales/pt_BR/messages.json @@ -1343,12 +1343,6 @@ "pending": { "message": "Pendente" }, - "permissionCheckedIconDescription": { - "message": "Você aprovou esta permissão" - }, - "permissionUncheckedIconDescription": { - "message": "Você não aprovou esta permissão" - }, "permissions": { "message": "Permissões" }, diff --git a/app/_locales/ru/messages.json b/app/_locales/ru/messages.json index ad5817696..09b1b28b0 100644 --- a/app/_locales/ru/messages.json +++ b/app/_locales/ru/messages.json @@ -1343,12 +1343,6 @@ "pending": { "message": "В ожидании" }, - "permissionCheckedIconDescription": { - "message": "Вы одобрили это разрешение" - }, - "permissionUncheckedIconDescription": { - "message": "Вы не одобрили это разрешение" - }, "permissions": { "message": "Разрешения" }, diff --git a/app/_locales/tl/messages.json b/app/_locales/tl/messages.json index 498379837..b35f809a3 100644 --- a/app/_locales/tl/messages.json +++ b/app/_locales/tl/messages.json @@ -1093,12 +1093,6 @@ "pending": { "message": "Nakabinbin" }, - "permissionCheckedIconDescription": { - "message": "Inaprubahan mo ang pahintulot na ito" - }, - "permissionUncheckedIconDescription": { - "message": "Hindi mo inaprubahan ang pahintulot na ito" - }, "permissions": { "message": "Mga Pahintulot" }, diff --git a/app/_locales/vi/messages.json b/app/_locales/vi/messages.json index bb6bda25d..90b821e2b 100644 --- a/app/_locales/vi/messages.json +++ b/app/_locales/vi/messages.json @@ -1343,12 +1343,6 @@ "pending": { "message": "Đang chờ xử lý" }, - "permissionCheckedIconDescription": { - "message": "Bạn đã phê duyệt quyền này" - }, - "permissionUncheckedIconDescription": { - "message": "Bạn chưa phê duyệt quyền này" - }, "permissions": { "message": "Quyền" }, diff --git a/app/_locales/zh_CN/messages.json b/app/_locales/zh_CN/messages.json index 539a4c29d..26888d054 100644 --- a/app/_locales/zh_CN/messages.json +++ b/app/_locales/zh_CN/messages.json @@ -1144,12 +1144,6 @@ "pending": { "message": "待处理" }, - "permissionCheckedIconDescription": { - "message": "您已同意该权限" - }, - "permissionUncheckedIconDescription": { - "message": "您还未同意该权限" - }, "permissions": { "message": "权限" }, diff --git a/app/build-types/flask/images/logo/metamask-logo-horizontal-dark.svg b/app/build-types/flask/images/logo/metamask-logo-horizontal-dark.svg index c38ba8c45..450ac8434 100644 --- a/app/build-types/flask/images/logo/metamask-logo-horizontal-dark.svg +++ b/app/build-types/flask/images/logo/metamask-logo-horizontal-dark.svg @@ -34,14 +34,14 @@ - - - - - - - - + + + + + + + + diff --git a/app/build-types/flask/images/logo/metamask-logo-horizontal.svg b/app/build-types/flask/images/logo/metamask-logo-horizontal.svg index 450ac8434..c38ba8c45 100644 --- a/app/build-types/flask/images/logo/metamask-logo-horizontal.svg +++ b/app/build-types/flask/images/logo/metamask-logo-horizontal.svg @@ -34,14 +34,14 @@ - - - - - - - - + + + + + + + + diff --git a/app/images/curve-high.svg b/app/images/curve-high.svg new file mode 100644 index 000000000..f5c918636 --- /dev/null +++ b/app/images/curve-high.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/images/curve-low.svg b/app/images/curve-low.svg new file mode 100644 index 000000000..fee21216e --- /dev/null +++ b/app/images/curve-low.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/images/curve-medium.svg b/app/images/curve-medium.svg new file mode 100644 index 000000000..c3cc1d2aa --- /dev/null +++ b/app/images/curve-medium.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/scripts/background.js b/app/scripts/background.js index ed546dc26..ca5148f73 100644 --- a/app/scripts/background.js +++ b/app/scripts/background.js @@ -56,7 +56,7 @@ const openMetamaskTabsIDs = {}; const requestAccountTabIds = {}; // state persistence -const inTest = process.env.IN_TEST === 'true'; +const inTest = process.env.IN_TEST; const localStore = inTest ? new ReadOnlyNetworkStore() : new LocalStore(); let versionedData; @@ -533,13 +533,8 @@ function setupController(initState, initLangCode) { ), ); - // 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), - ); + // Finally, reject all approvals managed by the ApprovalController + controller.approvalController.clear(); updateBadge(); } diff --git a/app/scripts/controllers/app-state.js b/app/scripts/controllers/app-state.js index 9bea8f193..e95a6cb93 100644 --- a/app/scripts/controllers/app-state.js +++ b/app/scripts/controllers/app-state.js @@ -31,6 +31,7 @@ export default class AppStateController extends EventEmitter { fullScreenGasPollTokens: [], recoveryPhraseReminderHasBeenShown: false, recoveryPhraseReminderLastShown: new Date().getTime(), + collectiblesDetectionNoticeDismissed: false, showTestnetMessageInDropdown: true, trezorModel: null, ...initState, @@ -252,4 +253,15 @@ export default class AppStateController extends EventEmitter { setTrezorModel(trezorModel) { this.store.updateState({ trezorModel }); } + + /** + * A setter for the `collectiblesDetectionNoticeDismissed` property + */ + setCollectiblesDetectionNoticeDismissed( + collectiblesDetectionNoticeDismissed, + ) { + this.store.updateState({ + collectiblesDetectionNoticeDismissed, + }); + } } diff --git a/app/scripts/controllers/network/createJsonRpcClient.js b/app/scripts/controllers/network/createJsonRpcClient.js index f4ca59157..d4e412b8f 100644 --- a/app/scripts/controllers/network/createJsonRpcClient.js +++ b/app/scripts/controllers/network/createJsonRpcClient.js @@ -10,7 +10,7 @@ import { import { PollingBlockTracker } from 'eth-block-tracker'; import { SECOND } from '../../../../shared/constants/time'; -const inTest = process.env.IN_TEST === 'true'; +const inTest = process.env.IN_TEST; const blockTrackerOpts = inTest ? { pollingInterval: SECOND } : {}; const getTestMiddlewares = () => { return inTest ? [createEstimateGasDelayTestMiddleware()] : []; diff --git a/app/scripts/controllers/network/network-controller.test.js b/app/scripts/controllers/network/network-controller.test.js index 6c234e852..260d30187 100644 --- a/app/scripts/controllers/network/network-controller.test.js +++ b/app/scripts/controllers/network/network-controller.test.js @@ -1,10 +1,9 @@ -import { strict as assert } from 'assert'; import sinon from 'sinon'; import { getNetworkDisplayName } from './util'; import NetworkController, { NETWORK_EVENTS } from './network'; -describe('NetworkController', function () { - describe('controller', function () { +describe('NetworkController', () => { + describe('controller', () => { let networkController; let getLatestBlockStub; let setProviderTypeAndWait; @@ -13,7 +12,7 @@ describe('NetworkController', function () { getAccounts: noop, }; - beforeEach(function () { + beforeEach(() => { networkController = new NetworkController(); getLatestBlockStub = sinon .stub(networkController, 'getLatestBlock') @@ -28,118 +27,108 @@ describe('NetworkController', function () { }); }); - afterEach(function () { + afterEach(() => { getLatestBlockStub.reset(); }); - describe('#provider', function () { - it('provider should be updatable without reassignment', function () { + describe('#provider', () => { + it('provider should be updatable without reassignment', () => { networkController.initializeProvider(networkControllerProviderConfig); const providerProxy = networkController.getProviderAndBlockTracker() .provider; - assert.equal(providerProxy.test, undefined); + expect(providerProxy.test).toBeUndefined(); providerProxy.setTarget({ test: true }); - assert.equal(providerProxy.test, true); + expect(providerProxy.test).toStrictEqual(true); }); }); - describe('#getNetworkState', function () { - it('should return "loading" when new', function () { + describe('#getNetworkState', () => { + it('should return "loading" when new', () => { const networkState = networkController.getNetworkState(); - assert.equal(networkState, 'loading', 'network is loading'); + expect(networkState).toStrictEqual('loading'); }); }); - describe('#setNetworkState', function () { - it('should update the network', function () { + describe('#setNetworkState', () => { + it('should update the network', () => { networkController.setNetworkState('1'); const networkState = networkController.getNetworkState(); - assert.equal(networkState, '1', 'network is 1'); + expect(networkState).toStrictEqual('1'); }); }); - describe('#setProviderType', function () { - it('should update provider.type', function () { + describe('#setProviderType', () => { + it('should update provider.type', () => { networkController.initializeProvider(networkControllerProviderConfig); networkController.setProviderType('mainnet'); const { type } = networkController.getProviderConfig(); - assert.equal(type, 'mainnet', 'provider type is updated'); + expect(type).toStrictEqual('mainnet'); }); - it('should set the network to loading', function () { + it('should set the network to loading', () => { networkController.initializeProvider(networkControllerProviderConfig); const spy = sinon.spy(networkController, 'setNetworkState'); networkController.setProviderType('mainnet'); - assert.equal( - spy.callCount, - 1, - 'should have called setNetworkState 2 times', - ); - assert.ok( - spy.calledOnceWithExactly('loading'), - 'should have called with "loading" first', - ); + expect(spy.callCount).toStrictEqual(1); + expect(spy.calledOnceWithExactly('loading')).toStrictEqual(true); }); }); - describe('#getEIP1559Compatibility', function () { - it('should return false when baseFeePerGas is not in the block header', async function () { + describe('#getEIP1559Compatibility', () => { + it('should return false when baseFeePerGas is not in the block header', async () => { networkController.initializeProvider(networkControllerProviderConfig); const supportsEIP1559 = await networkController.getEIP1559Compatibility(); - assert.equal(supportsEIP1559, false); + expect(supportsEIP1559).toStrictEqual(false); }); - it('should return true when baseFeePerGas is in block header', async function () { + it('should return true when baseFeePerGas is in block header', async () => { networkController.initializeProvider(networkControllerProviderConfig); getLatestBlockStub.callsFake(() => Promise.resolve({ baseFeePerGas: '0xa ' }), ); const supportsEIP1559 = await networkController.getEIP1559Compatibility(); - assert.equal(supportsEIP1559, true); + expect(supportsEIP1559).toStrictEqual(true); }); - it('should store EIP1559 support in state to reduce calls to getLatestBlock', async function () { + it('should store EIP1559 support in state to reduce calls to getLatestBlock', async () => { networkController.initializeProvider(networkControllerProviderConfig); getLatestBlockStub.callsFake(() => Promise.resolve({ baseFeePerGas: '0xa ' }), ); await networkController.getEIP1559Compatibility(); const supportsEIP1559 = await networkController.getEIP1559Compatibility(); - assert.equal(getLatestBlockStub.calledOnce, true); - assert.equal(supportsEIP1559, true); + expect(getLatestBlockStub.calledOnce).toStrictEqual(true); + expect(supportsEIP1559).toStrictEqual(true); }); - it('should clear stored EIP1559 support when changing networks', async function () { + it('should clear stored EIP1559 support when changing networks', async () => { networkController.initializeProvider(networkControllerProviderConfig); networkController.consoleThis = true; getLatestBlockStub.callsFake(() => Promise.resolve({ baseFeePerGas: '0xa ' }), ); await networkController.getEIP1559Compatibility(); - assert.equal( + expect( networkController.networkDetails.getState().EIPS[1559], - true, - ); + ).toStrictEqual(true); getLatestBlockStub.callsFake(() => Promise.resolve({})); await setProviderTypeAndWait('mainnet'); - assert.equal( + expect( networkController.networkDetails.getState().EIPS[1559], - undefined, - ); + ).toBeUndefined(); await networkController.getEIP1559Compatibility(); - assert.equal( + expect( networkController.networkDetails.getState().EIPS[1559], - false, - ); - assert.equal(getLatestBlockStub.calledTwice, true); + ).toStrictEqual(false); + expect(getLatestBlockStub.calledTwice).toStrictEqual(true); }); }); }); - describe('utils', function () { - it('getNetworkDisplayName should return the correct network name', function () { + describe('utils', () => { + it('getNetworkDisplayName should return the correct network name', () => { const tests = [ { input: '3', @@ -188,7 +177,7 @@ describe('NetworkController', function () { ]; tests.forEach(({ input, expected }) => - assert.equal(getNetworkDisplayName(input), expected), + expect(getNetworkDisplayName(input)).toStrictEqual(expected), ); }); }); diff --git a/app/scripts/controllers/network/network.js b/app/scripts/controllers/network/network.js index 9423c6a80..10fd9f824 100644 --- a/app/scripts/controllers/network/network.js +++ b/app/scripts/controllers/network/network.js @@ -33,7 +33,7 @@ const env = process.env.METAMASK_ENV; const fetchWithTimeout = getFetchWithTimeout(SECOND * 30); let defaultProviderConfigOpts; -if (process.env.IN_TEST === 'true') { +if (process.env.IN_TEST) { defaultProviderConfigOpts = { type: NETWORK_TYPE_RPC, rpcUrl: 'http://localhost:8545', diff --git a/app/scripts/controllers/network/pending-middleware.test.js b/app/scripts/controllers/network/pending-middleware.test.js index 0c8d9bc04..be0289648 100644 --- a/app/scripts/controllers/network/pending-middleware.test.js +++ b/app/scripts/controllers/network/pending-middleware.test.js @@ -1,4 +1,3 @@ -import { strict as assert } from 'assert'; import { GAS_LIMITS } from '../../../../shared/constants/gas'; import { TRANSACTION_ENVELOPE_TYPES } from '../../../../shared/constants/transaction'; import { txMetaStub } from '../../../../test/stub/tx-meta-stub'; @@ -7,25 +6,35 @@ import { createPendingTxMiddleware, } from './middleware/pending'; -describe('PendingNonceMiddleware', function () { - describe('#createPendingNonceMiddleware', function () { +describe('PendingNonceMiddleware', () => { + describe('#createPendingNonceMiddleware', () => { const getPendingNonce = async () => '0x2'; const address = '0xF231D46dD78806E1DD93442cf33C7671f8538748'; const pendingNonceMiddleware = createPendingNonceMiddleware({ getPendingNonce, }); - it('should call next if not a eth_getTransactionCount request', function (done) { + it('should call next if not a eth_getTransactionCount request', () => { const req = { method: 'eth_getBlockByNumber' }; const res = {}; - pendingNonceMiddleware(req, res, () => done()); + + const next = jest.fn(); + + pendingNonceMiddleware(req, res, next); + expect(next).toHaveBeenCalledTimes(1); }); - it('should call next if not a "pending" block request', function (done) { + + it('should call next if not a "pending" block request', () => { const req = { method: 'eth_getTransactionCount', params: [address] }; const res = {}; - pendingNonceMiddleware(req, res, () => done()); + + const next = jest.fn(); + + pendingNonceMiddleware(req, res, next); + expect(next).toHaveBeenCalledTimes(1); }); - it('should fill the result with a the "pending" nonce', function (done) { + + it('should fill the result with a the "pending" nonce', () => { const req = { method: 'eth_getTransactionCount', params: [address, 'pending'], @@ -35,17 +44,16 @@ describe('PendingNonceMiddleware', function () { req, res, () => { - done(new Error('should not have called next')); + return new Error('should not have called next'); }, () => { - assert(res.result === '0x2'); - done(); + expect(res.result).toStrictEqual('0x2'); }, ); }); }); - describe('#createPendingTxMiddleware', function () { + describe('#createPendingTxMiddleware', () => { let returnUndefined = true; const getPendingTransactionByHash = () => returnUndefined ? undefined : txMetaStub; @@ -72,19 +80,24 @@ describe('PendingNonceMiddleware', function () { r: '0x5f973e540f2d3c2f06d3725a626b75247593cb36477187ae07ecfe0a4db3cf57', s: '0x0259b52ee8c58baaa385fb05c3f96116e58de89bcc165cb3bfdfc708672fed8a', }; - it('should call next if not a eth_getTransactionByHash request', function (done) { + + it('should call next if not a eth_getTransactionByHash request', () => { const req = { method: 'eth_getBlockByNumber' }; const res = {}; - pendingTxMiddleware(req, res, () => done()); + const next = jest.fn(); + pendingTxMiddleware(req, res, next); + expect(next).toHaveBeenCalledTimes(1); }); - it('should call next if no pending txMeta is in history', function (done) { + it('should call next if no pending txMeta is in history', () => { const req = { method: 'eth_getTransactionByHash', params: [address] }; const res = {}; - pendingTxMiddleware(req, res, () => done()); + const next = jest.fn(); + pendingTxMiddleware(req, res, next); + expect(next).toHaveBeenCalledTimes(1); }); - it('should fill the result with a the "pending" tx the result should match the rpc spec', function (done) { + it('should fill the result with a the "pending" tx the result should match the rpc spec', () => { returnUndefined = false; const req = { method: 'eth_getTransactionByHash', @@ -95,15 +108,10 @@ describe('PendingNonceMiddleware', function () { req, res, () => { - done(new Error('should not have called next')); + return new Error('should not have called next'); }, () => { - assert.deepStrictEqual( - res.result, - spec, - new Error('result does not match the spec object'), - ); - done(); + expect(res.result).toStrictEqual(spec); }, ); }); diff --git a/app/scripts/controllers/network/util.test.js b/app/scripts/controllers/network/util.test.js index a1b7097b8..4e650f3db 100644 --- a/app/scripts/controllers/network/util.test.js +++ b/app/scripts/controllers/network/util.test.js @@ -1,4 +1,3 @@ -import { strict as assert } from 'assert'; import { TRANSACTION_STATUSES, TRANSACTION_TYPES, @@ -7,9 +6,9 @@ import { import { formatTxMetaForRpcResult } from './util'; -describe('network utils', function () { - describe('formatTxMetaForRpcResult', function () { - it('should correctly format the tx meta object (EIP-1559)', function () { +describe('network utils', () => { + describe('formatTxMetaForRpcResult', () => { + it('should correctly format the tx meta object (EIP-1559)', () => { const txMeta = { id: 1, status: TRANSACTION_STATUSES.UNAPPROVED, @@ -54,10 +53,10 @@ describe('network utils', function () { value: '0x0', }; const result = formatTxMetaForRpcResult(txMeta); - assert.deepEqual(result, expectedResult); + expect(result).toStrictEqual(expectedResult); }); - it('should correctly format the tx meta object (non EIP-1559)', function () { + it('should correctly format the tx meta object (non EIP-1559)', () => { const txMeta = { id: 1, status: TRANSACTION_STATUSES.UNAPPROVED, @@ -99,7 +98,7 @@ describe('network utils', function () { value: '0x0', }; const result = formatTxMetaForRpcResult(txMeta); - assert.deepEqual(result, expectedResult); + expect(result).toStrictEqual(expectedResult); }); }); }); diff --git a/app/scripts/controllers/permissions/background-api.js b/app/scripts/controllers/permissions/background-api.js new file mode 100644 index 000000000..da8a9c4c8 --- /dev/null +++ b/app/scripts/controllers/permissions/background-api.js @@ -0,0 +1,71 @@ +import { + CaveatTypes, + RestrictedMethods, +} from '../../../../shared/constants/permissions'; + +export function getPermissionBackgroundApiMethods(permissionController) { + return { + addPermittedAccount: (origin, account) => { + const existing = permissionController.getCaveat( + origin, + RestrictedMethods.eth_accounts, + CaveatTypes.restrictReturnedAccounts, + ); + + if (existing.value.includes(account)) { + throw new Error( + `eth_accounts permission for origin "${origin}" already permits account "${account}".`, + ); + } + + permissionController.updateCaveat( + origin, + RestrictedMethods.eth_accounts, + CaveatTypes.restrictReturnedAccounts, + [...existing.value, account], + ); + }, + + removePermittedAccount: (origin, account) => { + const existing = permissionController.getCaveat( + origin, + RestrictedMethods.eth_accounts, + CaveatTypes.restrictReturnedAccounts, + ); + + if (!existing.value.includes(account)) { + throw new Error( + `eth_accounts permission for origin "${origin}" already does not permit account "${account}".`, + ); + } + + const remainingAccounts = existing.value.filter( + (existingAccount) => existingAccount !== account, + ); + + if (remainingAccounts.length === 0) { + permissionController.revokePermission( + origin, + RestrictedMethods.eth_accounts, + ); + } else { + permissionController.updateCaveat( + origin, + RestrictedMethods.eth_accounts, + CaveatTypes.restrictReturnedAccounts, + remainingAccounts, + ); + } + }, + + requestAccountsPermissionWithId: async (origin) => { + const [, { id }] = await permissionController.requestPermissions( + { origin }, + { + eth_accounts: {}, + }, + ); + return id; + }, + }; +} diff --git a/app/scripts/controllers/permissions/background-api.test.js b/app/scripts/controllers/permissions/background-api.test.js new file mode 100644 index 000000000..39a96265a --- /dev/null +++ b/app/scripts/controllers/permissions/background-api.test.js @@ -0,0 +1,181 @@ +import { + CaveatTypes, + RestrictedMethods, +} from '../../../../shared/constants/permissions'; +import { getPermissionBackgroundApiMethods } from './background-api'; + +describe('permission background API methods', () => { + describe('addPermittedAccount', () => { + it('adds a permitted account', () => { + const permissionController = { + getCaveat: jest.fn().mockImplementationOnce(() => { + return { type: CaveatTypes.restrictReturnedAccounts, value: ['0x1'] }; + }), + updateCaveat: jest.fn(), + }; + + getPermissionBackgroundApiMethods( + permissionController, + ).addPermittedAccount('foo.com', '0x2'); + + expect(permissionController.getCaveat).toHaveBeenCalledTimes(1); + expect(permissionController.getCaveat).toHaveBeenCalledWith( + 'foo.com', + RestrictedMethods.eth_accounts, + CaveatTypes.restrictReturnedAccounts, + ); + + expect(permissionController.updateCaveat).toHaveBeenCalledTimes(1); + expect(permissionController.updateCaveat).toHaveBeenCalledWith( + 'foo.com', + RestrictedMethods.eth_accounts, + CaveatTypes.restrictReturnedAccounts, + ['0x1', '0x2'], + ); + }); + + it('throws if the specified account is already permitted', () => { + const permissionController = { + getCaveat: jest.fn().mockImplementationOnce(() => { + return { type: CaveatTypes.restrictReturnedAccounts, value: ['0x1'] }; + }), + updateCaveat: jest.fn(), + }; + + expect(() => + getPermissionBackgroundApiMethods( + permissionController, + ).addPermittedAccount('foo.com', '0x1'), + ).toThrow( + `eth_accounts permission for origin "foo.com" already permits account "0x1".`, + ); + + expect(permissionController.getCaveat).toHaveBeenCalledTimes(1); + expect(permissionController.getCaveat).toHaveBeenCalledWith( + 'foo.com', + RestrictedMethods.eth_accounts, + CaveatTypes.restrictReturnedAccounts, + ); + + expect(permissionController.updateCaveat).not.toHaveBeenCalled(); + }); + }); + + describe('removePermittedAccount', () => { + it('removes a permitted account', () => { + const permissionController = { + getCaveat: jest.fn().mockImplementationOnce(() => { + return { + type: CaveatTypes.restrictReturnedAccounts, + value: ['0x1', '0x2'], + }; + }), + revokePermission: jest.fn(), + updateCaveat: jest.fn(), + }; + + getPermissionBackgroundApiMethods( + permissionController, + ).removePermittedAccount('foo.com', '0x2'); + + expect(permissionController.getCaveat).toHaveBeenCalledTimes(1); + expect(permissionController.getCaveat).toHaveBeenCalledWith( + 'foo.com', + RestrictedMethods.eth_accounts, + CaveatTypes.restrictReturnedAccounts, + ); + + expect(permissionController.revokePermission).not.toHaveBeenCalled(); + + expect(permissionController.updateCaveat).toHaveBeenCalledTimes(1); + expect(permissionController.updateCaveat).toHaveBeenCalledWith( + 'foo.com', + RestrictedMethods.eth_accounts, + CaveatTypes.restrictReturnedAccounts, + ['0x1'], + ); + }); + + it('revokes the accounts permission if the removed account is the only permitted account', () => { + const permissionController = { + getCaveat: jest.fn().mockImplementationOnce(() => { + return { + type: CaveatTypes.restrictReturnedAccounts, + value: ['0x1'], + }; + }), + revokePermission: jest.fn(), + updateCaveat: jest.fn(), + }; + + getPermissionBackgroundApiMethods( + permissionController, + ).removePermittedAccount('foo.com', '0x1'); + + expect(permissionController.getCaveat).toHaveBeenCalledTimes(1); + expect(permissionController.getCaveat).toHaveBeenCalledWith( + 'foo.com', + RestrictedMethods.eth_accounts, + CaveatTypes.restrictReturnedAccounts, + ); + + expect(permissionController.revokePermission).toHaveBeenCalledTimes(1); + expect(permissionController.revokePermission).toHaveBeenCalledWith( + 'foo.com', + RestrictedMethods.eth_accounts, + ); + + expect(permissionController.updateCaveat).not.toHaveBeenCalled(); + }); + + it('throws if the specified account is not permitted', () => { + const permissionController = { + getCaveat: jest.fn().mockImplementationOnce(() => { + return { type: CaveatTypes.restrictReturnedAccounts, value: ['0x1'] }; + }), + revokePermission: jest.fn(), + updateCaveat: jest.fn(), + }; + + expect(() => + getPermissionBackgroundApiMethods( + permissionController, + ).removePermittedAccount('foo.com', '0x2'), + ).toThrow( + `eth_accounts permission for origin "foo.com" already does not permit account "0x2".`, + ); + + expect(permissionController.getCaveat).toHaveBeenCalledTimes(1); + expect(permissionController.getCaveat).toHaveBeenCalledWith( + 'foo.com', + RestrictedMethods.eth_accounts, + CaveatTypes.restrictReturnedAccounts, + ); + + expect(permissionController.revokePermission).not.toHaveBeenCalled(); + expect(permissionController.updateCaveat).not.toHaveBeenCalled(); + }); + }); + + describe('requestAccountsPermissionWithId', () => { + it('request an accounts permission and returns the request id', async () => { + const permissionController = { + requestPermissions: jest.fn().mockImplementationOnce(async () => { + return [null, { id: 'arbitraryId' }]; + }), + }; + + const id = await getPermissionBackgroundApiMethods( + permissionController, + ).requestAccountsPermissionWithId('foo.com'); + + expect(permissionController.requestPermissions).toHaveBeenCalledTimes(1); + expect(permissionController.requestPermissions).toHaveBeenCalledWith( + { origin: 'foo.com' }, + { eth_accounts: {} }, + ); + + expect(id).toStrictEqual('arbitraryId'); + }); + }); +}); diff --git a/app/scripts/controllers/permissions/caveat-mutators.js b/app/scripts/controllers/permissions/caveat-mutators.js new file mode 100644 index 000000000..fe61df6df --- /dev/null +++ b/app/scripts/controllers/permissions/caveat-mutators.js @@ -0,0 +1,39 @@ +import { CaveatMutatorOperation } from '@metamask/snap-controllers'; +import { CaveatTypes } from '../../../../shared/constants/permissions'; + +/** + * Factories that construct caveat mutator functions that are passed to + * PermissionController.updatePermissionsByCaveat. + */ +export const CaveatMutatorFactories = { + [CaveatTypes.restrictReturnedAccounts]: { + removeAccount, + }, +}; + +/** + * Removes the target account from the value arrays of all + * `restrictReturnedAccounts` caveats. No-ops if the target account is not in + * the array, and revokes the parent permission if it's the only account in + * the array. + * + * @param {string} targetAccount - The address of the account to remove from + * all accounts permissions. + * @param {string[]} existingAccounts - The account address array from the + * account permissions. + */ +function removeAccount(targetAccount, existingAccounts) { + const newAccounts = existingAccounts.filter( + (address) => address !== targetAccount, + ); + + if (newAccounts.length === existingAccounts.length) { + return { operation: CaveatMutatorOperation.noop }; + } else if (newAccounts.length > 0) { + return { + operation: CaveatMutatorOperation.updateValue, + value: newAccounts, + }; + } + return { operation: CaveatMutatorOperation.revokePermission }; +} diff --git a/app/scripts/controllers/permissions/caveat-mutators.test.js b/app/scripts/controllers/permissions/caveat-mutators.test.js new file mode 100644 index 000000000..93c3d0dad --- /dev/null +++ b/app/scripts/controllers/permissions/caveat-mutators.test.js @@ -0,0 +1,32 @@ +import { CaveatMutatorOperation } from '@metamask/snap-controllers'; +import { CaveatTypes } from '../../../../shared/constants/permissions'; +import { CaveatMutatorFactories } from './caveat-mutators'; + +describe('caveat mutators', () => { + describe('restrictReturnedAccounts', () => { + const { removeAccount } = CaveatMutatorFactories[ + CaveatTypes.restrictReturnedAccounts + ]; + + describe('removeAccount', () => { + it('returns the no-op operation if the target account is not permitted', () => { + expect(removeAccount('0x2', ['0x1'])).toStrictEqual({ + operation: CaveatMutatorOperation.noop, + }); + }); + + it('returns the update operation and a new value if the target account is permitted', () => { + expect(removeAccount('0x2', ['0x1', '0x2'])).toStrictEqual({ + operation: CaveatMutatorOperation.updateValue, + value: ['0x1'], + }); + }); + + it('returns the revoke permission operation the target account is the only permitted account', () => { + expect(removeAccount('0x1', ['0x1'])).toStrictEqual({ + operation: CaveatMutatorOperation.revokePermission, + }); + }); + }); + }); +}); diff --git a/app/scripts/controllers/permissions/enums.js b/app/scripts/controllers/permissions/enums.js index 4901140a1..577540d49 100644 --- a/app/scripts/controllers/permissions/enums.js +++ b/app/scripts/controllers/permissions/enums.js @@ -1,20 +1,5 @@ -export const APPROVAL_TYPE = 'wallet_requestPermissions'; - export const WALLET_PREFIX = 'wallet_'; -export const HISTORY_STORE_KEY = 'permissionsHistory'; - -export const LOG_STORE_KEY = 'permissionsLog'; - -export const METADATA_STORE_KEY = 'domainMetadata'; - -export const METADATA_CACHE_MAX_SIZE = 100; - -export const CAVEAT_TYPES = { - limitResponseLength: 'limitResponseLength', - filterResponse: 'filterResponse', -}; - export const NOTIFICATION_NAMES = { accountsChanged: 'metamask_accountsChanged', unlockStateChanged: 'metamask_unlockStateChanged', @@ -31,64 +16,7 @@ export const LOG_METHOD_TYPES = { internal: 'internal', }; +/** + * The permission activity log size limit. + */ export const LOG_LIMIT = 100; - -export const SAFE_METHODS = [ - 'eth_blockNumber', - 'eth_call', - 'eth_chainId', - 'eth_coinbase', - 'eth_decrypt', - 'eth_estimateGas', - 'eth_feeHistory', - 'eth_gasPrice', - 'eth_getBalance', - 'eth_getBlockByHash', - 'eth_getBlockByNumber', - 'eth_getBlockTransactionCountByHash', - 'eth_getBlockTransactionCountByNumber', - 'eth_getCode', - 'eth_getEncryptionPublicKey', - 'eth_getFilterChanges', - 'eth_getFilterLogs', - 'eth_getLogs', - 'eth_getProof', - 'eth_getStorageAt', - 'eth_getTransactionByBlockHashAndIndex', - 'eth_getTransactionByBlockNumberAndIndex', - 'eth_getTransactionByHash', - 'eth_getTransactionCount', - 'eth_getTransactionReceipt', - 'eth_getUncleByBlockHashAndIndex', - 'eth_getUncleByBlockNumberAndIndex', - 'eth_getUncleCountByBlockHash', - 'eth_getUncleCountByBlockNumber', - 'eth_getWork', - 'eth_hashrate', - 'eth_mining', - 'eth_newBlockFilter', - 'eth_newFilter', - 'eth_newPendingTransactionFilter', - 'eth_protocolVersion', - 'eth_sendRawTransaction', - 'eth_sendTransaction', - 'eth_sign', - 'eth_signTypedData', - 'eth_signTypedData_v1', - 'eth_signTypedData_v3', - 'eth_signTypedData_v4', - 'eth_submitHashrate', - 'eth_submitWork', - 'eth_syncing', - 'eth_uninstallFilter', - 'metamask_getProviderState', - 'metamask_watchAsset', - 'net_listening', - 'net_peerCount', - 'net_version', - 'personal_ecRecover', - 'personal_sign', - 'wallet_watchAsset', - 'web3_clientVersion', - 'web3_sha3', -]; diff --git a/app/scripts/controllers/permissions/index.js b/app/scripts/controllers/permissions/index.js index 061782c23..419d03993 100644 --- a/app/scripts/controllers/permissions/index.js +++ b/app/scripts/controllers/permissions/index.js @@ -1,718 +1,6 @@ -import nanoid from 'nanoid'; -import { JsonRpcEngine } from 'json-rpc-engine'; -import { ObservableStore } from '@metamask/obs-store'; -import log from 'loglevel'; -import { CapabilitiesController as RpcCap } from 'rpc-cap'; -import { ethErrors } from 'eth-rpc-errors'; -import { cloneDeep } from 'lodash'; - -import { CAVEAT_NAMES } from '../../../../shared/constants/permissions'; -import { - APPROVAL_TYPE, - SAFE_METHODS, // methods that do not require any permissions to use - WALLET_PREFIX, - METADATA_STORE_KEY, - METADATA_CACHE_MAX_SIZE, - LOG_STORE_KEY, - HISTORY_STORE_KEY, - NOTIFICATION_NAMES, - CAVEAT_TYPES, -} from './enums'; - -import createPermissionsMethodMiddleware from './permissionsMethodMiddleware'; -import PermissionsLogController from './permissionsLog'; - -// instanbul ignore next -const noop = () => undefined; - -export class PermissionsController { - constructor( - { - approvals, - getKeyringAccounts, - getRestrictedMethods, - getUnlockPromise, - isUnlocked, - notifyDomain, - notifyAllDomains, - preferences, - } = {}, - restoredPermissions = {}, - restoredState = {}, - ) { - // additional top-level store key set in _initializeMetadataStore - this.store = new ObservableStore({ - [LOG_STORE_KEY]: restoredState[LOG_STORE_KEY] || [], - [HISTORY_STORE_KEY]: restoredState[HISTORY_STORE_KEY] || {}, - }); - - this.getKeyringAccounts = getKeyringAccounts; - this._getUnlockPromise = getUnlockPromise; - this._notifyDomain = notifyDomain; - this._notifyAllDomains = notifyAllDomains; - this._isUnlocked = isUnlocked; - - this._restrictedMethods = getRestrictedMethods({ - getKeyringAccounts: this.getKeyringAccounts.bind(this), - getIdentities: this._getIdentities.bind(this), - }); - this.permissionsLog = new PermissionsLogController({ - restrictedMethods: Object.keys(this._restrictedMethods), - store: this.store, - }); - - /** - * @type {import('@metamask/controllers').ApprovalController} - * @public - */ - this.approvals = approvals; - this._initializePermissions(restoredPermissions); - this._lastSelectedAddress = preferences.getState().selectedAddress; - this.preferences = preferences; - - this._initializeMetadataStore(restoredState); - - preferences.subscribe(async ({ selectedAddress }) => { - if (selectedAddress && selectedAddress !== this._lastSelectedAddress) { - this._lastSelectedAddress = selectedAddress; - await this._handleAccountSelected(selectedAddress); - } - }); - } - - createMiddleware({ origin, extensionId }) { - if (typeof origin !== 'string' || !origin.length) { - throw new Error('Must provide non-empty string origin.'); - } - - const metadataState = this.store.getState()[METADATA_STORE_KEY]; - - if (extensionId && metadataState[origin]?.extensionId !== extensionId) { - this.addDomainMetadata(origin, { extensionId }); - } - - const engine = new JsonRpcEngine(); - - engine.push(this.permissionsLog.createMiddleware()); - - engine.push( - createPermissionsMethodMiddleware({ - addDomainMetadata: this.addDomainMetadata.bind(this), - getAccounts: this.getAccounts.bind(this, origin), - getUnlockPromise: () => this._getUnlockPromise(true), - hasPermission: this.hasPermission.bind(this, origin), - notifyAccountsChanged: this.notifyAccountsChanged.bind(this, origin), - requestAccountsPermission: this._requestPermissions.bind( - this, - { origin }, - { eth_accounts: {} }, - ), - }), - ); - - engine.push( - this.permissions.providerMiddlewareFunction.bind(this.permissions, { - origin, - }), - ); - - return engine.asMiddleware(); - } - - /** - * Request {@code eth_accounts} permissions - * @param {string} origin - The requesting origin - * @returns {Promise} The permissions request ID - */ - async requestAccountsPermissionWithId(origin) { - const id = nanoid(); - this._requestPermissions({ origin }, { eth_accounts: {} }, id).then( - async () => { - const permittedAccounts = await this.getAccounts(origin); - this.notifyAccountsChanged(origin, permittedAccounts); - }, - ); - return id; - } - - /** - * Returns the accounts that should be exposed for the given origin domain, - * if any. This method exists for when a trusted context needs to know - * which accounts are exposed to a given domain. - * - * @param {string} origin - The origin string. - */ - getAccounts(origin) { - return new Promise((resolve, _) => { - const req = { method: 'eth_accounts' }; - const res = {}; - this.permissions.providerMiddlewareFunction( - { origin }, - req, - res, - noop, - _end, - ); - - function _end() { - if (res.error || !Array.isArray(res.result)) { - resolve([]); - } else { - resolve(res.result); - } - } - }); - } - - /** - * Returns whether the given origin has the given permission. - * - * @param {string} origin - The origin to check. - * @param {string} permission - The permission to check for. - * @returns {boolean} Whether the origin has the permission. - */ - hasPermission(origin, permission) { - return Boolean(this.permissions.getPermission(origin, permission)); - } - - /** - * Gets the identities from the preferences controller store - * - * @returns {Object} identities - */ - _getIdentities() { - return this.preferences.getState().identities; - } - - /** - * Submits a permissions request to rpc-cap. Internal, background use only. - * - * @param {IOriginMetadata} domain - The external domain metadata. - * @param {IRequestedPermissions} permissions - The requested permissions. - * @param {string} [id] - The desired id of the permissions request, if any. - * @returns {Promise} A Promise that resolves with the - * approved permissions, or rejects with an error. - */ - _requestPermissions(domain, permissions, id) { - return new Promise((resolve, reject) => { - // rpc-cap assigns an id to the request if there is none, as expected by - // requestUserApproval below - const req = { - id, - method: 'wallet_requestPermissions', - params: [permissions], - }; - const res = {}; - - this.permissions.providerMiddlewareFunction(domain, req, res, noop, _end); - - function _end(_err) { - const err = _err || res.error; - if (err) { - reject(err); - } else { - resolve(res.result); - } - } - }); - } - - /** - * User approval callback. Resolves the Promise for the permissions request - * waited upon by rpc-cap, see requestUserApproval in _initializePermissions. - * The request will be rejected if finalizePermissionsRequest fails. - * Idempotent for a given request id. - * - * @param {Object} approved - The request object approved by the user - * @param {Array} accounts - The accounts to expose, if any - */ - async approvePermissionsRequest(approved, accounts) { - const { id } = approved.metadata; - - if (!this.approvals.has({ id })) { - log.debug(`Permissions request with id '${id}' not found.`); - return; - } - - try { - if (Object.keys(approved.permissions).length === 0) { - this.approvals.reject( - id, - ethErrors.rpc.invalidRequest({ - message: 'Must request at least one permission.', - }), - ); - } else { - // attempt to finalize the request and resolve it, - // settings caveats as necessary - approved.permissions = await this.finalizePermissionsRequest( - approved.permissions, - accounts, - ); - this.approvals.accept(id, approved.permissions); - } - } catch (err) { - // if finalization fails, reject the request - this.approvals.reject( - id, - ethErrors.rpc.invalidRequest({ - message: err.message, - data: err, - }), - ); - } - } - - /** - * User rejection callback. Rejects the Promise for the permissions request - * waited upon by rpc-cap, see requestUserApproval in _initializePermissions. - * Idempotent for a given id. - * - * @param {string} id - The id of the request rejected by the user - */ - async rejectPermissionsRequest(id) { - if (!this.approvals.has({ id })) { - log.debug(`Permissions request with id '${id}' not found.`); - return; - } - - this.approvals.reject(id, ethErrors.provider.userRejectedRequest()); - } - - /** - * Expose an account to the given origin. Changes the eth_accounts - * permissions and emits accountsChanged. - * - * Throws error if the origin or account is invalid, or if the update fails. - * - * @param {string} origin - The origin to expose the account to. - * @param {string} account - The new account to expose. - */ - async addPermittedAccount(origin, account) { - const domains = this.permissions.getDomains(); - if (!domains[origin]) { - throw new Error('Unrecognized domain'); - } - - this.validatePermittedAccounts([account]); - - const oldPermittedAccounts = this._getPermittedAccounts(origin); - if (oldPermittedAccounts.length === 0) { - throw new Error(`Origin does not have 'eth_accounts' permission`); - } else if (oldPermittedAccounts.includes(account)) { - throw new Error('Account is already permitted for origin'); - } - - this.permissions.updateCaveatFor( - origin, - 'eth_accounts', - CAVEAT_NAMES.exposedAccounts, - [...oldPermittedAccounts, account], - ); - - const permittedAccounts = await this.getAccounts(origin); - - this.notifyAccountsChanged(origin, permittedAccounts); - } - - /** - * Removes an exposed account from the given origin. Changes the eth_accounts - * permission and emits accountsChanged. - * If origin only has a single permitted account, removes the eth_accounts - * permission from the origin. - * - * Throws error if the origin or account is invalid, or if the update fails. - * - * @param {string} origin - The origin to remove the account from. - * @param {string} account - The account to remove. - */ - async removePermittedAccount(origin, account) { - const domains = this.permissions.getDomains(); - if (!domains[origin]) { - throw new Error('Unrecognized domain'); - } - - this.validatePermittedAccounts([account]); - - const oldPermittedAccounts = this._getPermittedAccounts(origin); - if (oldPermittedAccounts.length === 0) { - throw new Error(`Origin does not have 'eth_accounts' permission`); - } else if (!oldPermittedAccounts.includes(account)) { - throw new Error('Account is not permitted for origin'); - } - - let newPermittedAccounts = oldPermittedAccounts.filter( - (acc) => acc !== account, - ); - - if (newPermittedAccounts.length === 0) { - this.removePermissionsFor({ [origin]: ['eth_accounts'] }); - } else { - this.permissions.updateCaveatFor( - origin, - 'eth_accounts', - CAVEAT_NAMES.exposedAccounts, - newPermittedAccounts, - ); - - newPermittedAccounts = await this.getAccounts(origin); - } - - this.notifyAccountsChanged(origin, newPermittedAccounts); - } - - /** - * Remove all permissions associated with a particular account. Any eth_accounts - * permissions left with no permitted accounts will be removed as well. - * - * Throws error if the account is invalid, or if the update fails. - * - * @param {string} account - The account to remove. - */ - async removeAllAccountPermissions(account) { - this.validatePermittedAccounts([account]); - - const domains = this.permissions.getDomains(); - const connectedOrigins = Object.keys(domains).filter((origin) => - this._getPermittedAccounts(origin).includes(account), - ); - - await Promise.all( - connectedOrigins.map((origin) => - this.removePermittedAccount(origin, account), - ), - ); - } - - /** - * Finalizes a permissions request. Throws if request validation fails. - * Clones the passed-in parameters to prevent inadvertent modification. - * Sets (adds or replaces) caveats for the following permissions: - * - eth_accounts: the permitted accounts caveat - * - * @param {Object} requestedPermissions - The requested permissions. - * @param {string[]} requestedAccounts - The accounts to expose, if any. - * @returns {Object} The finalized permissions request object. - */ - async finalizePermissionsRequest(requestedPermissions, requestedAccounts) { - const finalizedPermissions = cloneDeep(requestedPermissions); - const finalizedAccounts = cloneDeep(requestedAccounts); - - const { eth_accounts: ethAccounts } = finalizedPermissions; - - if (ethAccounts) { - this.validatePermittedAccounts(finalizedAccounts); - - if (!ethAccounts.caveats) { - ethAccounts.caveats = []; - } - - // caveat names are unique, and we will only construct this caveat here - ethAccounts.caveats = ethAccounts.caveats.filter( - (c) => - c.name !== CAVEAT_NAMES.exposedAccounts && - c.name !== CAVEAT_NAMES.primaryAccountOnly, - ); - - ethAccounts.caveats.push({ - type: CAVEAT_TYPES.limitResponseLength, - value: 1, - name: CAVEAT_NAMES.primaryAccountOnly, - }); - - ethAccounts.caveats.push({ - type: CAVEAT_TYPES.filterResponse, - value: finalizedAccounts, - name: CAVEAT_NAMES.exposedAccounts, - }); - } - - return finalizedPermissions; - } - - /** - * Validate an array of accounts representing accounts to be exposed - * to a domain. Throws error if validation fails. - * - * @param {string[]} accounts - An array of addresses. - */ - validatePermittedAccounts(accounts) { - if (!Array.isArray(accounts) || accounts.length === 0) { - throw new Error('Must provide non-empty array of account(s).'); - } - - // assert accounts exist - const allIdentities = this._getIdentities(); - accounts.forEach((acc) => { - if (!allIdentities[acc]) { - throw new Error(`Unknown account: ${acc}`); - } - }); - } - - /** - * Notify a domain that its permitted accounts have changed. - * Also updates the accounts history log. - * - * @param {string} origin - The origin of the domain to notify. - * @param {Array} newAccounts - The currently permitted accounts. - */ - notifyAccountsChanged(origin, newAccounts) { - if (typeof origin !== 'string' || !origin) { - throw new Error(`Invalid origin: '${origin}'`); - } - - if (!Array.isArray(newAccounts)) { - throw new Error('Invalid accounts', newAccounts); - } - - // We do not share accounts when the extension is locked. - if (this._isUnlocked()) { - this._notifyDomain(origin, { - method: NOTIFICATION_NAMES.accountsChanged, - params: newAccounts, - }); - this.permissionsLog.updateAccountsHistory(origin, newAccounts); - } - - // NOTE: - // We don't check for accounts changing in the notifyAllDomains case, - // because the log only records when accounts were last seen, and the - // the accounts only change for all domains at once when permissions are - // removed. - } - - /** - * Removes the given permissions for the given domain. - * Should only be called after confirming that the permissions exist, to - * avoid sending unnecessary notifications. - * - * @param {Object} domains - The map of domain origins to permissions to remove. - * e.g. { origin: [permissions] } - */ - removePermissionsFor(domains) { - Object.entries(domains).forEach(([origin, perms]) => { - this.permissions.removePermissionsFor( - origin, - perms.map((methodName) => { - if (methodName === 'eth_accounts') { - this.notifyAccountsChanged(origin, []); - } - - return { parentCapability: methodName }; - }), - ); - }); - } - - /** - * Removes all known domains and their related permissions. - */ - clearPermissions() { - this.permissions.clearDomains(); - // It's safe to notify that no accounts are available, regardless of - // extension lock state - this._notifyAllDomains({ - method: NOTIFICATION_NAMES.accountsChanged, - params: [], - }); - } - - /** - * Stores domain metadata for the given origin (domain). - * Deletes metadata for domains without permissions in a FIFO manner, once - * more than 100 distinct origins have been added since boot. - * Metadata is never deleted for domains with permissions, to prevent a - * degraded user experience, since metadata cannot yet be requested on demand. - * - * @param {string} origin - The origin whose domain metadata to store. - * @param {Object} metadata - The domain's metadata that will be stored. - */ - addDomainMetadata(origin, metadata) { - const oldMetadataState = this.store.getState()[METADATA_STORE_KEY]; - const newMetadataState = { ...oldMetadataState }; - - // delete pending metadata origin from queue, and delete its metadata if - // it doesn't have any permissions - if (this._pendingSiteMetadata.size >= METADATA_CACHE_MAX_SIZE) { - const permissionsDomains = this.permissions.getDomains(); - - const oldOrigin = this._pendingSiteMetadata.values().next().value; - this._pendingSiteMetadata.delete(oldOrigin); - if (!permissionsDomains[oldOrigin]) { - delete newMetadataState[oldOrigin]; - } - } - - // add new metadata to store after popping - newMetadataState[origin] = { - ...oldMetadataState[origin], - ...metadata, - lastUpdated: Date.now(), - }; - - if ( - !newMetadataState[origin].extensionId && - !newMetadataState[origin].host - ) { - newMetadataState[origin].host = new URL(origin).host; - } - - this._pendingSiteMetadata.add(origin); - this._setDomainMetadata(newMetadataState); - } - - /** - * Removes all domains without permissions from the restored metadata state, - * and rehydrates the metadata store. - * - * Requires PermissionsController._initializePermissions to have been called first. - * - * @param {Object} restoredState - The restored permissions controller state. - */ - _initializeMetadataStore(restoredState) { - const metadataState = restoredState[METADATA_STORE_KEY] || {}; - const newMetadataState = this._trimDomainMetadata(metadataState); - - this._pendingSiteMetadata = new Set(); - this._setDomainMetadata(newMetadataState); - } - - /** - * Trims the given metadataState object by removing metadata for all origins - * without permissions. - * Returns a new object; does not mutate the argument. - * - * @param {Object} metadataState - The metadata store state object to trim. - * @returns {Object} The new metadata state object. - */ - _trimDomainMetadata(metadataState) { - const newMetadataState = { ...metadataState }; - const origins = Object.keys(metadataState); - const permissionsDomains = this.permissions.getDomains(); - - origins.forEach((origin) => { - if (!permissionsDomains[origin]) { - delete newMetadataState[origin]; - } - }); - - return newMetadataState; - } - - /** - * Replaces the existing domain metadata with the passed-in object. - * @param {Object} newMetadataState - The new metadata to set. - */ - _setDomainMetadata(newMetadataState) { - this.store.updateState({ [METADATA_STORE_KEY]: newMetadataState }); - } - - /** - * Get current set of permitted accounts for the given origin - * - * @param {string} origin - The origin to obtain permitted accounts for - * @returns {Array} The list of permitted accounts - */ - _getPermittedAccounts(origin) { - const permittedAccounts = this.permissions - .getPermission(origin, 'eth_accounts') - ?.caveats?.find((caveat) => caveat.name === CAVEAT_NAMES.exposedAccounts) - ?.value; - - return permittedAccounts || []; - } - - /** - * When a new account is selected in the UI, emit accountsChanged to each origin - * where the selected account is exposed. - * - * Note: This will emit "false positive" accountsChanged events, but they are - * handled by the inpage provider. - * - * @param {string} account - The newly selected account's address. - */ - async _handleAccountSelected(account) { - if (typeof account !== 'string') { - throw new Error('Selected account should be a non-empty string.'); - } - - const domains = this.permissions.getDomains() || {}; - const connectedDomains = Object.entries(domains) - .filter(([_, { permissions }]) => { - const ethAccounts = permissions.find( - (permission) => permission.parentCapability === 'eth_accounts', - ); - const exposedAccounts = ethAccounts?.caveats.find( - (caveat) => caveat.name === 'exposedAccounts', - )?.value; - return exposedAccounts?.includes(account); - }) - .map(([domain]) => domain); - - await Promise.all( - connectedDomains.map((origin) => - this._handleConnectedAccountSelected(origin), - ), - ); - } - - /** - * When a new account is selected in the UI, emit accountsChanged to 'origin' - * - * Note: This will emit "false positive" accountsChanged events, but they are - * handled by the inpage provider. - * - * @param {string} origin - The origin - */ - async _handleConnectedAccountSelected(origin) { - const permittedAccounts = await this.getAccounts(origin); - - this.notifyAccountsChanged(origin, permittedAccounts); - } - - /** - * A convenience method for retrieving a login object - * or creating a new one if needed. - * - * @param {string} origin - The origin string representing the domain. - */ - _initializePermissions(restoredState) { - // these permission requests are almost certainly stale - const initState = { ...restoredState, permissionsRequests: [] }; - - this.permissions = new RpcCap( - { - // Supports passthrough methods: - safeMethods: SAFE_METHODS, - - // optional prefix for internal methods - methodPrefix: WALLET_PREFIX, - - restrictedMethods: this._restrictedMethods, - - /** - * A promise-returning callback used to determine whether to approve - * permissions requests or not. - * - * Currently only returns a boolean, but eventually should return any - * specific parameters or amendments to the permissions. - * - * @param {string} req - The internal rpc-cap user request object. - */ - requestUserApproval: async (req) => { - const { - metadata: { id, origin }, - } = req; - - return this.approvals.addAndShowApprovalRequest({ - id, - origin, - type: APPROVAL_TYPE, - }); - }, - }, - initState, - ); - } -} +export * from './caveat-mutators'; +export * from './background-api'; +export * from './enums'; +export * from './permission-log'; +export * from './specifications'; +export * from './selectors'; diff --git a/app/scripts/controllers/permissions/permissionsLog.js b/app/scripts/controllers/permissions/permission-log.js similarity index 90% rename from app/scripts/controllers/permissions/permissionsLog.js rename to app/scripts/controllers/permissions/permission-log.js index 1f1a80b5f..f49e9401a 100644 --- a/app/scripts/controllers/permissions/permissionsLog.js +++ b/app/scripts/controllers/permissions/permission-log.js @@ -1,11 +1,10 @@ +import { ObservableStore } from '@metamask/obs-store'; import stringify from 'fast-safe-stringify'; -import { CAVEAT_NAMES } from '../../../../shared/constants/permissions'; +import { CaveatTypes } from '../../../../shared/constants/permissions'; import { - HISTORY_STORE_KEY, LOG_IGNORE_METHODS, LOG_LIMIT, LOG_METHOD_TYPES, - LOG_STORE_KEY, WALLET_PREFIX, } from './enums'; @@ -13,51 +12,59 @@ import { * Controller with middleware for logging requests and responses to restricted * and permissions-related methods. */ -export default class PermissionsLogController { - constructor({ restrictedMethods, store }) { +export class PermissionLogController { + /** + * @param {{ restrictedMethods: Set, initState: Record }} options - Options bag. + */ + constructor({ restrictedMethods, initState }) { this.restrictedMethods = restrictedMethods; - this.store = store; + this.store = new ObservableStore({ + permissionHistory: {}, + permissionActivityLog: [], + ...initState, + }); } /** - * Get the activity log. + * Get the restricted method activity log. * * @returns {Array} The activity log. */ getActivityLog() { - return this.store.getState()[LOG_STORE_KEY] || []; + return this.store.getState().permissionActivityLog; } /** - * Update the activity log. + * Update the restricted method activity log. * * @param {Array} logs - The new activity log array. */ updateActivityLog(logs) { - this.store.updateState({ [LOG_STORE_KEY]: logs }); + this.store.updateState({ permissionActivityLog: logs }); } /** - * Get the permissions history log. + * Get the permission history log. * * @returns {Object} The permissions history log. */ getHistory() { - return this.store.getState()[HISTORY_STORE_KEY] || {}; + return this.store.getState().permissionHistory; } /** - * Update the permissions history log. + * Update the permission history log. * * @param {Object} history - The new permissions history log object. */ updateHistory(history) { - this.store.updateState({ [HISTORY_STORE_KEY]: history }); + this.store.updateState({ permissionHistory: history }); } /** * Updates the exposed account history for the given origin. * Sets the 'last seen' time to Date.now() for the given accounts. + * Does **not** update the 'lastApproved' time for the permission itself. * Returns if the accounts array is empty. * * @param {string} origin - The origin that the accounts are exposed to. @@ -96,7 +103,7 @@ export default class PermissionsLogController { // we only log certain methods if ( !LOG_IGNORE_METHODS.includes(method) && - (isInternal || this.restrictedMethods.includes(method)) + (isInternal || this.restrictedMethods.has(method)) ) { activityEntry = this.logRequest(req, isInternal); @@ -341,7 +348,7 @@ export default class PermissionsLogController { const accounts = new Set(); for (const caveat of perm.caveats) { if ( - caveat.name === CAVEAT_NAMES.exposedAccounts && + caveat.type === CaveatTypes.restrictReturnedAccounts && Array.isArray(caveat.value) ) { for (const value of caveat.value) { diff --git a/app/scripts/controllers/permissions/permissions-log-controller.test.js b/app/scripts/controllers/permissions/permission-log.test.js similarity index 62% rename from app/scripts/controllers/permissions/permissions-log-controller.test.js rename to app/scripts/controllers/permissions/permission-log.test.js index c973319e9..ecbb169dc 100644 --- a/app/scripts/controllers/permissions/permissions-log-controller.test.js +++ b/app/scripts/controllers/permissions/permission-log.test.js @@ -1,23 +1,15 @@ -import { strict as assert } from 'assert'; -import { ObservableStore } from '@metamask/obs-store'; import nanoid from 'nanoid'; import { useFakeTimers } from 'sinon'; - -import { - constants, - getters, - noop, -} from '../../../../test/mocks/permission-controller'; -import { validateActivityEntry } from '../../../../test/helpers/permission-controller-helpers'; -import PermissionsLogController from './permissionsLog'; +import stringify from 'fast-safe-stringify'; +import { constants, getters, noop } from '../../../../test/mocks/permissions'; +import { PermissionLogController } from './permission-log'; import { LOG_LIMIT, LOG_METHOD_TYPES } from './enums'; const { PERMS, RPC_REQUESTS } = getters; - const { ACCOUNTS, EXPECTED_HISTORIES, - DOMAINS, + SUBJECTS, PERM_NAMES, REQUEST_IDS, RESTRICTED_METHODS, @@ -25,10 +17,10 @@ const { let clock; -const initPermLog = () => { - return new PermissionsLogController({ - store: new ObservableStore(), +const initPermLog = (initState = {}) => { + return new PermissionLogController({ restrictedMethods: RESTRICTED_METHODS, + initState, }); }; @@ -59,21 +51,21 @@ const getSavedMockNext = (arr) => (handler) => { arr.push(handler); }; -describe('permissions log', function () { - describe('activity log', function () { +describe('PermissionLogController', () => { + describe('restricted method activity log', () => { let permLog, logMiddleware; - beforeEach(function () { + beforeEach(() => { permLog = initPermLog(); logMiddleware = initMiddleware(permLog); }); - it('records activity for restricted methods', function () { + it('records activity for restricted methods', () => { let log, req, res; // test_method, success - req = RPC_REQUESTS.test_method(DOMAINS.a.origin); + req = RPC_REQUESTS.test_method(SUBJECTS.a.origin); req.id = REQUEST_IDS.a; res = { foo: 'bar' }; @@ -82,7 +74,7 @@ describe('permissions log', function () { log = permLog.getActivityLog(); const entry1 = log[0]; - assert.equal(log.length, 1, 'log should have single entry'); + expect(log).toHaveLength(1); validateActivityEntry( entry1, { ...req }, @@ -93,7 +85,7 @@ describe('permissions log', function () { // eth_accounts, failure - req = RPC_REQUESTS.eth_accounts(DOMAINS.b.origin); + req = RPC_REQUESTS.eth_accounts(SUBJECTS.b.origin); req.id = REQUEST_IDS.b; res = { error: new Error('Unauthorized.') }; @@ -102,7 +94,7 @@ describe('permissions log', function () { log = permLog.getActivityLog(); const entry2 = log[1]; - assert.equal(log.length, 2, 'log should have 2 entries'); + expect(log).toHaveLength(2); validateActivityEntry( entry2, { ...req }, @@ -113,7 +105,7 @@ describe('permissions log', function () { // eth_requestAccounts, success - req = RPC_REQUESTS.eth_requestAccounts(DOMAINS.c.origin); + req = RPC_REQUESTS.eth_requestAccounts(SUBJECTS.c.origin); req.id = REQUEST_IDS.c; res = { result: ACCOUNTS.c.permitted }; @@ -122,7 +114,7 @@ describe('permissions log', function () { log = permLog.getActivityLog(); const entry3 = log[2]; - assert.equal(log.length, 3, 'log should have 3 entries'); + expect(log).toHaveLength(3); validateActivityEntry( entry3, { ...req }, @@ -133,7 +125,7 @@ describe('permissions log', function () { // test_method, no response - req = RPC_REQUESTS.test_method(DOMAINS.a.origin); + req = RPC_REQUESTS.test_method(SUBJECTS.a.origin); req.id = REQUEST_IDS.a; res = null; @@ -142,7 +134,7 @@ describe('permissions log', function () { log = permLog.getActivityLog(); const entry4 = log[3]; - assert.equal(log.length, 4, 'log should have 4 entries'); + expect(log).toHaveLength(4); validateActivityEntry( entry4, { ...req }, @@ -152,14 +144,13 @@ describe('permissions log', function () { ); // validate final state - - assert.equal(entry1, log[0], 'first log entry should remain'); - assert.equal(entry2, log[1], 'second log entry should remain'); - assert.equal(entry3, log[2], 'third log entry should remain'); - assert.equal(entry4, log[3], 'fourth log entry should remain'); + expect(entry1).toStrictEqual(log[0]); + expect(entry2).toStrictEqual(log[1]); + expect(entry3).toStrictEqual(log[2]); + expect(entry4).toStrictEqual(log[3]); }); - it('handles responses added out of order', function () { + it('handles responses added out of order', () => { let log; const handlerArray = []; @@ -168,7 +159,7 @@ describe('permissions log', function () { const id2 = nanoid(); const id3 = nanoid(); - const req = RPC_REQUESTS.test_method(DOMAINS.a.origin); + const req = RPC_REQUESTS.test_method(SUBJECTS.a.origin); // get make requests req.id = id1; @@ -185,19 +176,15 @@ describe('permissions log', function () { // verify log state log = permLog.getActivityLog(); - assert.equal(log.length, 3, 'log should have 3 entries'); + expect(log).toHaveLength(3); const entry1 = log[0]; const entry2 = log[1]; const entry3 = log[2]; - assert.ok( - entry1.id === id1 && - entry1.response === null && - entry2.id === id2 && - entry2.response === null && - entry3.id === id3 && - entry3.response === null, - 'all entries should be in correct order and without responses', - ); + + // all entries should be in correct order, without responses + expect(entry1).toMatchObject({ id: id1, response: null }); + expect(entry2).toMatchObject({ id: id2, response: null }); + expect(entry3).toMatchObject({ id: id3, response: null }); // call response handlers for (const i of [1, 2, 0]) { @@ -206,7 +193,7 @@ describe('permissions log', function () { // verify log state again log = permLog.getActivityLog(); - assert.equal(log.length, 3, 'log should have 3 entries'); + expect(log).toHaveLength(3); // verify all entries log = permLog.getActivityLog(); @@ -236,8 +223,8 @@ describe('permissions log', function () { ); }); - it('handles a lack of response', function () { - let req = RPC_REQUESTS.test_method(DOMAINS.a.origin); + it('handles a lack of response', () => { + let req = RPC_REQUESTS.test_method(SUBJECTS.a.origin); req.id = REQUEST_IDS.a; let res = { foo: 'bar' }; @@ -247,7 +234,7 @@ describe('permissions log', function () { let log = permLog.getActivityLog(); const entry1 = log[0]; - assert.equal(log.length, 1, 'log should have single entry'); + expect(log).toHaveLength(1); validateActivityEntry( entry1, { ...req }, @@ -257,7 +244,7 @@ describe('permissions log', function () { ); // next request should be handled as normal - req = RPC_REQUESTS.eth_accounts(DOMAINS.b.origin); + req = RPC_REQUESTS.eth_accounts(SUBJECTS.b.origin); req.id = REQUEST_IDS.b; res = { result: ACCOUNTS.b.permitted }; @@ -265,7 +252,7 @@ describe('permissions log', function () { log = permLog.getActivityLog(); const entry2 = log[1]; - assert.equal(log.length, 2, 'log should have 2 entries'); + expect(log).toHaveLength(2); validateActivityEntry( entry2, { ...req }, @@ -275,32 +262,32 @@ describe('permissions log', function () { ); // validate final state - assert.equal(entry1, log[0], 'first log entry remains'); - assert.equal(entry2, log[1], 'second log entry remains'); + expect(entry1).toStrictEqual(log[0]); + expect(entry2).toStrictEqual(log[1]); }); - it('ignores expected methods', function () { + it('ignores expected methods', () => { let log = permLog.getActivityLog(); - assert.equal(log.length, 0, 'log should be empty'); + expect(log).toHaveLength(0); const res = { foo: 'bar' }; const req1 = RPC_REQUESTS.metamask_sendDomainMetadata( - DOMAINS.c.origin, + SUBJECTS.c.origin, 'foobar', ); - const req2 = RPC_REQUESTS.custom(DOMAINS.b.origin, 'eth_getBlockNumber'); - const req3 = RPC_REQUESTS.custom(DOMAINS.b.origin, 'net_version'); + const req2 = RPC_REQUESTS.custom(SUBJECTS.b.origin, 'eth_getBlockNumber'); + const req3 = RPC_REQUESTS.custom(SUBJECTS.b.origin, 'net_version'); logMiddleware(req1, res); logMiddleware(req2, res); logMiddleware(req3, res); log = permLog.getActivityLog(); - assert.equal(log.length, 0, 'log should still be empty'); + expect(log).toHaveLength(0); }); - it('enforces log limit', function () { - const req = RPC_REQUESTS.test_method(DOMAINS.a.origin); + it('enforces log limit', () => { + const req = RPC_REQUESTS.test_method(SUBJECTS.a.origin); const res = { foo: 'bar' }; // max out log @@ -312,11 +299,7 @@ describe('permissions log', function () { // check last entry valid let log = permLog.getActivityLog(); - assert.equal( - log.length, - LOG_LIMIT, - 'log should have LOG_LIMIT num entries', - ); + expect(log).toHaveLength(LOG_LIMIT); validateActivityEntry( log[LOG_LIMIT - 1], @@ -335,11 +318,7 @@ describe('permissions log', function () { // check log length log = permLog.getActivityLog(); - assert.equal( - log.length, - LOG_LIMIT, - 'log should have LOG_LIMIT num entries', - ); + expect(log).toHaveLength(LOG_LIMIT); // check first and last entries validateActivityEntry( @@ -360,24 +339,22 @@ describe('permissions log', function () { }); }); - describe('permissions history', function () { + describe('permission history log', () => { let permLog, logMiddleware; - beforeEach(function () { + beforeEach(() => { permLog = initPermLog(); logMiddleware = initMiddleware(permLog); initClock(); }); - afterEach(function () { + afterEach(() => { tearDownClock(); }); - it('only updates history on responses', function () { - let permHistory; - + it('only updates history on responses', () => { const req = RPC_REQUESTS.requestPermission( - DOMAINS.a.origin, + SUBJECTS.a.origin, PERM_NAMES.test_method, ); const res = { result: [PERMS.granted.test_method()] }; @@ -385,27 +362,19 @@ describe('permissions log', function () { // noop => no response logMiddleware({ ...req }, { ...res }, noop); - permHistory = permLog.getHistory(); - assert.deepEqual(permHistory, {}, 'history should not have been updated'); + expect(permLog.getHistory()).toStrictEqual({}); // response => records granted permissions logMiddleware({ ...req }, { ...res }); - permHistory = permLog.getHistory(); - assert.equal( - Object.keys(permHistory).length, - 1, - 'history should have single origin', - ); - assert.ok( - Boolean(permHistory[DOMAINS.a.origin]), - 'history should have expected origin', - ); + const permHistory = permLog.getHistory(); + expect(Object.keys(permHistory)).toHaveLength(1); + expect(permHistory[SUBJECTS.a.origin]).toBeDefined(); }); - it('ignores malformed permissions requests', function () { + it('ignores malformed permissions requests', () => { const req = RPC_REQUESTS.requestPermission( - DOMAINS.a.origin, + SUBJECTS.a.origin, PERM_NAMES.test_method, ); delete req.params; @@ -414,18 +383,12 @@ describe('permissions log', function () { // no params => no response logMiddleware({ ...req }, { ...res }); - assert.deepEqual( - permLog.getHistory(), - {}, - 'history should not have been updated', - ); + expect(permLog.getHistory()).toStrictEqual({}); }); - it('records and updates account history as expected', async function () { - let permHistory; - + it('records and updates account history as expected', async () => { const req = RPC_REQUESTS.requestPermission( - DOMAINS.a.origin, + SUBJECTS.a.origin, PERM_NAMES.eth_accounts, ); const res = { @@ -434,15 +397,7 @@ describe('permissions log', function () { logMiddleware({ ...req }, { ...res }); - // validate history - - permHistory = permLog.getHistory(); - - assert.deepEqual( - permHistory, - EXPECTED_HISTORIES.case1[0], - 'should have correct history', - ); + expect(permLog.getHistory()).toStrictEqual(EXPECTED_HISTORIES.case1[0]); // mock permission requested again, with another approved account @@ -452,18 +407,12 @@ describe('permissions log', function () { logMiddleware({ ...req }, { ...res }); - permHistory = permLog.getHistory(); - - assert.deepEqual( - permHistory, - EXPECTED_HISTORIES.case1[1], - 'should have correct history', - ); + expect(permLog.getHistory()).toStrictEqual(EXPECTED_HISTORIES.case1[1]); }); - it('handles eth_accounts response without caveats', async function () { + it('handles eth_accounts response without caveats', async () => { const req = RPC_REQUESTS.requestPermission( - DOMAINS.a.origin, + SUBJECTS.a.origin, PERM_NAMES.eth_accounts, ); const res = { @@ -473,18 +422,12 @@ describe('permissions log', function () { logMiddleware({ ...req }, { ...res }); - // validate history - - assert.deepEqual( - permLog.getHistory(), - EXPECTED_HISTORIES.case2[0], - 'should have expected history', - ); + expect(permLog.getHistory()).toStrictEqual(EXPECTED_HISTORIES.case2[0]); }); - it('handles extra caveats for eth_accounts', async function () { + it('handles extra caveats for eth_accounts', async () => { const req = RPC_REQUESTS.requestPermission( - DOMAINS.a.origin, + SUBJECTS.a.origin, PERM_NAMES.eth_accounts, ); const res = { @@ -494,20 +437,14 @@ describe('permissions log', function () { logMiddleware({ ...req }, { ...res }); - // validate history - - assert.deepEqual( - permLog.getHistory(), - EXPECTED_HISTORIES.case1[0], - 'should have correct history', - ); + expect(permLog.getHistory()).toStrictEqual(EXPECTED_HISTORIES.case1[0]); }); // wallet_requestPermissions returns all permissions approved for the // requesting origin, including old ones - it('handles unrequested permissions on the response', async function () { + it('handles unrequested permissions on the response', async () => { const req = RPC_REQUESTS.requestPermission( - DOMAINS.a.origin, + SUBJECTS.a.origin, PERM_NAMES.eth_accounts, ); const res = { @@ -519,18 +456,12 @@ describe('permissions log', function () { logMiddleware({ ...req }, { ...res }); - // validate history - - assert.deepEqual( - permLog.getHistory(), - EXPECTED_HISTORIES.case1[0], - 'should have correct history', - ); + expect(permLog.getHistory()).toStrictEqual(EXPECTED_HISTORIES.case1[0]); }); - it('does not update history if no new permissions are approved', async function () { + it('does not update history if no new permissions are approved', async () => { let req = RPC_REQUESTS.requestPermission( - DOMAINS.a.origin, + SUBJECTS.a.origin, PERM_NAMES.test_method, ); let res = { @@ -539,20 +470,14 @@ describe('permissions log', function () { logMiddleware({ ...req }, { ...res }); - // validate history - - assert.deepEqual( - permLog.getHistory(), - EXPECTED_HISTORIES.case4[0], - 'should have correct history', - ); + expect(permLog.getHistory()).toStrictEqual(EXPECTED_HISTORIES.case4[0]); // new permission requested, but not approved clock.tick(1); req = RPC_REQUESTS.requestPermission( - DOMAINS.a.origin, + SUBJECTS.a.origin, PERM_NAMES.eth_accounts, ); res = { @@ -561,18 +486,11 @@ describe('permissions log', function () { logMiddleware({ ...req }, { ...res }); - // validate history - - assert.deepEqual( - permLog.getHistory(), - EXPECTED_HISTORIES.case4[0], - 'should have same history as before', - ); + // history should be unmodified + expect(permLog.getHistory()).toStrictEqual(EXPECTED_HISTORIES.case4[0]); }); - it('records and updates history for multiple origins, regardless of response order', async function () { - let permHistory; - + it('records and updates history for multiple origins, regardless of response order', async () => { // make first round of requests const round1 = []; @@ -581,7 +499,7 @@ describe('permissions log', function () { // first origin round1.push({ req: RPC_REQUESTS.requestPermission( - DOMAINS.a.origin, + SUBJECTS.a.origin, PERM_NAMES.test_method, ), res: { @@ -592,7 +510,7 @@ describe('permissions log', function () { // second origin round1.push({ req: RPC_REQUESTS.requestPermission( - DOMAINS.b.origin, + SUBJECTS.b.origin, PERM_NAMES.eth_accounts, ), res: { @@ -602,7 +520,7 @@ describe('permissions log', function () { // third origin round1.push({ - req: RPC_REQUESTS.requestPermissions(DOMAINS.c.origin, { + req: RPC_REQUESTS.requestPermissions(SUBJECTS.c.origin, { [PERM_NAMES.test_method]: {}, [PERM_NAMES.eth_accounts]: {}, }), @@ -623,14 +541,7 @@ describe('permissions log', function () { handlers1[i](noop); } - // validate history - permHistory = permLog.getHistory(); - - assert.deepEqual( - permHistory, - EXPECTED_HISTORIES.case3[0], - 'should have expected history', - ); + expect(permLog.getHistory()).toStrictEqual(EXPECTED_HISTORIES.case3[0]); // make next round of requests @@ -642,7 +553,7 @@ describe('permissions log', function () { // first origin round2.push({ req: RPC_REQUESTS.requestPermission( - DOMAINS.a.origin, + SUBJECTS.a.origin, PERM_NAMES.test_method, ), res: { @@ -654,7 +565,7 @@ describe('permissions log', function () { // third origin round2.push({ - req: RPC_REQUESTS.requestPermissions(DOMAINS.c.origin, { + req: RPC_REQUESTS.requestPermissions(SUBJECTS.c.origin, { [PERM_NAMES.eth_accounts]: {}, }), res: { @@ -667,14 +578,90 @@ describe('permissions log', function () { logMiddleware({ ...x.req }, { ...x.res }); }); - // validate history - permHistory = permLog.getHistory(); + expect(permLog.getHistory()).toStrictEqual(EXPECTED_HISTORIES.case3[1]); + }); + }); - assert.deepEqual( - permHistory, - EXPECTED_HISTORIES.case3[1], - 'should have expected history', - ); + describe('updateAccountsHistory', () => { + beforeEach(() => { + initClock(); + }); + + afterEach(() => { + tearDownClock(); + }); + + it('does nothing if the list of accounts is empty', () => { + const permLog = initPermLog(); + permLog.updateAccountsHistory('foo.com', []); + + expect(permLog.getHistory()).toStrictEqual({}); + }); + + it('updates the account history', () => { + const permLog = initPermLog({ + permissionHistory: { + 'foo.com': { + [PERM_NAMES.eth_accounts]: { + accounts: { + '0x1': 1, + }, + lastApproved: 1, + }, + }, + }, + }); + + clock.tick(1); + permLog.updateAccountsHistory('foo.com', ['0x1', '0x2']); + + expect(permLog.getHistory()).toStrictEqual({ + 'foo.com': { + [PERM_NAMES.eth_accounts]: { + accounts: { + '0x1': 2, + '0x2': 2, + }, + lastApproved: 1, + }, + }, + }); }); }); }); + +/** + * Validates an activity log entry with respect to a request, response, and + * relevant metadata. + * + * @param {Object} entry - The activity log entry to validate. + * @param {Object} req - The request that generated the entry. + * @param {Object} [res] - The response for the request, if any. + * @param {'restricted'|'internal'} methodType - The method log controller method type of the request. + * @param {boolean} success - Whether the request succeeded or not. + */ +function validateActivityEntry(entry, req, res, methodType, success) { + expect(entry).toBeDefined(); + + expect(entry.id).toStrictEqual(req.id); + expect(entry.method).toStrictEqual(req.method); + expect(entry.origin).toStrictEqual(req.origin); + expect(entry.methodType).toStrictEqual(methodType); + expect(entry.request).toStrictEqual(stringify(req, null, 2)); + + expect(Number.isInteger(entry.requestTime)).toBe(true); + if (res) { + expect(Number.isInteger(entry.responseTime)).toBe(true); + expect(entry.requestTime <= entry.responseTime).toBe(true); + + expect(entry.success).toStrictEqual(success); + expect(entry.response).toStrictEqual(stringify(res, null, 2)); + } else { + expect(entry.requestTime > 0).toBe(true); + expect(entry).toMatchObject({ + response: null, + responseTime: null, + success: null, + }); + } +} diff --git a/app/scripts/controllers/permissions/permissions-controller.test.js b/app/scripts/controllers/permissions/permissions-controller.test.js deleted file mode 100644 index 46fa9d10f..000000000 --- a/app/scripts/controllers/permissions/permissions-controller.test.js +++ /dev/null @@ -1,1562 +0,0 @@ -import { strict as assert } from 'assert'; -import { find } from 'lodash'; -import sinon from 'sinon'; - -import { - constants, - getters, - getNotifyDomain, - getNotifyAllDomains, - getPermControllerOpts, -} from '../../../../test/mocks/permission-controller'; -import { - getRequestUserApprovalHelper, - grantPermissions, -} from '../../../../test/helpers/permission-controller-helpers'; -import { METADATA_STORE_KEY, METADATA_CACHE_MAX_SIZE } from './enums'; - -import { PermissionsController } from '.'; - -const { ERRORS, NOTIFICATIONS, PERMS } = getters; - -const { - ALL_ACCOUNTS, - ACCOUNTS, - DUMMY_ACCOUNT, - DOMAINS, - PERM_NAMES, - REQUEST_IDS, - EXTRA_ACCOUNT, -} = constants; - -const initNotifications = () => { - return Object.values(DOMAINS).reduce((acc, domain) => { - acc[domain.origin] = []; - return acc; - }, {}); -}; - -const initPermController = (notifications = initNotifications()) => { - return new PermissionsController({ - ...getPermControllerOpts(), - notifyDomain: getNotifyDomain(notifications), - notifyAllDomains: getNotifyAllDomains(notifications), - }); -}; - -describe('permissions controller', function () { - describe('constructor', function () { - it('throws on undefined argument', function () { - assert.throws( - () => new PermissionsController(), - 'should throw on undefined argument', - ); - }); - }); - - describe('getAccounts', function () { - let permController; - - beforeEach(function () { - permController = initPermController(); - grantPermissions( - permController, - DOMAINS.a.origin, - PERMS.finalizedRequests.eth_accounts(ACCOUNTS.a.permitted), - ); - grantPermissions( - permController, - DOMAINS.b.origin, - PERMS.finalizedRequests.eth_accounts(ACCOUNTS.b.permitted), - ); - }); - - it('gets permitted accounts for permitted origins', async function () { - const aAccounts = await permController.getAccounts(DOMAINS.a.origin); - const bAccounts = await permController.getAccounts(DOMAINS.b.origin); - - assert.deepEqual( - aAccounts, - [ACCOUNTS.a.primary], - 'first origin should have correct accounts', - ); - assert.deepEqual( - bAccounts, - [ACCOUNTS.b.primary], - 'second origin should have correct accounts', - ); - }); - - it('does not get accounts for unpermitted origins', async function () { - const cAccounts = await permController.getAccounts(DOMAINS.c.origin); - assert.deepEqual(cAccounts, [], 'origin should have no accounts'); - }); - - it('does not handle "metamask" origin as special case', async function () { - const metamaskAccounts = await permController.getAccounts('metamask'); - assert.deepEqual(metamaskAccounts, [], 'origin should have no accounts'); - }); - }); - - describe('hasPermission', function () { - it('returns correct values', async function () { - const permController = initPermController(); - grantPermissions( - permController, - DOMAINS.a.origin, - PERMS.finalizedRequests.eth_accounts(ACCOUNTS.a.permitted), - ); - grantPermissions( - permController, - DOMAINS.b.origin, - PERMS.finalizedRequests.test_method(), - ); - - assert.ok( - permController.hasPermission(DOMAINS.a.origin, 'eth_accounts'), - 'should return true for granted permission', - ); - assert.ok( - permController.hasPermission(DOMAINS.b.origin, 'test_method'), - 'should return true for granted permission', - ); - - assert.ok( - !permController.hasPermission(DOMAINS.a.origin, 'test_method'), - 'should return false for non-granted permission', - ); - assert.ok( - !permController.hasPermission(DOMAINS.b.origin, 'eth_accounts'), - 'should return true for non-granted permission', - ); - - assert.ok( - !permController.hasPermission('foo', 'eth_accounts'), - 'should return false for unknown origin', - ); - assert.ok( - !permController.hasPermission(DOMAINS.b.origin, 'foo'), - 'should return false for unknown permission', - ); - }); - }); - - describe('clearPermissions', function () { - it('notifies all appropriate domains and removes permissions', async function () { - const notifications = initNotifications(); - const permController = initPermController(notifications); - - grantPermissions( - permController, - DOMAINS.a.origin, - PERMS.finalizedRequests.eth_accounts(ACCOUNTS.a.permitted), - ); - grantPermissions( - permController, - DOMAINS.b.origin, - PERMS.finalizedRequests.eth_accounts(ACCOUNTS.b.permitted), - ); - grantPermissions( - permController, - DOMAINS.c.origin, - PERMS.finalizedRequests.eth_accounts(ACCOUNTS.c.permitted), - ); - - let aAccounts = await permController.getAccounts(DOMAINS.a.origin); - let bAccounts = await permController.getAccounts(DOMAINS.b.origin); - let cAccounts = await permController.getAccounts(DOMAINS.c.origin); - - assert.deepEqual( - aAccounts, - [ACCOUNTS.a.primary], - 'first origin should have correct accounts', - ); - assert.deepEqual( - bAccounts, - [ACCOUNTS.b.primary], - 'second origin should have correct accounts', - ); - assert.deepEqual( - cAccounts, - [ACCOUNTS.c.primary], - 'third origin should have correct accounts', - ); - - permController.clearPermissions(); - - Object.keys(notifications).forEach((origin) => { - assert.deepEqual( - notifications[origin], - [NOTIFICATIONS.removedAccounts()], - 'origin should have single metamask_accountsChanged:[] notification', - ); - }); - - aAccounts = await permController.getAccounts(DOMAINS.a.origin); - bAccounts = await permController.getAccounts(DOMAINS.b.origin); - cAccounts = await permController.getAccounts(DOMAINS.c.origin); - - assert.deepEqual(aAccounts, [], 'first origin should have no accounts'); - assert.deepEqual(bAccounts, [], 'second origin should have no accounts'); - assert.deepEqual(cAccounts, [], 'third origin should have no accounts'); - - Object.keys(notifications).forEach((origin) => { - assert.deepEqual( - permController.permissions.getPermissionsForDomain(origin), - [], - 'origin should have no permissions', - ); - }); - - assert.deepEqual( - Object.keys(permController.permissions.getDomains()), - [], - 'all domains should be deleted', - ); - }); - }); - - describe('removePermissionsFor', function () { - let permController, notifications; - - beforeEach(function () { - notifications = initNotifications(); - permController = initPermController(notifications); - grantPermissions( - permController, - DOMAINS.a.origin, - PERMS.finalizedRequests.eth_accounts(ACCOUNTS.a.permitted), - ); - grantPermissions( - permController, - DOMAINS.b.origin, - PERMS.finalizedRequests.eth_accounts(ACCOUNTS.b.permitted), - ); - }); - - it('removes permissions for multiple domains', async function () { - let aAccounts = await permController.getAccounts(DOMAINS.a.origin); - let bAccounts = await permController.getAccounts(DOMAINS.b.origin); - - assert.deepEqual( - aAccounts, - [ACCOUNTS.a.primary], - 'first origin should have correct accounts', - ); - assert.deepEqual( - bAccounts, - [ACCOUNTS.b.primary], - 'second origin should have correct accounts', - ); - - permController.removePermissionsFor({ - [DOMAINS.a.origin]: [PERM_NAMES.eth_accounts], - [DOMAINS.b.origin]: [PERM_NAMES.eth_accounts], - }); - - aAccounts = await permController.getAccounts(DOMAINS.a.origin); - bAccounts = await permController.getAccounts(DOMAINS.b.origin); - - assert.deepEqual(aAccounts, [], 'first origin should have no accounts'); - assert.deepEqual(bAccounts, [], 'second origin should have no accounts'); - - assert.deepEqual( - notifications[DOMAINS.a.origin], - [NOTIFICATIONS.removedAccounts()], - 'first origin should have correct notification', - ); - assert.deepEqual( - notifications[DOMAINS.b.origin], - [NOTIFICATIONS.removedAccounts()], - 'second origin should have correct notification', - ); - - assert.deepEqual( - Object.keys(permController.permissions.getDomains()), - [], - 'all domains should be deleted', - ); - }); - - it('only removes targeted permissions from single domain', async function () { - grantPermissions( - permController, - DOMAINS.b.origin, - PERMS.finalizedRequests.test_method(), - ); - - let bPermissions = permController.permissions.getPermissionsForDomain( - DOMAINS.b.origin, - ); - - assert.ok( - bPermissions.length === 2 && - find(bPermissions, { parentCapability: PERM_NAMES.eth_accounts }) && - find(bPermissions, { parentCapability: PERM_NAMES.test_method }), - 'origin should have correct permissions', - ); - - permController.removePermissionsFor({ - [DOMAINS.b.origin]: [PERM_NAMES.test_method], - }); - - bPermissions = permController.permissions.getPermissionsForDomain( - DOMAINS.b.origin, - ); - - assert.ok( - bPermissions.length === 1 && - find(bPermissions, { parentCapability: PERM_NAMES.eth_accounts }), - 'only targeted permission should have been removed', - ); - }); - - it('removes permissions for a single domain, without affecting another', async function () { - permController.removePermissionsFor({ - [DOMAINS.b.origin]: [PERM_NAMES.eth_accounts], - }); - - const aAccounts = await permController.getAccounts(DOMAINS.a.origin); - const bAccounts = await permController.getAccounts(DOMAINS.b.origin); - - assert.deepEqual( - aAccounts, - [ACCOUNTS.a.primary], - 'first origin should have correct accounts', - ); - assert.deepEqual(bAccounts, [], 'second origin should have no accounts'); - - assert.deepEqual( - notifications[DOMAINS.a.origin], - [], - 'first origin should have no notifications', - ); - assert.deepEqual( - notifications[DOMAINS.b.origin], - [NOTIFICATIONS.removedAccounts()], - 'second origin should have correct notification', - ); - - assert.deepEqual( - Object.keys(permController.permissions.getDomains()), - [DOMAINS.a.origin], - 'only first origin should remain', - ); - }); - - it('send notification but does not affect permissions for unknown domain', async function () { - // it knows nothing of this origin - permController.removePermissionsFor({ - [DOMAINS.c.origin]: [PERM_NAMES.eth_accounts], - }); - - assert.deepEqual( - notifications[DOMAINS.c.origin], - [NOTIFICATIONS.removedAccounts()], - 'unknown origin should have notification', - ); - - const aAccounts = await permController.getAccounts(DOMAINS.a.origin); - const bAccounts = await permController.getAccounts(DOMAINS.b.origin); - - assert.deepEqual( - aAccounts, - [ACCOUNTS.a.primary], - 'first origin should have correct accounts', - ); - assert.deepEqual( - bAccounts, - [ACCOUNTS.b.primary], - 'second origin should have correct accounts', - ); - - assert.deepEqual( - Object.keys(permController.permissions.getDomains()), - [DOMAINS.a.origin, DOMAINS.b.origin], - 'should have correct domains', - ); - }); - }); - - describe('validatePermittedAccounts', function () { - let permController; - - beforeEach(function () { - permController = initPermController(); - grantPermissions( - permController, - DOMAINS.a.origin, - PERMS.finalizedRequests.eth_accounts(ACCOUNTS.a.permitted), - ); - grantPermissions( - permController, - DOMAINS.b.origin, - PERMS.finalizedRequests.eth_accounts(ACCOUNTS.b.permitted), - ); - }); - - it('throws error on non-array accounts', async function () { - await assert.throws( - () => permController.validatePermittedAccounts(undefined), - ERRORS.validatePermittedAccounts.invalidParam(), - 'should throw on undefined', - ); - - await assert.throws( - () => permController.validatePermittedAccounts(false), - ERRORS.validatePermittedAccounts.invalidParam(), - 'should throw on false', - ); - - await assert.throws( - () => permController.validatePermittedAccounts(true), - ERRORS.validatePermittedAccounts.invalidParam(), - 'should throw on true', - ); - - await assert.throws( - () => permController.validatePermittedAccounts({}), - ERRORS.validatePermittedAccounts.invalidParam(), - 'should throw on non-array object', - ); - }); - - it('throws error on empty array of accounts', async function () { - await assert.throws( - () => permController.validatePermittedAccounts([]), - ERRORS.validatePermittedAccounts.invalidParam(), - 'should throw on empty array', - ); - }); - - it('throws error if any account value is not in keyring', async function () { - const keyringAccounts = await permController.getKeyringAccounts(); - - await assert.throws( - () => permController.validatePermittedAccounts([DUMMY_ACCOUNT]), - ERRORS.validatePermittedAccounts.nonKeyringAccount(DUMMY_ACCOUNT), - 'should throw on non-keyring account', - ); - - await assert.throws( - () => - permController.validatePermittedAccounts( - keyringAccounts.concat(DUMMY_ACCOUNT), - ), - ERRORS.validatePermittedAccounts.nonKeyringAccount(DUMMY_ACCOUNT), - 'should throw on non-keyring account with other accounts', - ); - }); - - it('succeeds if all accounts are in keyring', async function () { - const keyringAccounts = await permController.getKeyringAccounts(); - - await assert.doesNotThrow( - () => permController.validatePermittedAccounts(keyringAccounts), - 'should not throw on all keyring accounts', - ); - - await assert.doesNotThrow( - () => permController.validatePermittedAccounts([keyringAccounts[0]]), - 'should not throw on single keyring account', - ); - - await assert.doesNotThrow( - () => permController.validatePermittedAccounts([keyringAccounts[1]]), - 'should not throw on single keyring account', - ); - }); - }); - - describe('addPermittedAccount', function () { - let permController, notifications; - - beforeEach(function () { - notifications = initNotifications(); - permController = initPermController(notifications); - grantPermissions( - permController, - DOMAINS.a.origin, - PERMS.finalizedRequests.eth_accounts(ACCOUNTS.a.permitted), - ); - grantPermissions( - permController, - DOMAINS.b.origin, - PERMS.finalizedRequests.eth_accounts(ACCOUNTS.b.permitted), - ); - }); - - it('should throw if account is not a string', async function () { - await assert.rejects( - () => permController.addPermittedAccount(DOMAINS.a.origin, {}), - ERRORS.validatePermittedAccounts.nonKeyringAccount({}), - 'should throw on non-string account param', - ); - }); - - it('should throw if given account is not in keyring', async function () { - await assert.rejects( - () => - permController.addPermittedAccount(DOMAINS.a.origin, DUMMY_ACCOUNT), - ERRORS.validatePermittedAccounts.nonKeyringAccount(DUMMY_ACCOUNT), - 'should throw on non-keyring account', - ); - }); - - it('should throw if origin is invalid', async function () { - await assert.rejects( - () => permController.addPermittedAccount(false, EXTRA_ACCOUNT), - ERRORS.addPermittedAccount.invalidOrigin(), - 'should throw on invalid origin', - ); - }); - - it('should throw if origin lacks any permissions', async function () { - await assert.rejects( - () => - permController.addPermittedAccount(DOMAINS.c.origin, EXTRA_ACCOUNT), - ERRORS.addPermittedAccount.invalidOrigin(), - 'should throw on origin without permissions', - ); - }); - - it('should throw if origin lacks eth_accounts permission', async function () { - grantPermissions( - permController, - DOMAINS.c.origin, - PERMS.finalizedRequests.test_method(), - ); - - await assert.rejects( - () => - permController.addPermittedAccount(DOMAINS.c.origin, EXTRA_ACCOUNT), - ERRORS.addPermittedAccount.noEthAccountsPermission(), - 'should throw on origin without eth_accounts permission', - ); - }); - - it('should throw if account is already permitted', async function () { - await assert.rejects( - () => - permController.addPermittedAccount( - DOMAINS.a.origin, - ACCOUNTS.a.permitted[0], - ), - ERRORS.addPermittedAccount.alreadyPermitted(), - 'should throw if account is already permitted', - ); - }); - - it('should successfully add permitted account', async function () { - await permController.addPermittedAccount(DOMAINS.a.origin, EXTRA_ACCOUNT); - - const accounts = await permController._getPermittedAccounts( - DOMAINS.a.origin, - ); - - assert.deepEqual( - accounts, - [...ACCOUNTS.a.permitted, EXTRA_ACCOUNT], - 'origin should have correct accounts', - ); - - assert.deepEqual( - notifications[DOMAINS.a.origin][0], - NOTIFICATIONS.newAccounts([ACCOUNTS.a.primary]), - 'origin should have correct notification', - ); - }); - }); - - describe('removePermittedAccount', function () { - let permController, notifications; - - beforeEach(function () { - notifications = initNotifications(); - permController = initPermController(notifications); - grantPermissions( - permController, - DOMAINS.a.origin, - PERMS.finalizedRequests.eth_accounts(ACCOUNTS.a.permitted), - ); - grantPermissions( - permController, - DOMAINS.b.origin, - PERMS.finalizedRequests.eth_accounts(ACCOUNTS.b.permitted), - ); - }); - - it('should throw if account is not a string', async function () { - await assert.rejects( - () => permController.removePermittedAccount(DOMAINS.a.origin, {}), - ERRORS.validatePermittedAccounts.nonKeyringAccount({}), - 'should throw on non-string account param', - ); - }); - - it('should throw if given account is not in keyring', async function () { - await assert.rejects( - () => - permController.removePermittedAccount( - DOMAINS.a.origin, - DUMMY_ACCOUNT, - ), - ERRORS.validatePermittedAccounts.nonKeyringAccount(DUMMY_ACCOUNT), - 'should throw on non-keyring account', - ); - }); - - it('should throw if origin is invalid', async function () { - await assert.rejects( - () => permController.removePermittedAccount(false, EXTRA_ACCOUNT), - ERRORS.removePermittedAccount.invalidOrigin(), - 'should throw on invalid origin', - ); - }); - - it('should throw if origin lacks any permissions', async function () { - await assert.rejects( - () => - permController.removePermittedAccount( - DOMAINS.c.origin, - EXTRA_ACCOUNT, - ), - ERRORS.removePermittedAccount.invalidOrigin(), - 'should throw on origin without permissions', - ); - }); - - it('should throw if origin lacks eth_accounts permission', async function () { - grantPermissions( - permController, - DOMAINS.c.origin, - PERMS.finalizedRequests.test_method(), - ); - - await assert.rejects( - () => - permController.removePermittedAccount( - DOMAINS.c.origin, - EXTRA_ACCOUNT, - ), - ERRORS.removePermittedAccount.noEthAccountsPermission(), - 'should throw on origin without eth_accounts permission', - ); - }); - - it('should throw if account is not permitted', async function () { - await assert.rejects( - () => - permController.removePermittedAccount( - DOMAINS.b.origin, - ACCOUNTS.c.permitted[0], - ), - ERRORS.removePermittedAccount.notPermitted(), - 'should throw if account is not permitted', - ); - }); - - it('should successfully remove permitted account', async function () { - await permController.removePermittedAccount( - DOMAINS.a.origin, - ACCOUNTS.a.permitted[1], - ); - - const accounts = await permController._getPermittedAccounts( - DOMAINS.a.origin, - ); - - assert.deepEqual( - accounts, - ACCOUNTS.a.permitted.filter((acc) => acc !== ACCOUNTS.a.permitted[1]), - 'origin should have correct accounts', - ); - - assert.deepEqual( - notifications[DOMAINS.a.origin][0], - NOTIFICATIONS.newAccounts([ACCOUNTS.a.primary]), - 'origin should have correct notification', - ); - }); - - it('should remove eth_accounts permission if removing only permitted account', async function () { - await permController.removePermittedAccount( - DOMAINS.b.origin, - ACCOUNTS.b.permitted[0], - ); - - const accounts = await permController.getAccounts(DOMAINS.b.origin); - - assert.deepEqual(accounts, [], 'origin should have no accounts'); - - const permission = await permController.permissions.getPermission( - DOMAINS.b.origin, - PERM_NAMES.eth_accounts, - ); - - assert.equal( - permission, - undefined, - 'origin should not have eth_accounts permission', - ); - - assert.deepEqual( - notifications[DOMAINS.b.origin][0], - NOTIFICATIONS.removedAccounts(), - 'origin should have correct notification', - ); - }); - }); - - describe('removeAllAccountPermissions', function () { - let permController, notifications; - - beforeEach(function () { - notifications = initNotifications(); - permController = initPermController(notifications); - grantPermissions( - permController, - DOMAINS.a.origin, - PERMS.finalizedRequests.eth_accounts(ACCOUNTS.a.permitted), - ); - grantPermissions( - permController, - DOMAINS.b.origin, - PERMS.finalizedRequests.eth_accounts(ACCOUNTS.b.permitted), - ); - grantPermissions( - permController, - DOMAINS.c.origin, - PERMS.finalizedRequests.eth_accounts(ACCOUNTS.b.permitted), - ); - }); - - it('should throw if account is not a string', async function () { - await assert.rejects( - () => permController.removeAllAccountPermissions({}), - ERRORS.validatePermittedAccounts.nonKeyringAccount({}), - 'should throw on non-string account param', - ); - }); - - it('should throw if given account is not in keyring', async function () { - await assert.rejects( - () => permController.removeAllAccountPermissions(DUMMY_ACCOUNT), - ERRORS.validatePermittedAccounts.nonKeyringAccount(DUMMY_ACCOUNT), - 'should throw on non-keyring account', - ); - }); - - it('should remove permitted account from single origin', async function () { - await permController.removeAllAccountPermissions(ACCOUNTS.a.permitted[1]); - - const accounts = await permController._getPermittedAccounts( - DOMAINS.a.origin, - ); - - assert.deepEqual( - accounts, - ACCOUNTS.a.permitted.filter((acc) => acc !== ACCOUNTS.a.permitted[1]), - 'origin should have correct accounts', - ); - - assert.deepEqual( - notifications[DOMAINS.a.origin][0], - NOTIFICATIONS.newAccounts([ACCOUNTS.a.primary]), - 'origin should have correct notification', - ); - }); - - it('should permitted account from multiple origins', async function () { - await permController.removeAllAccountPermissions(ACCOUNTS.b.permitted[0]); - - const bAccounts = await permController.getAccounts(DOMAINS.b.origin); - assert.deepEqual(bAccounts, [], 'first origin should no accounts'); - - const cAccounts = await permController.getAccounts(DOMAINS.c.origin); - assert.deepEqual(cAccounts, [], 'second origin no accounts'); - - assert.deepEqual( - notifications[DOMAINS.b.origin][0], - NOTIFICATIONS.removedAccounts(), - 'first origin should have correct notification', - ); - - assert.deepEqual( - notifications[DOMAINS.c.origin][0], - NOTIFICATIONS.removedAccounts(), - 'second origin should have correct notification', - ); - }); - - it('should remove eth_accounts permission if removing only permitted account', async function () { - await permController.removeAllAccountPermissions(ACCOUNTS.b.permitted[0]); - - const accounts = await permController.getAccounts(DOMAINS.b.origin); - - assert.deepEqual(accounts, [], 'origin should have no accounts'); - - const permission = await permController.permissions.getPermission( - DOMAINS.b.origin, - PERM_NAMES.eth_accounts, - ); - - assert.equal( - permission, - undefined, - 'origin should not have eth_accounts permission', - ); - - assert.deepEqual( - notifications[DOMAINS.b.origin][0], - NOTIFICATIONS.removedAccounts(), - 'origin should have correct notification', - ); - }); - }); - - describe('finalizePermissionsRequest', function () { - let permController; - - beforeEach(function () { - permController = initPermController(); - }); - - it('throws on non-keyring accounts', async function () { - await assert.rejects( - permController.finalizePermissionsRequest( - PERMS.requests.eth_accounts(), - [DUMMY_ACCOUNT], - ), - ERRORS.validatePermittedAccounts.nonKeyringAccount(DUMMY_ACCOUNT), - 'should throw on non-keyring account', - ); - }); - - it('adds caveat to eth_accounts permission', async function () { - const perm = await permController.finalizePermissionsRequest( - PERMS.requests.eth_accounts(), - ACCOUNTS.a.permitted, - ); - - assert.deepEqual( - perm, - PERMS.finalizedRequests.eth_accounts(ACCOUNTS.a.permitted), - ); - }); - - it('replaces caveat of eth_accounts permission', async function () { - const perm = await permController.finalizePermissionsRequest( - PERMS.finalizedRequests.eth_accounts(ACCOUNTS.a.permitted), - ACCOUNTS.b.permitted, - ); - - assert.deepEqual( - perm, - PERMS.finalizedRequests.eth_accounts(ACCOUNTS.b.permitted), - 'permission should have correct caveat', - ); - }); - - it('handles non-eth_accounts permission', async function () { - const perm = await permController.finalizePermissionsRequest( - PERMS.finalizedRequests.test_method(), - ACCOUNTS.b.permitted, - ); - - assert.deepEqual( - perm, - PERMS.finalizedRequests.test_method(), - 'permission should have correct caveat', - ); - }); - }); - - describe('preferences state update', function () { - let permController, notifications, preferences, identities; - - beforeEach(function () { - identities = ALL_ACCOUNTS.reduce((identitiesAcc, account) => { - identitiesAcc[account] = {}; - return identitiesAcc; - }, {}); - preferences = { - getState: sinon.stub(), - subscribe: sinon.stub(), - }; - preferences.getState.returns({ - identities, - selectedAddress: DUMMY_ACCOUNT, - }); - notifications = initNotifications(); - permController = new PermissionsController({ - ...getPermControllerOpts(), - notifyDomain: getNotifyDomain(notifications), - notifyAllDomains: getNotifyAllDomains(notifications), - preferences, - }); - grantPermissions( - permController, - DOMAINS.b.origin, - PERMS.finalizedRequests.eth_accounts([ - ...ACCOUNTS.a.permitted, - EXTRA_ACCOUNT, - ]), - ); - grantPermissions( - permController, - DOMAINS.c.origin, - PERMS.finalizedRequests.eth_accounts(ACCOUNTS.a.permitted), - ); - }); - - it('should throw if given invalid account', async function () { - assert(preferences.subscribe.calledOnce); - assert(preferences.subscribe.firstCall.args.length === 1); - const onPreferencesUpdate = preferences.subscribe.firstCall.args[0]; - - await assert.rejects( - () => onPreferencesUpdate({ selectedAddress: {} }), - ERRORS._handleAccountSelected.invalidParams(), - 'should throw if account is not a string', - ); - }); - - it('should do nothing if account not permitted for any origins', async function () { - assert(preferences.subscribe.calledOnce); - assert(preferences.subscribe.firstCall.args.length === 1); - const onPreferencesUpdate = preferences.subscribe.firstCall.args[0]; - - await onPreferencesUpdate({ selectedAddress: DUMMY_ACCOUNT }); - - assert.deepEqual( - notifications[DOMAINS.b.origin], - [], - 'should not have emitted notification', - ); - assert.deepEqual( - notifications[DOMAINS.c.origin], - [], - 'should not have emitted notification', - ); - }); - - it('should emit notification if account already first in array for each connected site', async function () { - identities[ACCOUNTS.a.permitted[0]] = { lastSelected: 1000 }; - assert(preferences.subscribe.calledOnce); - assert(preferences.subscribe.firstCall.args.length === 1); - const onPreferencesUpdate = preferences.subscribe.firstCall.args[0]; - - await onPreferencesUpdate({ selectedAddress: ACCOUNTS.a.permitted[0] }); - - assert.deepEqual( - notifications[DOMAINS.b.origin], - [NOTIFICATIONS.newAccounts([ACCOUNTS.a.primary])], - 'should not have emitted notification', - ); - assert.deepEqual( - notifications[DOMAINS.c.origin], - [NOTIFICATIONS.newAccounts([ACCOUNTS.a.primary])], - 'should not have emitted notification', - ); - }); - - it('should emit notification just for connected domains', async function () { - identities[EXTRA_ACCOUNT] = { lastSelected: 1000 }; - assert(preferences.subscribe.calledOnce); - assert(preferences.subscribe.firstCall.args.length === 1); - const onPreferencesUpdate = preferences.subscribe.firstCall.args[0]; - - await onPreferencesUpdate({ selectedAddress: EXTRA_ACCOUNT }); - - assert.deepEqual( - notifications[DOMAINS.b.origin], - [NOTIFICATIONS.newAccounts([EXTRA_ACCOUNT])], - 'should have emitted notification', - ); - assert.deepEqual( - notifications[DOMAINS.c.origin], - [], - 'should not have emitted notification', - ); - }); - - it('should emit notification for multiple connected domains', async function () { - identities[ACCOUNTS.a.permitted[1]] = { lastSelected: 1000 }; - assert(preferences.subscribe.calledOnce); - assert(preferences.subscribe.firstCall.args.length === 1); - const onPreferencesUpdate = preferences.subscribe.firstCall.args[0]; - - await onPreferencesUpdate({ selectedAddress: ACCOUNTS.a.permitted[1] }); - - assert.deepEqual( - notifications[DOMAINS.b.origin], - [NOTIFICATIONS.newAccounts([ACCOUNTS.a.permitted[1]])], - 'should have emitted notification', - ); - assert.deepEqual( - notifications[DOMAINS.c.origin], - [NOTIFICATIONS.newAccounts([ACCOUNTS.c.primary])], - 'should have emitted notification', - ); - }); - }); - - describe('approvePermissionsRequest', function () { - let permController, requestUserApproval; - - beforeEach(function () { - permController = initPermController(); - requestUserApproval = getRequestUserApprovalHelper(permController); - }); - - it('does nothing if called on non-existing request', async function () { - sinon.spy(permController, 'finalizePermissionsRequest'); - - const request = PERMS.approvedRequest(REQUEST_IDS.a, null); - - await assert.doesNotReject( - permController.approvePermissionsRequest(request, null), - 'should not throw on non-existing request', - ); - - assert.ok( - permController.finalizePermissionsRequest.notCalled, - 'should not call finalizePermissionRequest', - ); - }); - - it('rejects request with bad accounts param', async function () { - const request = PERMS.approvedRequest( - REQUEST_IDS.a, - PERMS.requests.eth_accounts(), - ); - - const rejectionPromise = assert.rejects( - requestUserApproval(REQUEST_IDS.a), - ERRORS.validatePermittedAccounts.invalidParam(), - 'should reject with "null" accounts', - ); - - await permController.approvePermissionsRequest(request, null); - await rejectionPromise; - }); - - it('rejects request with no permissions', async function () { - const request = PERMS.approvedRequest(REQUEST_IDS.a, {}); - - const requestRejection = assert.rejects( - requestUserApproval(REQUEST_IDS.a), - ERRORS.approvePermissionsRequest.noPermsRequested(), - 'should reject if no permissions in request', - ); - - await permController.approvePermissionsRequest( - request, - ACCOUNTS.a.permitted, - ); - await requestRejection; - }); - - it('approves valid request', async function () { - const request = PERMS.approvedRequest( - REQUEST_IDS.a, - PERMS.requests.eth_accounts(), - ); - - let perms; - - const requestApproval = assert.doesNotReject(async () => { - perms = await requestUserApproval(REQUEST_IDS.a); - }, 'should not reject single valid request'); - - await permController.approvePermissionsRequest( - request, - ACCOUNTS.a.permitted, - ); - await requestApproval; - - assert.deepEqual( - perms, - PERMS.finalizedRequests.eth_accounts(ACCOUNTS.a.permitted), - 'should produce expected approved permissions', - ); - }); - - it('approves valid requests regardless of order', async function () { - const request1 = PERMS.approvedRequest( - REQUEST_IDS.a, - PERMS.requests.eth_accounts(), - ); - const request2 = PERMS.approvedRequest( - REQUEST_IDS.b, - PERMS.requests.eth_accounts(), - ); - const request3 = PERMS.approvedRequest( - REQUEST_IDS.c, - PERMS.requests.eth_accounts(), - ); - - let perms1, perms2; - - const approval1 = assert.doesNotReject(async () => { - perms1 = await requestUserApproval(REQUEST_IDS.a, DOMAINS.a.origin); - }, 'should not reject request'); - - const approval2 = assert.doesNotReject(async () => { - perms2 = await requestUserApproval(REQUEST_IDS.b, DOMAINS.b.origin); - }, 'should not reject request'); - - // approve out of order - await permController.approvePermissionsRequest( - request2, - ACCOUNTS.b.permitted, - ); - // add a non-existing request to the mix - await permController.approvePermissionsRequest( - request3, - ACCOUNTS.c.permitted, - ); - await permController.approvePermissionsRequest( - request1, - ACCOUNTS.a.permitted, - ); - - await approval1; - await approval2; - - assert.deepEqual( - perms1, - PERMS.finalizedRequests.eth_accounts(ACCOUNTS.a.permitted), - 'first request should produce expected approved permissions', - ); - - assert.deepEqual( - perms2, - PERMS.finalizedRequests.eth_accounts(ACCOUNTS.b.permitted), - 'second request should produce expected approved permissions', - ); - }); - }); - - describe('rejectPermissionsRequest', function () { - let permController, requestUserApproval; - - beforeEach(async function () { - permController = initPermController(); - requestUserApproval = getRequestUserApprovalHelper(permController); - }); - - it('does nothing if called on non-existing request', async function () { - permController.approvals.add = sinon.fake.throws( - new Error('should not call add'), - ); - - await assert.doesNotReject( - permController.rejectPermissionsRequest(REQUEST_IDS.a), - 'should not throw on non-existing request', - ); - }); - - it('rejects single existing request', async function () { - const requestRejection = assert.rejects( - requestUserApproval(REQUEST_IDS.a), - ERRORS.rejectPermissionsRequest.rejection(), - 'should reject with expected error', - ); - - await permController.rejectPermissionsRequest(REQUEST_IDS.a); - await requestRejection; - }); - - it('rejects requests regardless of order', async function () { - const requestRejection1 = assert.rejects( - requestUserApproval(REQUEST_IDS.b, DOMAINS.b.origin), - ERRORS.rejectPermissionsRequest.rejection(), - 'should reject with expected error', - ); - - const requestRejection2 = assert.rejects( - requestUserApproval(REQUEST_IDS.c, DOMAINS.c.origin), - ERRORS.rejectPermissionsRequest.rejection(), - 'should reject with expected error', - ); - - // reject out of order - await permController.rejectPermissionsRequest(REQUEST_IDS.c); - // add a non-existing request to the mix - await permController.rejectPermissionsRequest(REQUEST_IDS.a); - await permController.rejectPermissionsRequest(REQUEST_IDS.b); - - await requestRejection1; - await requestRejection2; - }); - }); - - // see permissions-middleware-test for testing the middleware itself - describe('createMiddleware', function () { - let permController, clock; - - beforeEach(function () { - permController = initPermController(); - clock = sinon.useFakeTimers(1); - }); - - afterEach(function () { - clock.restore(); - }); - - it('should throw on bad origin', function () { - assert.throws( - () => permController.createMiddleware({ origin: {} }), - ERRORS.createMiddleware.badOrigin(), - 'should throw expected error', - ); - - assert.throws( - () => permController.createMiddleware({ origin: '' }), - ERRORS.createMiddleware.badOrigin(), - 'should throw expected error', - ); - - assert.throws( - () => permController.createMiddleware({}), - ERRORS.createMiddleware.badOrigin(), - 'should throw expected error', - ); - }); - - it('should create a middleware', function () { - let middleware; - assert.doesNotThrow(() => { - middleware = permController.createMiddleware({ - origin: DOMAINS.a.origin, - }); - }, 'should not throw'); - - assert.equal(typeof middleware, 'function', 'should return function'); - }); - - it('should create a middleware with extensionId', function () { - const extensionId = 'fooExtension'; - - let middleware; - assert.doesNotThrow(() => { - middleware = permController.createMiddleware({ - origin: DOMAINS.a.origin, - extensionId, - }); - }, 'should not throw'); - - assert.equal(typeof middleware, 'function', 'should return function'); - - const metadataStore = permController.store.getState()[METADATA_STORE_KEY]; - - assert.deepEqual( - metadataStore[DOMAINS.a.origin], - { extensionId, lastUpdated: 1 }, - 'metadata should be stored', - ); - }); - }); - - describe('notifyAccountsChanged', function () { - let notifications, permController; - - beforeEach(function () { - notifications = initNotifications(); - permController = initPermController(notifications); - sinon.spy(permController.permissionsLog, 'updateAccountsHistory'); - }); - - it('notifyAccountsChanged records history and sends notification', async function () { - sinon.spy(permController, '_isUnlocked'); - - permController.notifyAccountsChanged( - DOMAINS.a.origin, - ACCOUNTS.a.permitted, - ); - - assert.ok( - permController._isUnlocked.calledOnce, - '_isUnlocked should have been called once', - ); - - assert.ok( - permController.permissionsLog.updateAccountsHistory.calledOnce, - 'permissionsLog.updateAccountsHistory should have been called once', - ); - - assert.deepEqual( - notifications[DOMAINS.a.origin], - [NOTIFICATIONS.newAccounts(ACCOUNTS.a.permitted)], - 'origin should have correct notification', - ); - }); - - it('notifyAccountsChanged does nothing if _isUnlocked returns false', async function () { - permController._isUnlocked = sinon.fake.returns(false); - - permController.notifyAccountsChanged( - DOMAINS.a.origin, - ACCOUNTS.a.permitted, - ); - - assert.ok( - permController._isUnlocked.calledOnce, - '_isUnlocked should have been called once', - ); - - assert.ok( - permController.permissionsLog.updateAccountsHistory.notCalled, - 'permissionsLog.updateAccountsHistory should not have been called', - ); - }); - - it('notifyAccountsChanged throws on invalid origin', async function () { - assert.throws( - () => permController.notifyAccountsChanged(4, ACCOUNTS.a.permitted), - ERRORS.notifyAccountsChanged.invalidOrigin(4), - 'should throw expected error for non-string origin', - ); - - assert.throws( - () => permController.notifyAccountsChanged('', ACCOUNTS.a.permitted), - ERRORS.notifyAccountsChanged.invalidOrigin(''), - 'should throw expected error for empty string origin', - ); - }); - - it('notifyAccountsChanged throws on invalid accounts', async function () { - assert.throws( - () => permController.notifyAccountsChanged(DOMAINS.a.origin, 4), - ERRORS.notifyAccountsChanged.invalidAccounts(), - 'should throw expected error for truthy non-array accounts', - ); - - assert.throws( - () => permController.notifyAccountsChanged(DOMAINS.a.origin, null), - ERRORS.notifyAccountsChanged.invalidAccounts(), - 'should throw expected error for falsy non-array accounts', - ); - }); - }); - - describe('addDomainMetadata', function () { - let permController, clock; - - function getMockMetadata(size) { - const dummyData = {}; - for (let i = 0; i < size; i++) { - const key = i.toString(); - dummyData[key] = {}; - } - return dummyData; - } - - beforeEach(function () { - permController = initPermController(); - permController._setDomainMetadata = sinon.fake(); - clock = sinon.useFakeTimers(1); - }); - - afterEach(function () { - clock.restore(); - }); - - it('calls setter function with expected new state when adding domain', function () { - permController.store.getState = sinon.fake.returns({ - [METADATA_STORE_KEY]: { - [DOMAINS.a.origin]: { - foo: 'bar', - }, - }, - }); - - permController.addDomainMetadata(DOMAINS.b.origin, { foo: 'bar' }); - - assert.ok( - permController.store.getState.called, - 'should have called store.getState', - ); - assert.equal( - permController._setDomainMetadata.getCalls().length, - 1, - 'should have called _setDomainMetadata once', - ); - assert.deepEqual(permController._setDomainMetadata.lastCall.args, [ - { - [DOMAINS.a.origin]: { - foo: 'bar', - }, - [DOMAINS.b.origin]: { - foo: 'bar', - host: DOMAINS.b.host, - lastUpdated: 1, - }, - }, - ]); - }); - - it('calls setter function with expected new states when updating existing domain', function () { - permController.store.getState = sinon.fake.returns({ - [METADATA_STORE_KEY]: { - [DOMAINS.a.origin]: { - foo: 'bar', - }, - [DOMAINS.b.origin]: { - bar: 'baz', - }, - }, - }); - - permController.addDomainMetadata(DOMAINS.b.origin, { foo: 'bar' }); - - assert.ok( - permController.store.getState.called, - 'should have called store.getState', - ); - assert.equal( - permController._setDomainMetadata.getCalls().length, - 1, - 'should have called _setDomainMetadata once', - ); - assert.deepEqual(permController._setDomainMetadata.lastCall.args, [ - { - [DOMAINS.a.origin]: { - foo: 'bar', - }, - [DOMAINS.b.origin]: { - foo: 'bar', - bar: 'baz', - host: DOMAINS.b.host, - lastUpdated: 1, - }, - }, - ]); - }); - - it('pops metadata on add when too many origins are pending', function () { - sinon.spy(permController._pendingSiteMetadata, 'delete'); - - const mockMetadata = getMockMetadata(METADATA_CACHE_MAX_SIZE); - const expectedDeletedOrigin = Object.keys(mockMetadata)[0]; - - permController.store.getState = sinon.fake.returns({ - [METADATA_STORE_KEY]: { ...mockMetadata }, - }); - - // populate permController._pendingSiteMetadata, as though these origins - // were actually added - Object.keys(mockMetadata).forEach((origin) => { - permController._pendingSiteMetadata.add(origin); - }); - - permController.addDomainMetadata(DOMAINS.a.origin, { foo: 'bar' }); - - assert.ok( - permController.store.getState.called, - 'should have called store.getState', - ); - - const expectedMetadata = { - ...mockMetadata, - [DOMAINS.a.origin]: { - foo: 'bar', - host: DOMAINS.a.host, - lastUpdated: 1, - }, - }; - delete expectedMetadata[expectedDeletedOrigin]; - - assert.ok( - permController._pendingSiteMetadata.delete.calledOnceWithExactly( - expectedDeletedOrigin, - ), - 'should have called _pendingSiteMetadata.delete once', - ); - assert.equal( - permController._setDomainMetadata.getCalls().length, - 1, - 'should have called _setDomainMetadata once', - ); - assert.deepEqual(permController._setDomainMetadata.lastCall.args, [ - expectedMetadata, - ]); - }); - }); - - describe('_trimDomainMetadata', function () { - const permController = initPermController(); - - it('trims domain metadata for domains without permissions', function () { - const metadataArg = { - [DOMAINS.a.origin]: {}, - [DOMAINS.b.origin]: {}, - }; - - permController.permissions.getDomains = sinon.fake.returns({ - [DOMAINS.a.origin]: {}, - }); - - const metadataResult = permController._trimDomainMetadata(metadataArg); - - assert.equal( - permController.permissions.getDomains.getCalls().length, - 1, - 'should have called permissions.getDomains once', - ); - assert.deepEqual( - metadataResult, - { - [DOMAINS.a.origin]: {}, - }, - 'should have produced expected state', - ); - }); - }); - - describe('miscellanea and edge cases', function () { - it('requestAccountsPermissionWithId calls _requestPermissions and notifyAccounts', function (done) { - const notifications = initNotifications(); - const permController = initPermController(notifications); - const _requestPermissions = sinon - .stub(permController, '_requestPermissions') - .resolves(); - const notifyAccountsChanged = sinon - .stub(permController, 'notifyAccountsChanged') - .callsFake(() => { - assert.ok( - notifyAccountsChanged.calledOnceWithExactly('example.com', []), - ); - notifyAccountsChanged.restore(); - _requestPermissions.restore(); - done(); - }); - permController.requestAccountsPermissionWithId('example.com'); - }); - it('requestAccountsPermissionWithId calls _requestAccountsPermission with an explicit request ID', function (done) { - const permController = initPermController(); - const _requestPermissions = sinon - .stub(permController, '_requestPermissions') - .resolves(); - const onResolved = async () => { - assert.ok( - _requestPermissions.calledOnceWithExactly( - sinon.match.object.and(sinon.match.has('origin')), - { eth_accounts: {} }, - sinon.match.string.and(sinon.match.truthy), - ), - ); - _requestPermissions.restore(); - // eslint-disable-next-line no-use-before-define - notifyAccountsChanged.restore(); - done(); - }; - const notifyAccountsChanged = sinon - .stub(permController, 'notifyAccountsChanged') - .callsFake(onResolved); - permController.requestAccountsPermissionWithId('example.com'); - }); - }); -}); diff --git a/app/scripts/controllers/permissions/permissions-middleware.test.js b/app/scripts/controllers/permissions/permissions-middleware.test.js deleted file mode 100644 index dc027b1e7..000000000 --- a/app/scripts/controllers/permissions/permissions-middleware.test.js +++ /dev/null @@ -1,950 +0,0 @@ -import { strict as assert } from 'assert'; -import sinon from 'sinon'; - -import { - constants, - getters, - getPermControllerOpts, - getPermissionsMiddleware, -} from '../../../../test/mocks/permission-controller'; -import { - getUserApprovalPromise, - grantPermissions, -} from '../../../../test/helpers/permission-controller-helpers'; -import { METADATA_STORE_KEY } from './enums'; - -import { PermissionsController } from '.'; - -const { CAVEATS, ERRORS, PERMS, RPC_REQUESTS } = getters; - -const { ACCOUNTS, DOMAINS, PERM_NAMES } = constants; - -const initPermController = () => { - return new PermissionsController({ - ...getPermControllerOpts(), - }); -}; - -const createApprovalSpies = (permController) => { - sinon.spy(permController.approvals, '_add'); -}; - -const getNextApprovalId = (permController) => { - return permController.approvals._approvals.keys().next().value; -}; - -const validatePermission = (perm, name, origin, caveats) => { - assert.equal( - name, - perm.parentCapability, - 'should have expected permission name', - ); - assert.equal(origin, perm.invoker, 'should have expected permission origin'); - if (caveats) { - assert.deepEqual( - caveats, - perm.caveats, - 'should have expected permission caveats', - ); - } else { - assert.ok(!perm.caveats, 'should not have any caveats'); - } -}; - -describe('permissions middleware', function () { - describe('wallet_requestPermissions', function () { - let permController; - - beforeEach(function () { - permController = initPermController(); - permController.notifyAccountsChanged = sinon.fake(); - }); - - it('grants permissions on user approval', async function () { - createApprovalSpies(permController); - - const aMiddleware = getPermissionsMiddleware( - permController, - DOMAINS.a.origin, - ); - - const req = RPC_REQUESTS.requestPermission( - DOMAINS.a.origin, - PERM_NAMES.eth_accounts, - ); - const res = {}; - - const userApprovalPromise = getUserApprovalPromise(permController); - - const pendingApproval = assert.doesNotReject( - aMiddleware(req, res), - 'should not reject permissions request', - ); - - await userApprovalPromise; - - assert.ok( - permController.approvals._add.calledOnce, - 'should have added single approval request', - ); - - const id = getNextApprovalId(permController); - const approvedReq = PERMS.approvedRequest( - id, - PERMS.requests.eth_accounts(), - ); - - await permController.approvePermissionsRequest( - approvedReq, - ACCOUNTS.a.permitted, - ); - await pendingApproval; - - assert.ok( - res.result && !res.error, - 'response should have result and no error', - ); - - assert.equal( - res.result.length, - 1, - 'origin should have single approved permission', - ); - - validatePermission( - res.result[0], - PERM_NAMES.eth_accounts, - DOMAINS.a.origin, - CAVEATS.eth_accounts(ACCOUNTS.a.permitted), - ); - - const aAccounts = await permController.getAccounts(DOMAINS.a.origin); - assert.deepEqual( - aAccounts, - [ACCOUNTS.a.primary], - 'origin should have correct accounts', - ); - - assert.ok( - permController.notifyAccountsChanged.calledOnceWith( - DOMAINS.a.origin, - aAccounts, - ), - 'expected notification call should have been made', - ); - }); - - it('handles serial approved requests that overwrite existing permissions', async function () { - const aMiddleware = getPermissionsMiddleware( - permController, - DOMAINS.a.origin, - ); - - // create first request - - const req1 = RPC_REQUESTS.requestPermission( - DOMAINS.a.origin, - PERM_NAMES.eth_accounts, - ); - const res1 = {}; - - // send, approve, and validate first request - // note use of ACCOUNTS.a.permitted - - let userApprovalPromise = getUserApprovalPromise(permController); - - const pendingApproval1 = assert.doesNotReject( - aMiddleware(req1, res1), - 'should not reject permissions request', - ); - - await userApprovalPromise; - - const id1 = getNextApprovalId(permController); - const approvedReq1 = PERMS.approvedRequest( - id1, - PERMS.requests.eth_accounts(), - ); - - await permController.approvePermissionsRequest( - approvedReq1, - ACCOUNTS.a.permitted, - ); - await pendingApproval1; - - assert.ok( - res1.result && !res1.error, - 'response should have result and no error', - ); - - assert.equal( - res1.result.length, - 1, - 'origin should have single approved permission', - ); - - validatePermission( - res1.result[0], - PERM_NAMES.eth_accounts, - DOMAINS.a.origin, - CAVEATS.eth_accounts(ACCOUNTS.a.permitted), - ); - - const accounts1 = await permController.getAccounts(DOMAINS.a.origin); - assert.deepEqual( - accounts1, - [ACCOUNTS.a.primary], - 'origin should have correct accounts', - ); - - assert.ok( - permController.notifyAccountsChanged.calledOnceWith( - DOMAINS.a.origin, - accounts1, - ), - 'expected notification call should have been made', - ); - - // create second request - - const requestedPerms2 = { - ...PERMS.requests.eth_accounts(), - ...PERMS.requests.test_method(), - }; - - const req2 = RPC_REQUESTS.requestPermissions(DOMAINS.a.origin, { - ...requestedPerms2, - }); - const res2 = {}; - - // send, approve, and validate second request - // note use of ACCOUNTS.b.permitted - - userApprovalPromise = getUserApprovalPromise(permController); - - const pendingApproval2 = assert.doesNotReject( - aMiddleware(req2, res2), - 'should not reject permissions request', - ); - - await userApprovalPromise; - - const id2 = getNextApprovalId(permController); - const approvedReq2 = PERMS.approvedRequest(id2, { ...requestedPerms2 }); - - await permController.approvePermissionsRequest( - approvedReq2, - ACCOUNTS.b.permitted, - ); - await pendingApproval2; - - assert.ok( - res2.result && !res2.error, - 'response should have result and no error', - ); - - assert.equal( - res2.result.length, - 2, - 'origin should have single approved permission', - ); - - validatePermission( - res2.result[0], - PERM_NAMES.eth_accounts, - DOMAINS.a.origin, - CAVEATS.eth_accounts(ACCOUNTS.b.permitted), - ); - - validatePermission( - res2.result[1], - PERM_NAMES.test_method, - DOMAINS.a.origin, - ); - - const accounts2 = await permController.getAccounts(DOMAINS.a.origin); - assert.deepEqual( - accounts2, - [ACCOUNTS.b.primary], - 'origin should have correct accounts', - ); - - assert.equal( - permController.notifyAccountsChanged.callCount, - 2, - 'should have called notification method 2 times in total', - ); - - assert.ok( - permController.notifyAccountsChanged.lastCall.calledWith( - DOMAINS.a.origin, - accounts2, - ), - 'expected notification call should have been made', - ); - }); - - it('rejects permissions on user rejection', async function () { - createApprovalSpies(permController); - - const aMiddleware = getPermissionsMiddleware( - permController, - DOMAINS.a.origin, - ); - - const req = RPC_REQUESTS.requestPermission( - DOMAINS.a.origin, - PERM_NAMES.eth_accounts, - ); - const res = {}; - - const expectedError = ERRORS.rejectPermissionsRequest.rejection(); - - const userApprovalPromise = getUserApprovalPromise(permController); - - const requestRejection = assert.rejects( - aMiddleware(req, res), - expectedError, - 'request should be rejected with correct error', - ); - - await userApprovalPromise; - - assert.ok( - permController.approvals._add.calledOnce, - 'should have added single approval request', - ); - - const id = getNextApprovalId(permController); - - await permController.rejectPermissionsRequest(id); - await requestRejection; - - assert.ok( - !res.result && res.error && res.error.message === expectedError.message, - 'response should have expected error and no result', - ); - - const aAccounts = await permController.getAccounts(DOMAINS.a.origin); - assert.deepEqual( - aAccounts, - [], - 'origin should have have correct accounts', - ); - - assert.ok( - permController.notifyAccountsChanged.notCalled, - 'should not have called notification method', - ); - }); - - it('rejects requests with unknown permissions', async function () { - createApprovalSpies(permController); - - const aMiddleware = getPermissionsMiddleware( - permController, - DOMAINS.a.origin, - ); - - const req = RPC_REQUESTS.requestPermissions(DOMAINS.a.origin, { - ...PERMS.requests.does_not_exist(), - ...PERMS.requests.test_method(), - }); - const res = {}; - - const expectedError = ERRORS.rejectPermissionsRequest.methodNotFound( - PERM_NAMES.does_not_exist, - ); - - await assert.rejects( - aMiddleware(req, res), - expectedError, - 'request should be rejected with correct error', - ); - - assert.ok( - permController.approvals._add.notCalled, - 'no approval requests should have been added', - ); - - assert.ok( - !res.result && res.error && res.error.message === expectedError.message, - 'response should have expected error and no result', - ); - - assert.ok( - permController.notifyAccountsChanged.notCalled, - 'should not have called notification method', - ); - }); - - it('accepts only a single pending permissions request per origin', async function () { - createApprovalSpies(permController); - - // two middlewares for two origins - - const aMiddleware = getPermissionsMiddleware( - permController, - DOMAINS.a.origin, - ); - const bMiddleware = getPermissionsMiddleware( - permController, - DOMAINS.b.origin, - ); - - // create and start processing first request for first origin - - const reqA1 = RPC_REQUESTS.requestPermission( - DOMAINS.a.origin, - PERM_NAMES.test_method, - ); - const resA1 = {}; - - let userApprovalPromise = getUserApprovalPromise(permController); - - const requestApproval1 = assert.doesNotReject( - aMiddleware(reqA1, resA1), - 'should not reject permissions request', - ); - - await userApprovalPromise; - - // create and start processing first request for second origin - - const reqB1 = RPC_REQUESTS.requestPermission( - DOMAINS.b.origin, - PERM_NAMES.test_method, - ); - const resB1 = {}; - - userApprovalPromise = getUserApprovalPromise(permController); - - const requestApproval2 = assert.doesNotReject( - bMiddleware(reqB1, resB1), - 'should not reject permissions request', - ); - - await userApprovalPromise; - - assert.ok( - permController.approvals._add.calledTwice, - 'should have added two approval requests', - ); - - // create and start processing second request for first origin, - // which should throw - - const reqA2 = RPC_REQUESTS.requestPermission( - DOMAINS.a.origin, - PERM_NAMES.test_method, - ); - const resA2 = {}; - - userApprovalPromise = getUserApprovalPromise(permController); - - const expectedError = ERRORS.pendingApprovals.requestAlreadyPending( - DOMAINS.a.origin, - ); - - const requestApprovalFail = assert.rejects( - aMiddleware(reqA2, resA2), - expectedError, - 'request should be rejected with correct error', - ); - - await userApprovalPromise; - await requestApprovalFail; - - assert.ok( - !resA2.result && - resA2.error && - resA2.error.message === expectedError.message, - 'response should have expected error and no result', - ); - - assert.equal( - permController.approvals._add.callCount, - 3, - 'should have attempted to create three pending approvals', - ); - assert.equal( - permController.approvals._approvals.size, - 2, - 'should only have created two pending approvals', - ); - - // now, remaining pending requests should be approved without issue - - for (const id of permController.approvals._approvals.keys()) { - await permController.approvePermissionsRequest( - PERMS.approvedRequest(id, PERMS.requests.test_method()), - ); - } - await requestApproval1; - await requestApproval2; - - assert.ok( - resA1.result && !resA1.error, - 'first response should have result and no error', - ); - assert.equal( - resA1.result.length, - 1, - 'first origin should have single approved permission', - ); - - assert.ok( - resB1.result && !resB1.error, - 'second response should have result and no error', - ); - assert.equal( - resB1.result.length, - 1, - 'second origin should have single approved permission', - ); - }); - }); - - describe('restricted methods', function () { - let permController; - - beforeEach(function () { - permController = initPermController(); - }); - - it('prevents restricted method access for unpermitted domain', async function () { - const aMiddleware = getPermissionsMiddleware( - permController, - DOMAINS.a.origin, - ); - - const req = RPC_REQUESTS.test_method(DOMAINS.a.origin); - const res = {}; - - const expectedError = ERRORS.rpcCap.unauthorized(); - - await assert.rejects( - aMiddleware(req, res), - expectedError, - 'request should be rejected with correct error', - ); - - assert.ok( - !res.result && res.error && res.error.code === expectedError.code, - 'response should have expected error and no result', - ); - }); - - it('allows restricted method access for permitted domain', async function () { - const bMiddleware = getPermissionsMiddleware( - permController, - DOMAINS.b.origin, - ); - - grantPermissions( - permController, - DOMAINS.b.origin, - PERMS.finalizedRequests.test_method(), - ); - - const req = RPC_REQUESTS.test_method(DOMAINS.b.origin, true); - const res = {}; - - await assert.doesNotReject(bMiddleware(req, res), 'should not reject'); - - assert.ok( - res.result && res.result === 1, - 'response should have correct result', - ); - }); - }); - - describe('eth_accounts', function () { - let permController; - - beforeEach(function () { - permController = initPermController(); - }); - - it('returns empty array for non-permitted domain', async function () { - const aMiddleware = getPermissionsMiddleware( - permController, - DOMAINS.a.origin, - ); - - const req = RPC_REQUESTS.eth_accounts(DOMAINS.a.origin); - const res = {}; - - await assert.doesNotReject(aMiddleware(req, res), 'should not reject'); - - assert.ok( - res.result && !res.error, - 'response should have result and no error', - ); - assert.deepEqual(res.result, [], 'response should have correct result'); - }); - - it('returns correct accounts for permitted domain', async function () { - const aMiddleware = getPermissionsMiddleware( - permController, - DOMAINS.a.origin, - ); - - grantPermissions( - permController, - DOMAINS.a.origin, - PERMS.finalizedRequests.eth_accounts(ACCOUNTS.a.permitted), - ); - - const req = RPC_REQUESTS.eth_accounts(DOMAINS.a.origin); - const res = {}; - - await assert.doesNotReject(aMiddleware(req, res), 'should not reject'); - - assert.ok( - res.result && !res.error, - 'response should have result and no error', - ); - assert.deepEqual( - res.result, - [ACCOUNTS.a.primary], - 'response should have correct result', - ); - }); - }); - - describe('eth_requestAccounts', function () { - let permController; - - beforeEach(function () { - permController = initPermController(); - }); - - it('requests accounts for unpermitted origin, and approves on user approval', async function () { - createApprovalSpies(permController); - - const userApprovalPromise = getUserApprovalPromise(permController); - - const aMiddleware = getPermissionsMiddleware( - permController, - DOMAINS.a.origin, - ); - - const req = RPC_REQUESTS.eth_requestAccounts(DOMAINS.a.origin); - const res = {}; - - const pendingApproval = assert.doesNotReject( - aMiddleware(req, res), - 'should not reject permissions request', - ); - - await userApprovalPromise; - - assert.ok( - permController.approvals._add.calledOnce, - 'should have added single approval request', - ); - - const id = getNextApprovalId(permController); - const approvedReq = PERMS.approvedRequest( - id, - PERMS.requests.eth_accounts(), - ); - - await permController.approvePermissionsRequest( - approvedReq, - ACCOUNTS.a.permitted, - ); - - // wait for permission to be granted - await pendingApproval; - - const perms = permController.permissions.getPermissionsForDomain( - DOMAINS.a.origin, - ); - - assert.equal( - perms.length, - 1, - 'domain should have correct number of permissions', - ); - - validatePermission( - perms[0], - PERM_NAMES.eth_accounts, - DOMAINS.a.origin, - CAVEATS.eth_accounts(ACCOUNTS.a.permitted), - ); - - // we should also see the accounts on the response - assert.ok( - res.result && !res.error, - 'response should have result and no error', - ); - - assert.deepEqual( - res.result, - [ACCOUNTS.a.primary], - 'result should have correct accounts', - ); - - // we should also be able to get the accounts independently - const aAccounts = await permController.getAccounts(DOMAINS.a.origin); - assert.deepEqual( - aAccounts, - [ACCOUNTS.a.primary], - 'origin should have have correct accounts', - ); - }); - - it('requests accounts for unpermitted origin, and rejects on user rejection', async function () { - createApprovalSpies(permController); - - const userApprovalPromise = getUserApprovalPromise(permController); - - const aMiddleware = getPermissionsMiddleware( - permController, - DOMAINS.a.origin, - ); - - const req = RPC_REQUESTS.eth_requestAccounts(DOMAINS.a.origin); - const res = {}; - - const expectedError = ERRORS.rejectPermissionsRequest.rejection(); - - const requestRejection = assert.rejects( - aMiddleware(req, res), - expectedError, - 'request should be rejected with correct error', - ); - - await userApprovalPromise; - - assert.ok( - permController.approvals._add.calledOnce, - 'should have added single approval request', - ); - - const id = getNextApprovalId(permController); - - await permController.rejectPermissionsRequest(id); - await requestRejection; - - assert.ok( - !res.result && res.error && res.error.message === expectedError.message, - 'response should have expected error and no result', - ); - - const aAccounts = await permController.getAccounts(DOMAINS.a.origin); - assert.deepEqual( - aAccounts, - [], - 'origin should have have correct accounts', - ); - }); - - it('directly returns accounts for permitted domain', async function () { - const cMiddleware = getPermissionsMiddleware( - permController, - DOMAINS.c.origin, - ); - - grantPermissions( - permController, - DOMAINS.c.origin, - PERMS.finalizedRequests.eth_accounts(ACCOUNTS.c.permitted), - ); - - const req = RPC_REQUESTS.eth_requestAccounts(DOMAINS.c.origin); - const res = {}; - - await assert.doesNotReject(cMiddleware(req, res), 'should not reject'); - - assert.ok( - res.result && !res.error, - 'response should have result and no error', - ); - assert.deepEqual( - res.result, - [ACCOUNTS.c.primary], - 'response should have correct result', - ); - }); - - it('rejects new requests when request already pending', async function () { - let unlock; - const unlockPromise = new Promise((resolve) => { - unlock = resolve; - }); - - permController.getUnlockPromise = () => unlockPromise; - - const cMiddleware = getPermissionsMiddleware( - permController, - DOMAINS.c.origin, - ); - - grantPermissions( - permController, - DOMAINS.c.origin, - PERMS.finalizedRequests.eth_accounts(ACCOUNTS.c.permitted), - ); - - const req = RPC_REQUESTS.eth_requestAccounts(DOMAINS.c.origin); - const res = {}; - - // this will block until we resolve the unlock Promise - const requestApproval = assert.doesNotReject( - cMiddleware(req, res), - 'should not reject', - ); - - // this will reject because of the already pending request - await assert.rejects( - cMiddleware({ ...req }, {}), - ERRORS.eth_requestAccounts.requestAlreadyPending(DOMAINS.c.origin), - ); - - // now unlock and let through the first request - unlock(); - - await requestApproval; - - assert.ok( - res.result && !res.error, - 'response should have result and no error', - ); - assert.deepEqual( - res.result, - [ACCOUNTS.c.primary], - 'response should have correct result', - ); - }); - }); - - describe('metamask_sendDomainMetadata', function () { - let permController, clock; - - beforeEach(function () { - permController = initPermController(); - clock = sinon.useFakeTimers(1); - }); - - afterEach(function () { - clock.restore(); - }); - - it('records domain metadata', async function () { - const name = 'BAZ'; - - const cMiddleware = getPermissionsMiddleware( - permController, - DOMAINS.c.origin, - ); - - const req = RPC_REQUESTS.metamask_sendDomainMetadata( - DOMAINS.c.origin, - name, - ); - const res = {}; - - await assert.doesNotReject(cMiddleware(req, res), 'should not reject'); - - assert.ok(res.result, 'result should be true'); - - const metadataStore = permController.store.getState()[METADATA_STORE_KEY]; - - assert.deepEqual( - metadataStore, - { - [DOMAINS.c.origin]: { - name, - host: DOMAINS.c.host, - lastUpdated: 1, - }, - }, - 'metadata should have been added to store', - ); - }); - - it('records domain metadata and preserves extensionId', async function () { - const extensionId = 'fooExtension'; - - const name = 'BAZ'; - - const cMiddleware = getPermissionsMiddleware( - permController, - DOMAINS.c.origin, - extensionId, - ); - - const req = RPC_REQUESTS.metamask_sendDomainMetadata( - DOMAINS.c.origin, - name, - ); - const res = {}; - - await assert.doesNotReject(cMiddleware(req, res), 'should not reject'); - - assert.ok(res.result, 'result should be true'); - - const metadataStore = permController.store.getState()[METADATA_STORE_KEY]; - - assert.deepEqual( - metadataStore, - { [DOMAINS.c.origin]: { name, extensionId, lastUpdated: 1 } }, - 'metadata should have been added to store', - ); - }); - - it('should not record domain metadata if no name', async function () { - const name = null; - - const cMiddleware = getPermissionsMiddleware( - permController, - DOMAINS.c.origin, - ); - - const req = RPC_REQUESTS.metamask_sendDomainMetadata( - DOMAINS.c.origin, - name, - ); - const res = {}; - - await assert.doesNotReject(cMiddleware(req, res), 'should not reject'); - - assert.ok(res.result, 'result should be true'); - - const metadataStore = permController.store.getState()[METADATA_STORE_KEY]; - - assert.deepEqual( - metadataStore, - {}, - 'metadata should not have been added to store', - ); - }); - - it('should not record domain metadata if no metadata', async function () { - const cMiddleware = getPermissionsMiddleware( - permController, - DOMAINS.c.origin, - ); - - const req = RPC_REQUESTS.metamask_sendDomainMetadata(DOMAINS.c.origin); - delete req.domainMetadata; - const res = {}; - - await assert.doesNotReject(cMiddleware(req, res), 'should not reject'); - - assert.ok(res.result, 'result should be true'); - - const metadataStore = permController.store.getState()[METADATA_STORE_KEY]; - - assert.deepEqual( - metadataStore, - {}, - 'metadata should not have been added to store', - ); - }); - }); -}); diff --git a/app/scripts/controllers/permissions/permissionsMethodMiddleware.js b/app/scripts/controllers/permissions/permissionsMethodMiddleware.js deleted file mode 100644 index 10e5e1fa0..000000000 --- a/app/scripts/controllers/permissions/permissionsMethodMiddleware.js +++ /dev/null @@ -1,112 +0,0 @@ -import { createAsyncMiddleware } from 'json-rpc-engine'; -import { ethErrors } from 'eth-rpc-errors'; - -/** - * Create middleware for handling certain methods and preprocessing permissions requests. - */ -export default function createPermissionsMethodMiddleware({ - addDomainMetadata, - getAccounts, - getUnlockPromise, - hasPermission, - notifyAccountsChanged, - requestAccountsPermission, -}) { - let isProcessingRequestAccounts = false; - - return createAsyncMiddleware(async (req, res, next) => { - let responseHandler; - - switch (req.method) { - // Intercepting eth_accounts requests for backwards compatibility: - // The getAccounts call below wraps the rpc-cap middleware, and returns - // an empty array in case of errors (such as 4100:unauthorized) - case 'eth_accounts': { - res.result = await getAccounts(); - return; - } - - case 'eth_requestAccounts': { - if (isProcessingRequestAccounts) { - res.error = ethErrors.rpc.resourceUnavailable( - 'Already processing eth_requestAccounts. Please wait.', - ); - return; - } - - if (hasPermission('eth_accounts')) { - isProcessingRequestAccounts = true; - await getUnlockPromise(); - isProcessingRequestAccounts = false; - } - - // first, just try to get accounts - let accounts = await getAccounts(); - if (accounts.length > 0) { - res.result = accounts; - return; - } - - // if no accounts, request the accounts permission - try { - await requestAccountsPermission(); - } catch (err) { - res.error = err; - return; - } - - // get the accounts again - accounts = await getAccounts(); - /* istanbul ignore else: too hard to induce, see below comment */ - if (accounts.length > 0) { - res.result = accounts; - } else { - // this should never happen, because it should be caught in the - // above catch clause - res.error = ethErrors.rpc.internal( - 'Accounts unexpectedly unavailable. Please report this bug.', - ); - } - - return; - } - - // custom method for getting metadata from the requesting domain, - // sent automatically by the inpage provider when it's initialized - case 'metamask_sendDomainMetadata': { - if (typeof req.params?.name === 'string') { - addDomainMetadata(req.origin, req.params); - } - res.result = true; - return; - } - - // register return handler to send accountsChanged notification - case 'wallet_requestPermissions': { - if ('eth_accounts' in req.params?.[0]) { - responseHandler = async () => { - if (Array.isArray(res.result)) { - for (const permission of res.result) { - if (permission.parentCapability === 'eth_accounts') { - notifyAccountsChanged(await getAccounts()); - } - } - } - }; - } - break; - } - - default: - break; - } - - // when this promise resolves, the response is on its way back - // eslint-disable-next-line node/callback-return - await next(); - - if (responseHandler) { - responseHandler(); - } - }); -} diff --git a/app/scripts/controllers/permissions/restricted-methods.test.js b/app/scripts/controllers/permissions/restricted-methods.test.js deleted file mode 100644 index 237cce4b5..000000000 --- a/app/scripts/controllers/permissions/restricted-methods.test.js +++ /dev/null @@ -1,174 +0,0 @@ -import { strict as assert } from 'assert'; -import pify from 'pify'; - -import getRestrictedMethods from './restrictedMethods'; - -describe('restricted methods', function () { - describe('eth_accounts', function () { - it('should handle getKeyringAccounts error', async function () { - const restrictedMethods = getRestrictedMethods({ - getKeyringAccounts: async () => { - throw new Error('foo'); - }, - }); - const ethAccountsMethod = pify(restrictedMethods.eth_accounts.method); - - const res = {}; - const fooError = new Error('foo'); - await assert.rejects( - ethAccountsMethod(null, res, null), - fooError, - 'Should reject with expected error', - ); - - assert.deepEqual( - res, - { error: fooError }, - 'response should have expected error and no result', - ); - }); - - it('should handle missing identity for first account when sorting', async function () { - const restrictedMethods = getRestrictedMethods({ - getIdentities: () => { - return { '0x7e57e2': {} }; - }, - getKeyringAccounts: async () => ['0x7e57e2', '0x7e57e3'], - }); - const ethAccountsMethod = pify(restrictedMethods.eth_accounts.method); - - const res = {}; - await assert.rejects(ethAccountsMethod(null, res, null)); - assert.ok(res.error instanceof Error, 'result should have error'); - assert.deepEqual( - Object.keys(res), - ['error'], - 'result should only contain error', - ); - }); - - it('should handle missing identity for second account when sorting', async function () { - const restrictedMethods = getRestrictedMethods({ - getIdentities: () => { - return { '0x7e57e3': {} }; - }, - getKeyringAccounts: async () => ['0x7e57e2', '0x7e57e3'], - }); - const ethAccountsMethod = pify(restrictedMethods.eth_accounts.method); - - const res = {}; - await assert.rejects(ethAccountsMethod(null, res, null)); - assert.ok(res.error instanceof Error, 'result should have error'); - assert.deepEqual( - Object.keys(res), - ['error'], - 'result should only contain error', - ); - }); - - it('should return accounts in keyring order when none are selected', async function () { - const keyringAccounts = ['0x7e57e2', '0x7e57e3', '0x7e57e4', '0x7e57e5']; - const restrictedMethods = getRestrictedMethods({ - getIdentities: () => { - return keyringAccounts.reduce((identities, address) => { - identities[address] = {}; - return identities; - }, {}); - }, - getKeyringAccounts: async () => [...keyringAccounts], - }); - const ethAccountsMethod = pify(restrictedMethods.eth_accounts.method); - - const res = {}; - await ethAccountsMethod(null, res, null); - assert.deepEqual( - res, - { result: keyringAccounts }, - 'should return accounts in correct order', - ); - }); - - it('should return accounts in keyring order when all have same last selected time', async function () { - const keyringAccounts = ['0x7e57e2', '0x7e57e3', '0x7e57e4', '0x7e57e5']; - const restrictedMethods = getRestrictedMethods({ - getIdentities: () => { - return keyringAccounts.reduce((identities, address) => { - identities[address] = { lastSelected: 1000 }; - return identities; - }, {}); - }, - getKeyringAccounts: async () => [...keyringAccounts], - }); - const ethAccountsMethod = pify(restrictedMethods.eth_accounts.method); - - const res = {}; - await ethAccountsMethod(null, res, null); - assert.deepEqual( - res, - { result: keyringAccounts }, - 'should return accounts in correct order', - ); - }); - - it('should return accounts sorted by last selected (descending)', async function () { - const keyringAccounts = ['0x7e57e2', '0x7e57e3', '0x7e57e4', '0x7e57e5']; - const expectedResult = keyringAccounts.slice().reverse(); - const restrictedMethods = getRestrictedMethods({ - getIdentities: () => { - return keyringAccounts.reduce((identities, address, index) => { - identities[address] = { lastSelected: index * 1000 }; - return identities; - }, {}); - }, - getKeyringAccounts: async () => [...keyringAccounts], - }); - const ethAccountsMethod = pify(restrictedMethods.eth_accounts.method); - - const res = {}; - await ethAccountsMethod(null, res, null); - assert.deepEqual( - res, - { result: expectedResult }, - 'should return accounts in correct order', - ); - }); - - it('should return accounts sorted by last selected (descending) with unselected accounts last, in keyring order', async function () { - const keyringAccounts = [ - '0x7e57e2', - '0x7e57e3', - '0x7e57e4', - '0x7e57e5', - '0x7e57e6', - ]; - const expectedResult = [ - '0x7e57e4', - '0x7e57e2', - '0x7e57e3', - '0x7e57e5', - '0x7e57e6', - ]; - const restrictedMethods = getRestrictedMethods({ - getIdentities: () => { - return { - '0x7e57e2': { lastSelected: 1000 }, - '0x7e57e3': {}, - '0x7e57e4': { lastSelected: 2000 }, - '0x7e57e5': {}, - '0x7e57e6': {}, - }; - }, - getKeyringAccounts: async () => [...keyringAccounts], - }); - const ethAccountsMethod = pify(restrictedMethods.eth_accounts.method); - - const res = {}; - await ethAccountsMethod(null, res, null); - assert.deepEqual( - res, - { result: expectedResult }, - 'should return accounts in correct order', - ); - }); - }); -}); diff --git a/app/scripts/controllers/permissions/restrictedMethods.js b/app/scripts/controllers/permissions/restrictedMethods.js deleted file mode 100644 index 05e42cabc..000000000 --- a/app/scripts/controllers/permissions/restrictedMethods.js +++ /dev/null @@ -1,40 +0,0 @@ -export default function getRestrictedMethods({ - getIdentities, - getKeyringAccounts, -}) { - return { - eth_accounts: { - method: async (_, res, __, end) => { - try { - const accounts = await getKeyringAccounts(); - const identities = getIdentities(); - res.result = accounts.sort((firstAddress, secondAddress) => { - if (!identities[firstAddress]) { - throw new Error(`Missing identity for address ${firstAddress}`); - } else if (!identities[secondAddress]) { - throw new Error(`Missing identity for address ${secondAddress}`); - } else if ( - identities[firstAddress].lastSelected === - identities[secondAddress].lastSelected - ) { - return 0; - } else if (identities[firstAddress].lastSelected === undefined) { - return 1; - } else if (identities[secondAddress].lastSelected === undefined) { - return -1; - } - - return ( - identities[secondAddress].lastSelected - - identities[firstAddress].lastSelected - ); - }); - end(); - } catch (err) { - res.error = err; - end(err); - } - }, - }, - }; -} diff --git a/app/scripts/controllers/permissions/selectors.js b/app/scripts/controllers/permissions/selectors.js new file mode 100644 index 000000000..0a9c1cacf --- /dev/null +++ b/app/scripts/controllers/permissions/selectors.js @@ -0,0 +1,84 @@ +import { createSelector } from 'reselect'; +import { CaveatTypes } from '../../../../shared/constants/permissions'; + +/** + * This file contains selectors for PermissionController selector event + * subscriptions, used to detect whenever a subject's accounts change so that + * we can notify the subject via the `accountsChanged` provider event. + */ + +/** + * @param {Record>} state - The + * PermissionController state. + * @returns {Record} The PermissionController subjects. + */ +const getSubjects = (state) => state.subjects; + +/** + * Get the permitted accounts for each subject, keyed by origin. + * The values of the returned map are immutable values from the + * PermissionController state. + * + * @returns {Map} The current origin:accounts[] map. + */ +export const getPermittedAccountsByOrigin = createSelector( + getSubjects, + (subjects) => { + return Object.values(subjects).reduce((originToAccountsMap, subject) => { + const caveat = subject.permissions?.eth_accounts?.caveats.find( + ({ type }) => type === CaveatTypes.restrictReturnedAccounts, + ); + + if (caveat) { + originToAccountsMap.set(subject.origin, caveat.value); + } + return originToAccountsMap; + }, new Map()); + }, +); + +/** + * Given the current and previous exposed accounts for each PermissionController + * subject, returns a new map containing all accounts that have changed. + * The values of each map must be immutable values directly from the + * PermissionController state, or an empty array instantiated in this + * function. + * + * @param {Map} newAccountsMap - The new origin:accounts[] map. + * @param {Map} [previousAccountsMap] - The previous origin:accounts[] map. + * @returns {Map} The origin:accounts[] map of changed accounts. + */ +export const getChangedAccounts = (newAccountsMap, previousAccountsMap) => { + if (previousAccountsMap === undefined) { + return newAccountsMap; + } + + const changedAccounts = new Map(); + if (newAccountsMap === previousAccountsMap) { + return changedAccounts; + } + + const newOrigins = new Set([...newAccountsMap.keys()]); + + for (const origin of previousAccountsMap.keys()) { + const newAccounts = newAccountsMap.get(origin) ?? []; + + // The values of these maps are references to immutable values, which is why + // a strict equality check is enough for diffing. The values are either from + // PermissionController state, or an empty array initialized in the previous + // call to this function. `newAccountsMap` will never contain any empty + // arrays. + if (previousAccountsMap.get(origin) !== newAccounts) { + changedAccounts.set(origin, newAccounts); + } + + newOrigins.delete(origin); + } + + // By now, newOrigins is either empty or contains some number of previously + // unencountered origins, and all of their accounts have "changed". + for (const origin of newOrigins.keys()) { + changedAccounts.set(origin, newAccountsMap.get(origin)); + } + return changedAccounts; +}; diff --git a/app/scripts/controllers/permissions/selectors.test.js b/app/scripts/controllers/permissions/selectors.test.js new file mode 100644 index 000000000..a32eabf77 --- /dev/null +++ b/app/scripts/controllers/permissions/selectors.test.js @@ -0,0 +1,116 @@ +import { cloneDeep } from 'lodash'; +import { getChangedAccounts, getPermittedAccountsByOrigin } from './selectors'; + +describe('PermissionController selectors', () => { + describe('getChangedAccounts', () => { + it('returns the new value if the previous value is undefined', () => { + const newAccounts = new Map([['foo.bar', ['0x1']]]); + expect(getChangedAccounts(newAccounts)).toBe(newAccounts); + }); + + it('returns an empty map if the new and previous values are the same', () => { + const newAccounts = new Map([['foo.bar', ['0x1']]]); + expect(getChangedAccounts(newAccounts, newAccounts)).toStrictEqual( + new Map(), + ); + }); + + it('returns a new map of the changed accounts if the new and previous values differ', () => { + // We set this on the new and previous value under the key 'foo.bar' to + // check that identical values are excluded. + const identicalValue = ['0x1']; + + const previousAccounts = new Map([ + ['bar.baz', ['0x1']], // included: different accounts + ['fizz.buzz', ['0x1']], // included: removed in new value + ]); + previousAccounts.set('foo.bar', identicalValue); + + const newAccounts = new Map([ + ['bar.baz', ['0x1', '0x2']], // included: different accounts + ['baz.fizz', ['0x3']], // included: brand new + ]); + newAccounts.set('foo.bar', identicalValue); + + expect(getChangedAccounts(newAccounts, previousAccounts)).toStrictEqual( + new Map([ + ['bar.baz', ['0x1', '0x2']], + ['fizz.buzz', []], + ['baz.fizz', ['0x3']], + ]), + ); + }); + }); + + describe('getPermittedAccountsByOrigin', () => { + it('memoizes and gets permitted accounts by origin', () => { + const state1 = { + subjects: { + 'foo.bar': { + origin: 'foo.bar', + permissions: { + eth_accounts: { + caveats: [{ type: 'restrictReturnedAccounts', value: ['0x1'] }], + }, + }, + }, + 'bar.baz': { + origin: 'bar.baz', + permissions: { + eth_accounts: { + caveats: [{ type: 'restrictReturnedAccounts', value: ['0x2'] }], + }, + }, + }, + 'baz.bizz': { + origin: 'baz.fizz', + permissions: { + eth_accounts: { + caveats: [ + { type: 'restrictReturnedAccounts', value: ['0x1', '0x2'] }, + ], + }, + }, + }, + 'no.accounts': { + // we shouldn't see this in the result + permissions: { + foobar: {}, + }, + }, + }, + }; + + const expected1 = new Map([ + ['foo.bar', ['0x1']], + ['bar.baz', ['0x2']], + ['baz.fizz', ['0x1', '0x2']], + ]); + + const selected1 = getPermittedAccountsByOrigin(state1); + + expect(selected1).toStrictEqual(expected1); + // The selector should return the memoized value if state.subjects is + // the same object + expect(selected1).toBe(getPermittedAccountsByOrigin(state1)); + + // If we mutate the state, the selector return value should be different + // from the first. + const state2 = cloneDeep(state1); + delete state2.subjects['foo.bar']; + + const expected2 = new Map([ + ['bar.baz', ['0x2']], + ['baz.fizz', ['0x1', '0x2']], + ]); + + const selected2 = getPermittedAccountsByOrigin(state2); + + expect(selected2).toStrictEqual(expected2); + expect(selected2).not.toBe(selected1); + // Since we didn't mutate the state at this point, the value should once + // again be the memoized. + expect(selected2).toBe(getPermittedAccountsByOrigin(state2)); + }); + }); +}); diff --git a/app/scripts/controllers/permissions/specifications.js b/app/scripts/controllers/permissions/specifications.js new file mode 100644 index 000000000..4db3bf7ea --- /dev/null +++ b/app/scripts/controllers/permissions/specifications.js @@ -0,0 +1,258 @@ +import { constructPermission } from '@metamask/snap-controllers'; +import { + CaveatTypes, + RestrictedMethods, +} from '../../../../shared/constants/permissions'; + +/** + * This file contains the specifications of the permissions and caveats + * that are recognized by our permission system. See the PermissionController + * README in @metamask/snap-controllers for details. + */ + +/** + * The "keys" of all of permissions recognized by the PermissionController. + * Permission keys and names have distinct meanings in the permission system. + */ +const PermissionKeys = Object.freeze({ + ...RestrictedMethods, +}); + +/** + * Factory functions for all caveat types recognized by the + * PermissionController. + */ +const CaveatFactories = Object.freeze({ + [CaveatTypes.restrictReturnedAccounts]: (accounts) => { + return { type: CaveatTypes.restrictReturnedAccounts, value: accounts }; + }, +}); + +/** + * A PreferencesController identity object. + * + * @typedef {Object} Identity + * @property {string} address - The address of the identity. + * @property {string} name - The name of the identity. + * @property {number} [lastSelected] - Unix timestamp of when the identity was + * last selected in the UI. + */ + +/** + * Gets the specifications for all caveats that will be recognized by the + * PermissionController. + * + * @param {{ + * getIdentities: () => Record, + * }} options - Options bag. + */ +export const getCaveatSpecifications = ({ getIdentities }) => { + return { + [CaveatTypes.restrictReturnedAccounts]: { + type: CaveatTypes.restrictReturnedAccounts, + + decorator: (method, caveat) => { + return async (args) => { + const result = await method(args); + return result + .filter((account) => caveat.value.includes(account)) + .slice(0, 1); + }; + }, + + validator: (caveat, _origin, _target) => + validateCaveatAccounts(caveat.value, getIdentities), + }, + }; +}; + +/** + * Gets the specifications for all permissions that will be recognized by the + * PermissionController. + * + * @param {{ + * getAllAccounts: () => Promise, + * getIdentities: () => Record, + * }} options - Options bag. + * @param options.getAllAccounts - A function that returns all Ethereum accounts + * in the current MetaMask instance. + * @param options.getIdentities - A function that returns the + * `PreferencesController` identity objects for all Ethereum accounts in the + * current MetaMask instance. + */ +export const getPermissionSpecifications = ({ + getAllAccounts, + getIdentities, +}) => { + return { + [PermissionKeys.eth_accounts]: { + targetKey: PermissionKeys.eth_accounts, + allowedCaveats: [CaveatTypes.restrictReturnedAccounts], + + factory: (permissionOptions, requestData) => { + if (Array.isArray(permissionOptions.caveats)) { + throw new Error( + `${PermissionKeys.eth_accounts} error: Received unexpected caveats. Any permitted caveats will be added automatically.`, + ); + } + + // This value will be further validated as part of the caveat. + if (!requestData.approvedAccounts) { + throw new Error( + `${PermissionKeys.eth_accounts} error: No approved accounts specified.`, + ); + } + + return constructPermission({ + ...permissionOptions, + caveats: [ + CaveatFactories[CaveatTypes.restrictReturnedAccounts]( + requestData.approvedAccounts, + ), + ], + }); + }, + + methodImplementation: async (_args) => { + const accounts = await getAllAccounts(); + const identities = getIdentities(); + + return accounts.sort((firstAddress, secondAddress) => { + if (!identities[firstAddress]) { + throw new Error(`Missing identity for address: "${firstAddress}".`); + } else if (!identities[secondAddress]) { + throw new Error( + `Missing identity for address: "${secondAddress}".`, + ); + } else if ( + identities[firstAddress].lastSelected === + identities[secondAddress].lastSelected + ) { + return 0; + } else if (identities[firstAddress].lastSelected === undefined) { + return 1; + } else if (identities[secondAddress].lastSelected === undefined) { + return -1; + } + + return ( + identities[secondAddress].lastSelected - + identities[firstAddress].lastSelected + ); + }); + }, + + validator: (permission, _origin, _target) => { + const { caveats } = permission; + if ( + !caveats || + caveats.length !== 1 || + caveats[0].type !== CaveatTypes.restrictReturnedAccounts + ) { + throw new Error( + `${PermissionKeys.eth_accounts} error: Invalid caveats. There must be a single caveat of type "${CaveatTypes.restrictReturnedAccounts}".`, + ); + } + }, + }, + }; +}; + +/** + * Validates the accounts associated with a caveat. In essence, ensures that + * the accounts value is an array of non-empty strings, and that each string + * corresponds to a PreferencesController identity. + * + * @param {string[]} accounts - The accounts associated with the caveat. + * @param {() => Record} getIdentities - Gets all + * PreferencesController identities. + */ +function validateCaveatAccounts(accounts, getIdentities) { + if (!Array.isArray(accounts) || accounts.length === 0) { + throw new Error( + `${PermissionKeys.eth_accounts} error: Expected non-empty array of Ethereum addresses.`, + ); + } + + const identities = getIdentities(); + accounts.forEach((address) => { + if (!address || typeof address !== 'string') { + throw new Error( + `${PermissionKeys.eth_accounts} error: Expected an array of Ethereum addresses. Received: "${address}".`, + ); + } + + if (!identities[address]) { + throw new Error( + `${PermissionKeys.eth_accounts} error: Received unrecognized address: "${address}".`, + ); + } + }); +} + +/** + * All unrestricted methods recognized by the PermissionController. + * Unrestricted methods are ignored by the permission system, but every + * JSON-RPC request seen by the permission system must correspond to a + * restricted or unrestricted method, or the request will be rejected with a + * "method not found" error. + */ +export const unrestrictedMethods = Object.freeze([ + 'eth_blockNumber', + 'eth_call', + 'eth_chainId', + 'eth_coinbase', + 'eth_decrypt', + 'eth_estimateGas', + 'eth_feeHistory', + 'eth_gasPrice', + 'eth_getBalance', + 'eth_getBlockByHash', + 'eth_getBlockByNumber', + 'eth_getBlockTransactionCountByHash', + 'eth_getBlockTransactionCountByNumber', + 'eth_getCode', + 'eth_getEncryptionPublicKey', + 'eth_getFilterChanges', + 'eth_getFilterLogs', + 'eth_getLogs', + 'eth_getProof', + 'eth_getStorageAt', + 'eth_getTransactionByBlockHashAndIndex', + 'eth_getTransactionByBlockNumberAndIndex', + 'eth_getTransactionByHash', + 'eth_getTransactionCount', + 'eth_getTransactionReceipt', + 'eth_getUncleByBlockHashAndIndex', + 'eth_getUncleByBlockNumberAndIndex', + 'eth_getUncleCountByBlockHash', + 'eth_getUncleCountByBlockNumber', + 'eth_getWork', + 'eth_hashrate', + 'eth_mining', + 'eth_newBlockFilter', + 'eth_newFilter', + 'eth_newPendingTransactionFilter', + 'eth_protocolVersion', + 'eth_sendRawTransaction', + 'eth_sendTransaction', + 'eth_sign', + 'eth_signTypedData', + 'eth_signTypedData_v1', + 'eth_signTypedData_v3', + 'eth_signTypedData_v4', + 'eth_submitHashrate', + 'eth_submitWork', + 'eth_syncing', + 'eth_uninstallFilter', + 'metamask_getProviderState', + 'metamask_watchAsset', + 'net_listening', + 'net_peerCount', + 'net_version', + 'personal_ecRecover', + 'personal_sign', + 'wallet_watchAsset', + 'web3_clientVersion', + 'web3_sha3', +]); diff --git a/app/scripts/controllers/permissions/specifications.test.js b/app/scripts/controllers/permissions/specifications.test.js new file mode 100644 index 000000000..596172b95 --- /dev/null +++ b/app/scripts/controllers/permissions/specifications.test.js @@ -0,0 +1,340 @@ +import { + CaveatTypes, + RestrictedMethods, +} from '../../../../shared/constants/permissions'; +import { + getCaveatSpecifications, + getPermissionSpecifications, + unrestrictedMethods, +} from './specifications'; + +// Note: This causes Date.now() to return the number 1. +jest.useFakeTimers('modern').setSystemTime(1); + +describe('PermissionController specifications', () => { + describe('caveat specifications', () => { + it('getCaveatSpecifications returns the expected specifications object', () => { + const caveatSpecifications = getCaveatSpecifications({}); + expect(Object.keys(caveatSpecifications)).toHaveLength(1); + expect( + caveatSpecifications[CaveatTypes.restrictReturnedAccounts].type, + ).toStrictEqual(CaveatTypes.restrictReturnedAccounts); + }); + + describe('restrictReturnedAccounts', () => { + describe('decorator', () => { + it('returns the first array member included in the caveat value', async () => { + const getIdentities = jest.fn(); + const { decorator } = getCaveatSpecifications({ getIdentities })[ + CaveatTypes.restrictReturnedAccounts + ]; + + const method = async () => ['0x1', '0x2', '0x3']; + const caveat = { + type: CaveatTypes.restrictReturnedAccounts, + value: ['0x1', '0x2'], + }; + const decorated = decorator(method, caveat); + expect(await decorated()).toStrictEqual(['0x1']); + }); + + it('returns an empty array if no array members are included in the caveat value', async () => { + const getIdentities = jest.fn(); + const { decorator } = getCaveatSpecifications({ getIdentities })[ + CaveatTypes.restrictReturnedAccounts + ]; + + const method = async () => ['0x1', '0x2', '0x3']; + const caveat = { + type: CaveatTypes.restrictReturnedAccounts, + value: ['0x5'], + }; + const decorated = decorator(method, caveat); + expect(await decorated()).toStrictEqual([]); + }); + + it('returns an empty array if the method result is an empty array', async () => { + const getIdentities = jest.fn(); + const { decorator } = getCaveatSpecifications({ getIdentities })[ + CaveatTypes.restrictReturnedAccounts + ]; + + const method = async () => []; + const caveat = { + type: CaveatTypes.restrictReturnedAccounts, + value: ['0x1', '0x2'], + }; + const decorated = decorator(method, caveat); + expect(await decorated()).toStrictEqual([]); + }); + }); + + describe('validator', () => { + it('rejects invalid array values', () => { + const getIdentities = jest.fn(); + const { validator } = getCaveatSpecifications({ getIdentities })[ + CaveatTypes.restrictReturnedAccounts + ]; + + [null, 'foo', {}, []].forEach((invalidValue) => { + expect(() => validator({ value: invalidValue })).toThrow( + /Expected non-empty array of Ethereum addresses\.$/u, + ); + }); + }); + + it('rejects falsy or non-string addresses', () => { + const getIdentities = jest.fn(); + const { validator } = getCaveatSpecifications({ getIdentities })[ + CaveatTypes.restrictReturnedAccounts + ]; + + [[{}], [[]], [null], ['']].forEach((invalidValue) => { + expect(() => validator({ value: invalidValue })).toThrow( + /Expected an array of Ethereum addresses. Received:/u, + ); + }); + }); + + it('rejects addresses that have no corresponding identity', () => { + const getIdentities = jest.fn().mockImplementationOnce(() => { + return { + '0x1': true, + '0x3': true, + }; + }); + + const { validator } = getCaveatSpecifications({ getIdentities })[ + CaveatTypes.restrictReturnedAccounts + ]; + + expect(() => validator({ value: ['0x1', '0x2', '0x3'] })).toThrow( + /Received unrecognized address:/u, + ); + }); + }); + }); + }); + + describe('permission specifications', () => { + it('getPermissionSpecifications returns the expected specifications object', () => { + const permissionSpecifications = getPermissionSpecifications({}); + expect(Object.keys(permissionSpecifications)).toHaveLength(1); + expect( + permissionSpecifications[RestrictedMethods.eth_accounts].targetKey, + ).toStrictEqual(RestrictedMethods.eth_accounts); + }); + + describe('eth_accounts', () => { + describe('factory', () => { + it('constructs a valid eth_accounts permission', () => { + const getIdentities = jest.fn(); + const getAllAccounts = jest.fn(); + const { factory } = getPermissionSpecifications({ + getIdentities, + getAllAccounts, + })[RestrictedMethods.eth_accounts]; + + expect( + factory( + { invoker: 'foo.bar', target: 'eth_accounts' }, + { approvedAccounts: ['0x1'] }, + ), + ).toStrictEqual({ + caveats: [ + { + type: CaveatTypes.restrictReturnedAccounts, + value: ['0x1'], + }, + ], + date: 1, + id: expect.any(String), + invoker: 'foo.bar', + parentCapability: 'eth_accounts', + }); + }); + + it('throws an error if no approvedAccounts are specified', () => { + const getIdentities = jest.fn(); + const getAllAccounts = jest.fn(); + const { factory } = getPermissionSpecifications({ + getIdentities, + getAllAccounts, + })[RestrictedMethods.eth_accounts]; + + expect(() => + factory( + { invoker: 'foo.bar', target: 'eth_accounts' }, + {}, // no approvedAccounts + ), + ).toThrow(/No approved accounts specified\.$/u); + }); + + it('throws an error if any caveats are specified directly', () => { + const getIdentities = jest.fn(); + const getAllAccounts = jest.fn(); + const { factory } = getPermissionSpecifications({ + getIdentities, + getAllAccounts, + })[RestrictedMethods.eth_accounts]; + + expect(() => + factory( + { + caveats: [ + { + type: CaveatTypes.restrictReturnedAccounts, + value: ['0x1', '0x2'], + }, + ], + invoker: 'foo.bar', + target: 'eth_accounts', + }, + { approvedAccounts: ['0x1'] }, + ), + ).toThrow(/Received unexpected caveats./u); + }); + }); + + describe('methodImplementation', () => { + it('returns the keyring accounts in lastSelected order', async () => { + const getIdentities = jest.fn().mockImplementationOnce(() => { + return { + '0x1': { + lastSelected: 1, + }, + '0x2': {}, + '0x3': { + lastSelected: 3, + }, + '0x4': { + lastSelected: 3, + }, + }; + }); + const getAllAccounts = jest + .fn() + .mockImplementationOnce(() => ['0x1', '0x2', '0x3', '0x4']); + + const { methodImplementation } = getPermissionSpecifications({ + getIdentities, + getAllAccounts, + })[RestrictedMethods.eth_accounts]; + + expect(await methodImplementation()).toStrictEqual([ + '0x3', + '0x4', + '0x1', + '0x2', + ]); + }); + + it('throws if a keyring account is missing an address (case 1)', async () => { + const getIdentities = jest.fn().mockImplementationOnce(() => { + return { + '0x2': { + lastSelected: 3, + }, + '0x3': { + lastSelected: 3, + }, + }; + }); + const getAllAccounts = jest + .fn() + .mockImplementationOnce(() => ['0x1', '0x2', '0x3']); + + const { methodImplementation } = getPermissionSpecifications({ + getIdentities, + getAllAccounts, + })[RestrictedMethods.eth_accounts]; + + await expect(() => methodImplementation()).rejects.toThrow( + 'Missing identity for address: "0x1".', + ); + }); + + it('throws if a keyring account is missing an address (case 2)', async () => { + const getIdentities = jest.fn().mockImplementationOnce(() => { + return { + '0x1': { + lastSelected: 1, + }, + '0x3': { + lastSelected: 3, + }, + }; + }); + const getAllAccounts = jest + .fn() + .mockImplementationOnce(() => ['0x1', '0x2', '0x3']); + + const { methodImplementation } = getPermissionSpecifications({ + getIdentities, + getAllAccounts, + })[RestrictedMethods.eth_accounts]; + + await expect(() => methodImplementation()).rejects.toThrow( + 'Missing identity for address: "0x2".', + ); + }); + }); + + describe('validator', () => { + it('accepts valid permissions', () => { + const getIdentities = jest.fn(); + const getAllAccounts = jest.fn(); + const { validator } = getPermissionSpecifications({ + getIdentities, + getAllAccounts, + })[RestrictedMethods.eth_accounts]; + + expect(() => + validator({ + caveats: [ + { + type: CaveatTypes.restrictReturnedAccounts, + value: ['0x1', '0x2'], + }, + ], + date: 1, + id: expect.any(String), + invoker: 'foo.bar', + parentCapability: 'eth_accounts', + }), + ).not.toThrow(); + }); + + it('rejects invalid caveats', () => { + const getIdentities = jest.fn(); + const getAllAccounts = jest.fn(); + const { validator } = getPermissionSpecifications({ + getIdentities, + getAllAccounts, + })[RestrictedMethods.eth_accounts]; + + [null, [], [1, 2], [{ type: 'foobar' }]].forEach( + (invalidCaveatsValue) => { + expect(() => + validator({ + caveats: invalidCaveatsValue, + date: 1, + id: expect.any(String), + invoker: 'foo.bar', + parentCapability: 'eth_accounts', + }), + ).toThrow(/Invalid caveats./u); + }, + ); + }); + }); + }); + }); + + describe('unrestricted methods', () => { + it('defines the unrestricted methods', () => { + expect(Array.isArray(unrestrictedMethods)).toBe(true); + expect(Object.isFrozen(unrestrictedMethods)).toBe(true); + }); + }); +}); diff --git a/app/scripts/controllers/preferences.js b/app/scripts/controllers/preferences.js index 738432e73..120056708 100644 --- a/app/scripts/controllers/preferences.js +++ b/app/scripts/controllers/preferences.js @@ -3,7 +3,10 @@ import { ObservableStore } from '@metamask/obs-store'; import { normalize as normalizeAddress } from 'eth-sig-util'; import { ethers } from 'ethers'; import log from 'loglevel'; -import { NETWORK_TYPE_TO_ID_MAP } from '../../../shared/constants/network'; +import { + IPFS_DEFAULT_GATEWAY_URL, + NETWORK_TYPE_TO_ID_MAP, +} from '../../../shared/constants/network'; import { isPrefixedFormattedHexString } from '../../../shared/modules/network.utils'; import { LEDGER_TRANSPORT_TYPES } from '../../../shared/constants/hardware-wallets'; import { NETWORK_EVENTS } from './network'; @@ -61,7 +64,7 @@ export default class PreferencesController { hideZeroBalanceTokens: false, }, // ENS decentralized website resolution - ipfsGateway: 'dweb.link', + ipfsGateway: IPFS_DEFAULT_GATEWAY_URL, infuraBlocked: null, ledgerTransportType: window.navigator.hid ? LEDGER_TRANSPORT_TYPES.WEBHID diff --git a/app/scripts/controllers/preferences.test.js b/app/scripts/controllers/preferences.test.js index 3afaba4ea..d6c8c59bf 100644 --- a/app/scripts/controllers/preferences.test.js +++ b/app/scripts/controllers/preferences.test.js @@ -31,6 +31,7 @@ describe('preferences controller', function () { .callsFake(() => ({ type: 'mainnet' })); preferencesController = new PreferencesController({ + initLangCode: 'en_US', migrateAddressBookState, network, provider, @@ -41,6 +42,30 @@ describe('preferences controller', function () { sinon.restore(); }); + describe('useBlockie', function () { + it('defaults useBlockie to false', function () { + assert.equal(preferencesController.store.getState().useBlockie, false); + }); + + it('setUseBlockie to true', function () { + preferencesController.setUseBlockie(true); + assert.equal(preferencesController.store.getState().useBlockie, true); + }); + }); + + describe('setCurrentLocale', function () { + it('checks the default currentLocale', function () { + const { currentLocale } = preferencesController.store.getState(); + assert.equal(currentLocale, 'en_US'); + }); + + it('sets current locale in preferences controller', function () { + preferencesController.setCurrentLocale('ja'); + const { currentLocale } = preferencesController.store.getState(); + assert.equal(currentLocale, 'ja'); + }); + }); + describe('setAddresses', function () { it('should keep a map of addresses to names and addresses in the store', function () { preferencesController.setAddresses(['0xda22le', '0x7e57e2']); diff --git a/app/scripts/controllers/transactions/index.js b/app/scripts/controllers/transactions/index.js index c2655a6d7..b0ca98072 100644 --- a/app/scripts/controllers/transactions/index.js +++ b/app/scripts/controllers/transactions/index.js @@ -977,7 +977,7 @@ export default class TransactionController extends EventEmitter { * @param {number} txId - The tx's ID * @returns {Promise} */ - async confirmTransaction(txId, txReceipt, baseFeePerGas) { + async confirmTransaction(txId, txReceipt, baseFeePerGas, blockTimestamp) { // get the txReceipt before marking the transaction confirmed // to ensure the receipt is gotten before the ui revives the tx const txMeta = this.txStateManager.getTransaction(txId); @@ -1002,6 +1002,9 @@ export default class TransactionController extends EventEmitter { if (baseFeePerGas) { txMeta.baseFeePerGas = baseFeePerGas; } + if (blockTimestamp) { + txMeta.blockTimestamp = blockTimestamp; + } this.txStateManager.setTxStatusConfirmed(txId); this._markNonceDuplicatesDropped(txId); @@ -1183,8 +1186,13 @@ export default class TransactionController extends EventEmitter { }); this.pendingTxTracker.on( 'tx:confirmed', - (txId, transactionReceipt, baseFeePerGas) => - this.confirmTransaction(txId, transactionReceipt, baseFeePerGas), + (txId, transactionReceipt, baseFeePerGas, blockTimestamp) => + this.confirmTransaction( + txId, + transactionReceipt, + baseFeePerGas, + blockTimestamp, + ), ); this.pendingTxTracker.on('tx:dropped', (txId) => { this._dropTransaction(txId); diff --git a/app/scripts/controllers/transactions/lib/tx-state-history-helpers.js b/app/scripts/controllers/transactions/lib/tx-state-history-helpers.js index ea3f91bf1..da3aaac7f 100644 --- a/app/scripts/controllers/transactions/lib/tx-state-history-helpers.js +++ b/app/scripts/controllers/transactions/lib/tx-state-history-helpers.js @@ -38,7 +38,6 @@ export function generateHistoryEntry(previousState, newState, note) { if (note) { entry[0].note = note; } - entry[0].timestamp = Date.now(); } return entry; diff --git a/app/scripts/controllers/transactions/lib/tx-state-history-helpers.test.js b/app/scripts/controllers/transactions/lib/tx-state-history-helpers.test.js index 5e22b5d69..1c1bcd35a 100644 --- a/app/scripts/controllers/transactions/lib/tx-state-history-helpers.test.js +++ b/app/scripts/controllers/transactions/lib/tx-state-history-helpers.test.js @@ -119,9 +119,9 @@ describe('Transaction state history helper', function () { }, }; - const before = new Date().getTime(); + const timeBefore = new Date().getTime(); const result = generateHistoryEntry(prevState, nextState, note); - const after = new Date().getTime(); + const timeAfter = new Date().getTime(); assert.ok(Array.isArray(result)); assert.equal(result.length, 3); @@ -134,7 +134,9 @@ describe('Transaction state history helper', function () { assert.equal(result[0].path, expectedEntry1.path); assert.equal(result[0].value, expectedEntry1.value); assert.equal(result[0].note, note); - assert.ok(result[0].timestamp >= before && result[0].timestamp <= after); + assert.ok( + result[0].timestamp >= timeBefore && result[0].timestamp <= timeAfter, + ); const expectedEntry2 = { op: 'replace', diff --git a/app/scripts/controllers/transactions/pending-tx-tracker.js b/app/scripts/controllers/transactions/pending-tx-tracker.js index 96e7d9793..2cfced934 100644 --- a/app/scripts/controllers/transactions/pending-tx-tracker.js +++ b/app/scripts/controllers/transactions/pending-tx-tracker.js @@ -164,6 +164,7 @@ export default class PendingTransactionTracker extends EventEmitter { * @emits tx:warning * @private */ + async _checkPendingTx(txMeta) { const txHash = txMeta.hash; const txId = txMeta.id; @@ -193,11 +194,21 @@ export default class PendingTransactionTracker extends EventEmitter { try { const transactionReceipt = await this.query.getTransactionReceipt(txHash); if (transactionReceipt?.blockNumber) { - const { baseFeePerGas } = await this.query.getBlockByHash( + const { + baseFeePerGas, + timestamp: blockTimestamp, + } = await this.query.getBlockByHash( transactionReceipt?.blockHash, false, ); - this.emit('tx:confirmed', txId, transactionReceipt, baseFeePerGas); + + this.emit( + 'tx:confirmed', + txId, + transactionReceipt, + baseFeePerGas, + blockTimestamp, + ); return; } } catch (err) { diff --git a/app/scripts/controllers/transactions/tx-state-manager.test.js b/app/scripts/controllers/transactions/tx-state-manager.test.js index 3c29efc10..16e6dffe3 100644 --- a/app/scripts/controllers/transactions/tx-state-manager.test.js +++ b/app/scripts/controllers/transactions/tx-state-manager.test.js @@ -845,9 +845,9 @@ describe('TransactionStateManager', function () { ); // modify value and updateTransaction updatedTx.txParams.gasPrice = desiredGasPrice; - const before = new Date().getTime(); + const timeBefore = new Date().getTime(); txStateManager.updateTransaction(updatedTx); - const after = new Date().getTime(); + const timeAfter = new Date().getTime(); // check updated value const result = txStateManager.getTransaction('1'); assert.equal( @@ -888,8 +888,8 @@ describe('TransactionStateManager', function () { 'two history items (initial + diff) value', ); assert.ok( - result.history[1][0].timestamp >= before && - result.history[1][0].timestamp <= after, + result.history[1][0].timestamp >= timeBefore && + result.history[1][0].timestamp <= timeAfter, ); }); diff --git a/app/scripts/lib/ComposableObservableStore.test.js b/app/scripts/lib/ComposableObservableStore.test.js index 063f97cbf..0beeacdfb 100644 --- a/app/scripts/lib/ComposableObservableStore.test.js +++ b/app/scripts/lib/ComposableObservableStore.test.js @@ -1,4 +1,3 @@ -import { strict as assert } from 'assert'; import { ObservableStore } from '@metamask/obs-store'; import { BaseController, @@ -48,17 +47,17 @@ class ExampleController extends BaseControllerV2 { } } -describe('ComposableObservableStore', function () { - it('should register initial state', function () { +describe('ComposableObservableStore', () => { + it('should register initial state', () => { const controllerMessenger = new ControllerMessenger(); const store = new ComposableObservableStore({ controllerMessenger, state: 'state', }); - assert.strictEqual(store.getState(), 'state'); + expect(store.getState()).toStrictEqual('state'); }); - it('should register initial structure', function () { + it('should register initial structure', () => { const controllerMessenger = new ControllerMessenger(); const testStore = new ObservableStore(); const store = new ComposableObservableStore({ @@ -66,28 +65,28 @@ describe('ComposableObservableStore', function () { controllerMessenger, }); testStore.putState('state'); - assert.deepEqual(store.getState(), { TestStore: 'state' }); + expect(store.getState()).toStrictEqual({ TestStore: 'state' }); }); - it('should update structure with observable store', function () { + it('should update structure with observable store', () => { const controllerMessenger = new ControllerMessenger(); const testStore = new ObservableStore(); const store = new ComposableObservableStore({ controllerMessenger }); store.updateStructure({ TestStore: testStore }); testStore.putState('state'); - assert.deepEqual(store.getState(), { TestStore: 'state' }); + expect(store.getState()).toStrictEqual({ TestStore: 'state' }); }); - it('should update structure with BaseController-based controller', function () { + it('should update structure with BaseController-based controller', () => { const controllerMessenger = new ControllerMessenger(); const oldExampleController = new OldExampleController(); const store = new ComposableObservableStore({ controllerMessenger }); store.updateStructure({ OldExample: oldExampleController }); oldExampleController.updateBaz('state'); - assert.deepEqual(store.getState(), { OldExample: { baz: 'state' } }); + expect(store.getState()).toStrictEqual({ OldExample: { baz: 'state' } }); }); - it('should update structure with BaseControllerV2-based controller', function () { + it('should update structure with BaseControllerV2-based controller', () => { const controllerMessenger = new ControllerMessenger(); const exampleController = new ExampleController({ messenger: controllerMessenger, @@ -95,11 +94,10 @@ describe('ComposableObservableStore', function () { const store = new ComposableObservableStore({ controllerMessenger }); store.updateStructure({ Example: exampleController }); exampleController.updateBar('state'); - console.log(exampleController.state); - assert.deepEqual(store.getState(), { Example: { bar: 'state' } }); + expect(store.getState()).toStrictEqual({ Example: { bar: 'state' } }); }); - it('should update structure with all three types of stores', function () { + it('should update structure with all three types of stores', () => { const controllerMessenger = new ControllerMessenger(); const exampleStore = new ObservableStore(); const exampleController = new ExampleController({ @@ -115,14 +113,14 @@ describe('ComposableObservableStore', function () { exampleStore.putState('state'); exampleController.updateBar('state'); oldExampleController.updateBaz('state'); - assert.deepEqual(store.getState(), { + expect(store.getState()).toStrictEqual({ Example: { bar: 'state' }, OldExample: { baz: 'state' }, Store: 'state', }); }); - it('should return flattened state', function () { + it('should return flattened state', () => { const controllerMessenger = new ControllerMessenger(); const fooStore = new ObservableStore({ foo: 'foo' }); const barController = new ExampleController({ @@ -142,46 +140,48 @@ describe('ComposableObservableStore', function () { BazStore: bazController.state, }, }); - assert.deepEqual(store.getFlatState(), { + expect(store.getFlatState()).toStrictEqual({ foo: 'foo', bar: 'bar', baz: 'baz', }); }); - it('should return empty flattened state when not configured', function () { + it('should return empty flattened state when not configured', () => { const controllerMessenger = new ControllerMessenger(); const store = new ComposableObservableStore({ controllerMessenger }); - assert.deepEqual(store.getFlatState(), {}); + expect(store.getFlatState()).toStrictEqual({}); }); - it('should throw if the controller messenger is omitted and the config includes a BaseControllerV2 controller', function () { + it('should throw if the controller messenger is omitted and the config includes a BaseControllerV2 controller', () => { const controllerMessenger = new ControllerMessenger(); const exampleController = new ExampleController({ messenger: controllerMessenger, }); - assert.throws( + expect( () => new ComposableObservableStore({ config: { Example: exampleController, }, }), - ); + ).toThrow(`Cannot read property 'subscribe' of undefined`); }); - it('should throw if the controller messenger is omitted and updateStructure called with a BaseControllerV2 controller', function () { + it('should throw if the controller messenger is omitted and updateStructure called with a BaseControllerV2 controller', () => { const controllerMessenger = new ControllerMessenger(); const exampleController = new ExampleController({ messenger: controllerMessenger, }); const store = new ComposableObservableStore({}); - assert.throws(() => store.updateStructure({ Example: exampleController })); + expect(() => store.updateStructure({ Example: exampleController })).toThrow( + `Cannot read property 'subscribe' of undefined`, + ); }); - it('should throw if initialized with undefined config entry', function () { + it('should throw if initialized with undefined config entry', () => { const controllerMessenger = new ControllerMessenger(); - assert.throws( + expect( () => new ComposableObservableStore({ config: { @@ -189,6 +189,6 @@ describe('ComposableObservableStore', function () { }, controllerMessenger, }), - ); + ).toThrow(`Undefined 'Example'`); }); }); diff --git a/app/scripts/lib/buy-eth-url.js b/app/scripts/lib/buy-eth-url.js index bda772693..f1cf62ba7 100644 --- a/app/scripts/lib/buy-eth-url.js +++ b/app/scripts/lib/buy-eth-url.js @@ -27,7 +27,7 @@ const createWyrePurchaseUrl = async (address) => { const response = await fetchWithTimeout(fiatOnRampUrlApi, { method: 'GET', headers: { - 'Accept': 'application/json', + Accept: 'application/json', 'Content-Type': 'application/json', }, }); diff --git a/app/scripts/lib/buy-eth-url.test.js b/app/scripts/lib/buy-eth-url.test.js index b240f55b8..4545f36d3 100644 --- a/app/scripts/lib/buy-eth-url.test.js +++ b/app/scripts/lib/buy-eth-url.test.js @@ -1,4 +1,3 @@ -import { strict as assert } from 'assert'; import nock from 'nock'; import { KOVAN_CHAIN_ID, @@ -27,8 +26,8 @@ const KOVAN = { chainId: KOVAN_CHAIN_ID, }; -describe('buy-eth-url', function () { - it('returns Wyre url with an ETH address for Ethereum mainnet', async function () { +describe('buy-eth-url', () => { + it('returns Wyre url with an ETH address for Ethereum mainnet', async () => { nock(SWAPS_API_V2_BASE_URL) .get( `/networks/1/fiatOnRampUrl?serviceName=wyre&destinationAddress=${ETH_ADDRESS}`, @@ -37,43 +36,40 @@ describe('buy-eth-url', function () { 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`, }); const wyreUrl = await getBuyEthUrl(MAINNET); - assert.equal( - wyreUrl, + expect(wyreUrl).toStrictEqual( `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`, ); nock.cleanAll(); }); - it('returns a fallback Wyre url if /orders/reserve API call fails', async function () { + it('returns a fallback Wyre url if /orders/reserve API call fails', async () => { const wyreUrl = await getBuyEthUrl(MAINNET); - assert.equal( - wyreUrl, + expect(wyreUrl).toStrictEqual( `https://pay.sendwyre.com/purchase?dest=ethereum:${ETH_ADDRESS}&destCurrency=ETH&accountId=${WYRE_ACCOUNT_ID}&paymentMethod=debit-card`, ); }); - it('returns Transak url with an ETH address for Ethereum mainnet', async function () { + it('returns Transak url with an ETH address for Ethereum mainnet', async () => { const transakUrl = await getBuyEthUrl({ ...MAINNET, service: 'transak' }); - assert.equal( - transakUrl, + expect(transakUrl).toStrictEqual( `https://global.transak.com/?apiKey=${TRANSAK_API_KEY}&hostURL=https%3A%2F%2Fmetamask.io&defaultCryptoCurrency=ETH&walletAddress=${ETH_ADDRESS}`, ); }); - it('returns metamask ropsten faucet for network 3', async function () { + it('returns metamask ropsten faucet for network 3', async () => { const ropstenUrl = await getBuyEthUrl(ROPSTEN); - assert.equal(ropstenUrl, 'https://faucet.metamask.io/'); + expect(ropstenUrl).toStrictEqual('https://faucet.metamask.io/'); }); - it('returns rinkeby dapp for network 4', async function () { + it('returns rinkeby dapp for network 4', async () => { const rinkebyUrl = await getBuyEthUrl(RINKEBY); - assert.equal(rinkebyUrl, 'https://www.rinkeby.io/'); + expect(rinkebyUrl).toStrictEqual('https://www.rinkeby.io/'); }); - it('returns kovan github test faucet for network 42', async function () { + it('returns kovan github test faucet for network 42', async () => { const kovanUrl = await getBuyEthUrl(KOVAN); - assert.equal(kovanUrl, 'https://github.com/kovan-testnet/faucet'); + expect(kovanUrl).toStrictEqual('https://github.com/kovan-testnet/faucet'); }); }); diff --git a/app/scripts/lib/cleanErrorStack.test.js b/app/scripts/lib/cleanErrorStack.test.js index b87152f21..16462dc97 100644 --- a/app/scripts/lib/cleanErrorStack.test.js +++ b/app/scripts/lib/cleanErrorStack.test.js @@ -1,34 +1,36 @@ -import { strict as assert } from 'assert'; import cleanErrorStack from './cleanErrorStack'; -describe('Clean Error Stack', function () { +describe('Clean Error Stack', () => { const testMessage = 'Test Message'; const testError = new Error(testMessage); const undefinedErrorName = new Error(testMessage); const blankErrorName = new Error(testMessage); const blankMsgError = new Error(); - beforeEach(function () { + beforeEach(() => { undefinedErrorName.name = undefined; blankErrorName.name = ''; }); - it('tests error with message', function () { - assert.equal(cleanErrorStack(testError).toString(), 'Error: Test Message'); + it('tests error with message', () => { + expect(cleanErrorStack(testError).toString()).toStrictEqual( + 'Error: Test Message', + ); }); - it('tests error with undefined name', function () { - assert.equal( - cleanErrorStack(undefinedErrorName).toString(), + it('tests error with undefined name', () => { + expect(cleanErrorStack(undefinedErrorName).toString()).toStrictEqual( 'Error: Test Message', ); }); - it('tests error with blank name', function () { - assert.equal(cleanErrorStack(blankErrorName).toString(), 'Test Message'); + it('tests error with blank name', () => { + expect(cleanErrorStack(blankErrorName).toString()).toStrictEqual( + 'Test Message', + ); }); - it('tests error with blank message', function () { - assert.equal(cleanErrorStack(blankMsgError).toString(), 'Error'); + it('tests error with blank message', () => { + expect(cleanErrorStack(blankMsgError).toString()).toStrictEqual('Error'); }); }); diff --git a/app/scripts/lib/createMetaRPCHandler.js b/app/scripts/lib/createMetaRPCHandler.js index 9b25c6ce7..d89156f86 100644 --- a/app/scripts/lib/createMetaRPCHandler.js +++ b/app/scripts/lib/createMetaRPCHandler.js @@ -1,7 +1,7 @@ import { ethErrors, serializeError } from 'eth-rpc-errors'; const createMetaRPCHandler = (api, outStream) => { - return (data) => { + return async (data) => { if (outStream._writableState.ended) { return; } @@ -15,24 +15,35 @@ const createMetaRPCHandler = (api, outStream) => { }); return; } - api[data.method](...data.params, (err, result) => { - if (outStream._writableState.ended) { - return; - } - if (err) { - outStream.write({ - jsonrpc: '2.0', - error: serializeError(err, { shouldIncludeStack: true }), - id: data.id, - }); - } else { - outStream.write({ - jsonrpc: '2.0', - result, - id: data.id, - }); + + let result; + let error; + try { + result = await api[data.method](...data.params); + } catch (err) { + error = err; + } + + if (outStream._writableState.ended) { + if (error) { + console.error(error); } - }); + return; + } + + if (error) { + outStream.write({ + jsonrpc: '2.0', + error: serializeError(error, { shouldIncludeStack: true }), + id: data.id, + }); + } else { + outStream.write({ + jsonrpc: '2.0', + result, + id: data.id, + }); + } }; }; diff --git a/app/scripts/lib/createMetaRPCHandler.test.js b/app/scripts/lib/createMetaRPCHandler.test.js index d6472b5e1..842af632e 100644 --- a/app/scripts/lib/createMetaRPCHandler.test.js +++ b/app/scripts/lib/createMetaRPCHandler.test.js @@ -1,13 +1,11 @@ -import { strict as assert } from 'assert'; import { obj as createThoughStream } from 'through2'; import createMetaRPCHandler from './createMetaRPCHandler'; -describe('createMetaRPCHandler', function () { - it('can call the api when handler receives a JSON-RPC request', function (done) { +describe('createMetaRPCHandler', () => { + it('can call the api when handler receives a JSON-RPC request', () => { const api = { foo: (param1) => { - assert.strictEqual(param1, 'bar'); - done(); + expect(param1).toStrictEqual('bar'); }, }; const streamTest = createThoughStream(); @@ -18,11 +16,11 @@ describe('createMetaRPCHandler', function () { params: ['bar'], }); }); - it('can write the response to the outstream when api callback is called', function (done) { + it('can write the response to the outstream', () => { const api = { - foo: (param1, cb) => { - assert.strictEqual(param1, 'bar'); - cb(null, 'foobarbaz'); + foo: (param1) => { + expect(param1).toStrictEqual('bar'); + return 'foobarbaz'; }, }; const streamTest = createThoughStream(); @@ -33,16 +31,16 @@ describe('createMetaRPCHandler', function () { params: ['bar'], }); streamTest.on('data', (data) => { - assert.strictEqual(data.result, 'foobarbaz'); + expect(data.result).toStrictEqual('foobarbaz'); streamTest.end(); - done(); }); }); - it('can write the error to the outstream when api callback is called with an error', function (done) { + it('can write an async response to the outstream', () => { const api = { - foo: (param1, cb) => { - assert.strictEqual(param1, 'bar'); - cb(new Error('foo-error')); + foo: async (param1) => { + expect(param1).toStrictEqual('bar'); + await new Promise((resolve) => setTimeout(() => resolve(), 100)); + return 'foobarbaz'; }, }; const streamTest = createThoughStream(); @@ -53,45 +51,65 @@ describe('createMetaRPCHandler', function () { params: ['bar'], }); streamTest.on('data', (data) => { - assert.strictEqual(data.error.message, 'foo-error'); + expect(data.result).toStrictEqual('foobarbaz'); streamTest.end(); - done(); }); }); - it('can not throw an error for writing an error after end', function (done) { + it('can write the error to the outstream when method throws an error', () => { const api = { - foo: (param1, cb) => { - assert.strictEqual(param1, 'bar'); - cb(new Error('foo-error')); + foo: (param1) => { + expect(param1).toStrictEqual('bar'); + throw new Error('foo-error'); }, }; const streamTest = createThoughStream(); const handler = createMetaRPCHandler(api, streamTest); - streamTest.end(); handler({ id: 1, method: 'foo', params: ['bar'], }); - done(); + streamTest.on('data', (data) => { + expect(data.error.message).toStrictEqual('foo-error'); + streamTest.end(); + }); + }); + it('can not throw an error for writing an error after end', () => { + const api = { + foo: (param1) => { + expect(param1).toStrictEqual('bar'); + throw new Error('foo-error'); + }, + }; + const streamTest = createThoughStream(); + const handler = createMetaRPCHandler(api, streamTest); + streamTest.end(); + expect(() => { + handler({ + id: 1, + method: 'foo', + params: ['bar'], + }); + }).not.toThrow(); }); - it('can not throw an error for write after end', function (done) { + it('can not throw an error for write after end', () => { const api = { - foo: (param1, cb) => { - assert.strictEqual(param1, 'bar'); - cb(undefined, { + foo: (param1) => { + expect(param1).toStrictEqual('bar'); + return { foo: 'bar', - }); + }; }, }; const streamTest = createThoughStream(); const handler = createMetaRPCHandler(api, streamTest); streamTest.end(); - handler({ - id: 1, - method: 'foo', - params: ['bar'], - }); - done(); + expect(() => { + handler({ + id: 1, + method: 'foo', + params: ['bar'], + }); + }).not.toThrow(); }); }); diff --git a/app/scripts/lib/ens-ipfs/setup.js b/app/scripts/lib/ens-ipfs/setup.js index c7d1b2c09..a4c199b42 100644 --- a/app/scripts/lib/ens-ipfs/setup.js +++ b/app/scripts/lib/ens-ipfs/setup.js @@ -50,6 +50,7 @@ export default function setupEnsIpfsResolver({ async function attemptResolve({ tabId, name, pathname, search, fragment }) { const ipfsGateway = getIpfsGateway(); + extension.tabs.update(tabId, { url: `loading.html` }); let url = `https://app.ens.domains/name/${name}`; try { diff --git a/app/scripts/lib/message-manager.test.js b/app/scripts/lib/message-manager.test.js index 82fa147b3..7045b6588 100644 --- a/app/scripts/lib/message-manager.test.js +++ b/app/scripts/lib/message-manager.test.js @@ -1,27 +1,25 @@ -import { strict as assert } from 'assert'; -import sinon from 'sinon'; import { TRANSACTION_STATUSES } from '../../../shared/constants/transaction'; import MessageManager from './message-manager'; -describe('Message Manager', function () { +describe('Message Manager', () => { let messageManager; - beforeEach(function () { + beforeEach(() => { messageManager = new MessageManager({ - metricsEvent: sinon.fake(), + metricsEvent: jest.fn(), }); }); - describe('#getMsgList', function () { - it('when new should return empty array', function () { + describe('#getMsgList', () => { + it('when new should return empty array', () => { const result = messageManager.messages; - assert.ok(Array.isArray(result)); - assert.equal(result.length, 0); + expect(Array.isArray(result)).toStrictEqual(true); + expect(result).toHaveLength(0); }); }); - describe('#addMsg', function () { - it('adds a Msg returned in getMsgList', function () { + describe('#addMsg', () => { + it('adds a Msg returned in getMsgList', () => { const Msg = { id: 1, status: TRANSACTION_STATUSES.APPROVED, @@ -29,14 +27,14 @@ describe('Message Manager', function () { }; messageManager.addMsg(Msg); const result = messageManager.messages; - assert.ok(Array.isArray(result)); - assert.equal(result.length, 1); - assert.equal(result[0].id, 1); + expect(Array.isArray(result)).toStrictEqual(true); + expect(result).toHaveLength(1); + expect(result[0].id).toStrictEqual(1); }); }); - describe('#setMsgStatusApproved', function () { - it('sets the Msg status to approved', function () { + describe('#setMsgStatusApproved', () => { + it('sets the Msg status to approved', () => { const Msg = { id: 1, status: 'unapproved', @@ -45,14 +43,14 @@ describe('Message Manager', function () { messageManager.addMsg(Msg); messageManager.setMsgStatusApproved(1); const result = messageManager.messages; - assert.ok(Array.isArray(result)); - assert.equal(result.length, 1); - assert.equal(result[0].status, TRANSACTION_STATUSES.APPROVED); + expect(Array.isArray(result)).toStrictEqual(true); + expect(result).toHaveLength(1); + expect(result[0].status).toStrictEqual(TRANSACTION_STATUSES.APPROVED); }); }); - describe('#rejectMsg', function () { - it('sets the Msg status to rejected', function () { + describe('#rejectMsg', () => { + it('sets the Msg status to rejected', () => { const Msg = { id: 1, status: 'unapproved', @@ -61,14 +59,14 @@ describe('Message Manager', function () { messageManager.addMsg(Msg); messageManager.rejectMsg(1); const result = messageManager.messages; - assert.ok(Array.isArray(result)); - assert.equal(result.length, 1); - assert.equal(result[0].status, TRANSACTION_STATUSES.REJECTED); + expect(Array.isArray(result)).toStrictEqual(true); + expect(result).toHaveLength(1); + expect(result[0].status).toStrictEqual(TRANSACTION_STATUSES.REJECTED); }); }); - describe('#_updateMsg', function () { - it('replaces the Msg with the same id', function () { + describe('#_updateMsg', () => { + it('replaces the Msg with the same id', () => { messageManager.addMsg({ id: '1', status: 'unapproved', @@ -86,12 +84,12 @@ describe('Message Manager', function () { metamaskNetworkId: 'unit test', }); const result = messageManager.getMsg('1'); - assert.equal(result.hash, 'foo'); + expect(result.hash).toStrictEqual('foo'); }); }); - describe('#getUnapprovedMsgs', function () { - it('returns unapproved Msgs in a hash', function () { + describe('#getUnapprovedMsgs', () => { + it('returns unapproved Msgs in a hash', () => { messageManager.addMsg({ id: '1', status: 'unapproved', @@ -103,14 +101,14 @@ describe('Message Manager', function () { metamaskNetworkId: 'unit test', }); const result = messageManager.getUnapprovedMsgs(); - assert.equal(typeof result, 'object'); - assert.equal(result['1'].status, 'unapproved'); - assert.equal(result['2'], undefined); + expect(typeof result).toStrictEqual('object'); + expect(result['1'].status).toStrictEqual('unapproved'); + expect(result['2']).toBeUndefined(); }); }); - describe('#getMsg', function () { - it('returns a Msg with the requested id', function () { + describe('#getMsg', () => { + it('returns a Msg with the requested id', () => { messageManager.addMsg({ id: '1', status: 'unapproved', @@ -121,9 +119,8 @@ describe('Message Manager', function () { status: TRANSACTION_STATUSES.APPROVED, metamaskNetworkId: 'unit test', }); - assert.equal(messageManager.getMsg('1').status, 'unapproved'); - assert.equal( - messageManager.getMsg('2').status, + expect(messageManager.getMsg('1').status).toStrictEqual('unapproved'); + expect(messageManager.getMsg('2').status).toStrictEqual( TRANSACTION_STATUSES.APPROVED, ); }); diff --git a/app/scripts/lib/metaRPCClientFactory.test.js b/app/scripts/lib/metaRPCClientFactory.test.js index d0553da73..8304cf4e9 100644 --- a/app/scripts/lib/metaRPCClientFactory.test.js +++ b/app/scripts/lib/metaRPCClientFactory.test.js @@ -1,24 +1,21 @@ -import { strict as assert } from 'assert'; import { obj as createThoughStream } from 'through2'; import metaRPCClientFactory from './metaRPCClientFactory'; -describe('metaRPCClientFactory', function () { - it('should be able to make an rpc request with the method', function (done) { +describe('metaRPCClientFactory', () => { + it('should be able to make an rpc request with the method', () => { const streamTest = createThoughStream((chunk) => { - assert.strictEqual(chunk.method, 'foo'); - done(); + expect(chunk.method).toStrictEqual('foo'); }); const metaRPCClient = metaRPCClientFactory(streamTest); metaRPCClient.foo(); }); - it('should be able to make an rpc request/response with the method and params and node-style callback', function (done) { + it('should be able to make an rpc request/response with the method and params and node-style callback', () => { const streamTest = createThoughStream(); const metaRPCClient = metaRPCClientFactory(streamTest); // make a "foo" method call metaRPCClient.foo('bar', (_, result) => { - assert.strictEqual(result, 'foobarbaz'); - done(); + expect(result).toStrictEqual('foobarbaz'); }); // fake a response @@ -30,15 +27,14 @@ describe('metaRPCClientFactory', function () { }); }); }); - it('should be able to make an rpc request/error with the method and params and node-style callback', function (done) { + it('should be able to make an rpc request/error with the method and params and node-style callback', () => { const streamTest = createThoughStream(); const metaRPCClient = metaRPCClientFactory(streamTest); // make a "foo" method call metaRPCClient.foo('bar', (err) => { - assert.strictEqual(err.message, 'foo-message'); - assert.strictEqual(err.code, 1); - done(); + expect(err.message).toStrictEqual('foo-message'); + expect(err.code).toStrictEqual(1); }); metaRPCClient.requests.forEach((_, key) => { @@ -53,17 +49,16 @@ describe('metaRPCClientFactory', function () { }); }); - it('should be able to make an rpc request/response with the method and params and node-style callback with multiple instances of metaRPCClientFactory and the same connectionStream', function (done) { + it('should be able to make an rpc request/response with the method and params and node-style callback with multiple instances of metaRPCClientFactory and the same connectionStream', () => { const streamTest = createThoughStream(); const metaRPCClient = metaRPCClientFactory(streamTest); const metaRPCClient2 = metaRPCClientFactory(streamTest); // make a "foo" method call, followed by "baz" call on metaRPCClient2 metaRPCClient.foo('bar', (_, result) => { - assert.strictEqual(result, 'foobarbaz'); + expect(result).toStrictEqual('foobarbaz'); metaRPCClient2.baz('bar', (err) => { - assert.strictEqual(err, null); - done(); + expect(err).toBeNull(); }); }); @@ -86,13 +81,12 @@ describe('metaRPCClientFactory', function () { }); }); - it('should be able to handle notifications', function (done) { + it('should be able to handle notifications', () => { const streamTest = createThoughStream(); const metaRPCClient = metaRPCClientFactory(streamTest); metaRPCClient.onNotification((notification) => { - assert(notification.method, 'foobarbaz'); - done(); + expect(notification.method).toStrictEqual('foobarbaz'); }); // send a notification @@ -103,13 +97,12 @@ describe('metaRPCClientFactory', function () { }); }); - it('should be able to handle errors with no id', function (done) { + it('should be able to handle errors with no id', () => { const streamTest = createThoughStream(); const metaRPCClient = metaRPCClientFactory(streamTest); metaRPCClient.onUncaughtError((error) => { - assert(error.code, 1); - done(); + expect(error.code).toStrictEqual(1); }); streamTest.write({ @@ -121,13 +114,12 @@ describe('metaRPCClientFactory', function () { }); }); - it('should be able to handle errors with null id', function (done) { + it('should be able to handle errors with null id', () => { const streamTest = createThoughStream(); const metaRPCClient = metaRPCClientFactory(streamTest); metaRPCClient.onUncaughtError((error) => { - assert(error.code, 1); - done(); + expect(error.code).toStrictEqual(1); }); streamTest.write({ diff --git a/app/scripts/lib/migrator/index.test.js b/app/scripts/lib/migrator/index.test.js index cdaac9e82..5ecc3b8d3 100644 --- a/app/scripts/lib/migrator/index.test.js +++ b/app/scripts/lib/migrator/index.test.js @@ -1,5 +1,5 @@ +/* eslint-disable jest/no-conditional-expect */ import fs from 'fs'; -import { strict as assert } from 'assert'; import { cloneDeep } from 'lodash'; import liveMigrations from '../../migrations'; import data from '../../first-time-state'; @@ -39,11 +39,11 @@ const firstTimeState = { data, }; -describe('migrations', function () { - describe('liveMigrations require list', function () { +describe('migrations', () => { + describe('liveMigrations require list', () => { let migrationNumbers; - before(function () { + beforeAll(() => { const fileNames = fs.readdirSync('./app/scripts/migrations/'); migrationNumbers = fileNames .reduce((acc, filename) => { @@ -56,21 +56,19 @@ describe('migrations', function () { .map((num) => parseInt(num, 10)); }); - it('should include all migrations', function () { + it('should include all migrations', () => { migrationNumbers.forEach((num) => { const migration = liveMigrations.find((m) => m.version === num); - assert( - migration, - `migration not included in 'migrations/index.js': ${num}`, - ); + expect(migration.version).toStrictEqual(num); }); }); - it('should have tests for all migrations', function () { + it('should have tests for all migrations', () => { const fileNames = fs.readdirSync('./app/scripts/migrations/'); const testNumbers = fileNames .reduce((acc, filename) => { const name = filename.split('.test.')[0]; + // eslint-disable-next-line jest/no-if if (/^\d+$/u.test(name)) { acc.push(name); } @@ -80,30 +78,31 @@ describe('migrations', function () { migrationNumbers.forEach((num) => { if (num >= 33) { - assert.ok( - testNumbers.includes(num), - `no test found for migration: ${num}`, - ); + expect(testNumbers).toContain(num); } }); }); }); - describe('Migrator', function () { - it('migratedData version should be version 3', async function () { + describe('Migrator', () => { + it('migratedData version should be version 3', async () => { const migrator = new Migrator({ migrations: stubMigrations }); const migratedData = await migrator.migrateData(versionedData); - assert.equal(migratedData.meta.version, stubMigrations[2].version); + expect(migratedData.meta.version).toStrictEqual( + stubMigrations[2].version, + ); }); - it('should match the last version in live migrations', async function () { + it('should match the last version in live migrations', async () => { const migrator = new Migrator({ migrations: liveMigrations }); const migratedData = await migrator.migrateData(firstTimeState); const last = liveMigrations.length - 1; - assert.equal(migratedData.meta.version, liveMigrations[last].version); + expect(migratedData.meta.version).toStrictEqual( + liveMigrations[last].version, + ); }); - it('should emit an error', async function () { + it('should emit an error', async () => { const migrator = new Migrator({ migrations: [ { @@ -114,7 +113,9 @@ describe('migrations', function () { }, ], }); - await assert.rejects(migrator.migrateData({ meta: { version: 0 } })); + await expect(async () => { + await migrator.migrateData({ meta: { version: 0 } }); + }).rejects.toThrow('Error: MetaMask Migration Error #1: test'); }); }); }); diff --git a/app/scripts/lib/nodeify.js b/app/scripts/lib/nodeify.js deleted file mode 100644 index ab222e437..000000000 --- a/app/scripts/lib/nodeify.js +++ /dev/null @@ -1,38 +0,0 @@ -import promiseToCallback from 'promise-to-callback'; - -const callbackNoop = function (err) { - if (err) { - throw err; - } -}; - -/** - * A generator that returns a function which, when passed a promise, can treat that promise as a node style callback. - * The prime advantage being that callbacks are better for error handling. - * - * @param {Function} fn - The function to handle as a callback - * @param {Object} context - The context in which the fn is to be called, most often a this reference - * - */ -export default function nodeify(fn, context) { - return function (...args) { - const lastArg = args[args.length - 1]; - const lastArgIsCallback = typeof lastArg === 'function'; - let callback; - if (lastArgIsCallback) { - callback = lastArg; - args.pop(); - } else { - callback = callbackNoop; - } - // call the provided function and ensure result is a promise - let result; - try { - result = Promise.resolve(fn.apply(context, args)); - } catch (err) { - result = Promise.reject(err); - } - // wire up promise resolution to callback - promiseToCallback(result)(callback); - }; -} diff --git a/app/scripts/lib/nodeify.test.js b/app/scripts/lib/nodeify.test.js deleted file mode 100644 index accfc48e8..000000000 --- a/app/scripts/lib/nodeify.test.js +++ /dev/null @@ -1,74 +0,0 @@ -import { strict as assert } from 'assert'; -import nodeify from './nodeify'; - -describe('nodeify', function () { - const obj = { - foo: 'bar', - promiseFunc(a) { - const solution = this.foo + a; - return Promise.resolve(solution); - }, - }; - - it('should retain original context', function (done) { - const nodified = nodeify(obj.promiseFunc, obj); - nodified('baz', (err, res) => { - if (!err) { - assert.equal(res, 'barbaz'); - done(); - return; - } - - done(new Error(err.toString())); - }); - }); - - it('no callback - should allow the last argument to not be a function', function (done) { - const nodified = nodeify(obj.promiseFunc, obj); - try { - nodified('baz'); - done(); - } catch (err) { - done( - new Error( - 'should not have thrown if the last argument is not a function', - ), - ); - } - }); - - it('sync functions - returns value', function (done) { - const nodified = nodeify(() => 42); - try { - nodified((err, result) => { - if (err) { - done(new Error(`should not have thrown any error: ${err.message}`)); - return; - } - assert.equal(42, result, 'got expected result'); - }); - done(); - } catch (err) { - done(new Error(`should not have thrown any error: ${err.message}`)); - } - }); - - it('sync functions - handles errors', function (done) { - const nodified = nodeify(() => { - throw new Error('boom!'); - }); - try { - nodified((err, result) => { - if (result) { - done(new Error('should not have returned any result')); - return; - } - assert.ok(err, 'got expected error'); - assert.ok(err.message.includes('boom!'), 'got expected error message'); - }); - done(); - } catch (err) { - done(new Error(`should not have thrown any error: ${err.message}`)); - } - }); -}); diff --git a/app/scripts/lib/personal-message-manager.test.js b/app/scripts/lib/personal-message-manager.test.js index add3ac28e..4fc7a6283 100644 --- a/app/scripts/lib/personal-message-manager.test.js +++ b/app/scripts/lib/personal-message-manager.test.js @@ -1,25 +1,25 @@ -import { strict as assert } from 'assert'; -import sinon from 'sinon'; import { TRANSACTION_STATUSES } from '../../../shared/constants/transaction'; import PersonalMessageManager from './personal-message-manager'; -describe('Personal Message Manager', function () { +describe('Personal Message Manager', () => { let messageManager; - beforeEach(function () { - messageManager = new PersonalMessageManager({ metricsEvent: sinon.fake() }); + beforeEach(() => { + messageManager = new PersonalMessageManager({ + metricsEvent: jest.fn(), + }); }); - describe('#getMsgList', function () { - it('when new should return empty array', function () { + describe('#getMsgList', () => { + it('when new should return empty array', () => { const result = messageManager.messages; - assert.ok(Array.isArray(result)); - assert.equal(result.length, 0); + expect(Array.isArray(result)).toStrictEqual(true); + expect(result).toHaveLength(0); }); }); - describe('#addMsg', function () { - it('adds a Msg returned in getMsgList', function () { + describe('#addMsg', () => { + it('adds a Msg returned in getMsgList', () => { const Msg = { id: 1, status: TRANSACTION_STATUSES.APPROVED, @@ -27,14 +27,14 @@ describe('Personal Message Manager', function () { }; messageManager.addMsg(Msg); const result = messageManager.messages; - assert.ok(Array.isArray(result)); - assert.equal(result.length, 1); - assert.equal(result[0].id, 1); + expect(Array.isArray(result)).toStrictEqual(true); + expect(result).toHaveLength(1); + expect(result[0].id).toStrictEqual(1); }); }); - describe('#setMsgStatusApproved', function () { - it('sets the Msg status to approved', function () { + describe('#setMsgStatusApproved', () => { + it('sets the Msg status to approved', () => { const Msg = { id: 1, status: TRANSACTION_STATUSES.UNAPPROVED, @@ -43,14 +43,14 @@ describe('Personal Message Manager', function () { messageManager.addMsg(Msg); messageManager.setMsgStatusApproved(1); const result = messageManager.messages; - assert.ok(Array.isArray(result)); - assert.equal(result.length, 1); - assert.equal(result[0].status, TRANSACTION_STATUSES.APPROVED); + expect(Array.isArray(result)).toStrictEqual(true); + expect(result).toHaveLength(1); + expect(result[0].status).toStrictEqual(TRANSACTION_STATUSES.APPROVED); }); }); - describe('#rejectMsg', function () { - it('sets the Msg status to rejected', function () { + describe('#rejectMsg', () => { + it('sets the Msg status to rejected', () => { const Msg = { id: 1, status: TRANSACTION_STATUSES.UNAPPROVED, @@ -59,14 +59,14 @@ describe('Personal Message Manager', function () { messageManager.addMsg(Msg); messageManager.rejectMsg(1); const result = messageManager.messages; - assert.ok(Array.isArray(result)); - assert.equal(result.length, 1); - assert.equal(result[0].status, TRANSACTION_STATUSES.REJECTED); + expect(Array.isArray(result)).toStrictEqual(true); + expect(result).toHaveLength(1); + expect(result[0].status).toStrictEqual(TRANSACTION_STATUSES.REJECTED); }); }); - describe('#_updateMsg', function () { - it('replaces the Msg with the same id', function () { + describe('#_updateMsg', () => { + it('replaces the Msg with the same id', () => { messageManager.addMsg({ id: '1', status: TRANSACTION_STATUSES.UNAPPROVED, @@ -84,12 +84,12 @@ describe('Personal Message Manager', function () { metamaskNetworkId: 'unit test', }); const result = messageManager.getMsg('1'); - assert.equal(result.hash, 'foo'); + expect(result.hash).toStrictEqual('foo'); }); }); - describe('#getUnapprovedMsgs', function () { - it('returns unapproved Msgs in a hash', function () { + describe('#getUnapprovedMsgs', () => { + it('returns unapproved Msgs in a hash', () => { messageManager.addMsg({ id: '1', status: TRANSACTION_STATUSES.UNAPPROVED, @@ -101,14 +101,14 @@ describe('Personal Message Manager', function () { metamaskNetworkId: 'unit test', }); const result = messageManager.getUnapprovedMsgs(); - assert.equal(typeof result, 'object'); - assert.equal(result['1'].status, TRANSACTION_STATUSES.UNAPPROVED); - assert.equal(result['2'], undefined); + expect(typeof result).toStrictEqual('object'); + expect(result['1'].status).toStrictEqual(TRANSACTION_STATUSES.UNAPPROVED); + expect(result['2']).toBeUndefined(); }); }); - describe('#getMsg', function () { - it('returns a Msg with the requested id', function () { + describe('#getMsg', () => { + it('returns a Msg with the requested id', () => { messageManager.addMsg({ id: '1', status: TRANSACTION_STATUSES.UNAPPROVED, @@ -119,34 +119,32 @@ describe('Personal Message Manager', function () { status: TRANSACTION_STATUSES.APPROVED, metamaskNetworkId: 'unit test', }); - assert.equal( - messageManager.getMsg('1').status, + expect(messageManager.getMsg('1').status).toStrictEqual( TRANSACTION_STATUSES.UNAPPROVED, ); - assert.equal( - messageManager.getMsg('2').status, + expect(messageManager.getMsg('2').status).toStrictEqual( TRANSACTION_STATUSES.APPROVED, ); }); }); - describe('#normalizeMsgData', function () { - it('converts text to a utf8 hex string', function () { + describe('#normalizeMsgData', () => { + it('converts text to a utf8 hex string', () => { const input = 'hello'; const output = messageManager.normalizeMsgData(input); - assert.equal(output, '0x68656c6c6f', 'predictably hex encoded'); + expect(output).toStrictEqual('0x68656c6c6f'); }); - it('tolerates a hex prefix', function () { + it('tolerates a hex prefix', () => { const input = '0x12'; const output = messageManager.normalizeMsgData(input); - assert.equal(output, '0x12', 'un modified'); + expect(output).toStrictEqual('0x12'); }); - it('tolerates normal hex', function () { + it('tolerates normal hex', () => { const input = '12'; const output = messageManager.normalizeMsgData(input); - assert.equal(output, '0x12', 'adds prefix'); + expect(output).toStrictEqual('0x12'); }); }); }); diff --git a/app/scripts/lib/rpc-method-middleware/createMethodMiddleware.js b/app/scripts/lib/rpc-method-middleware/createMethodMiddleware.js index 9c7935d3e..949e3b168 100644 --- a/app/scripts/lib/rpc-method-middleware/createMethodMiddleware.js +++ b/app/scripts/lib/rpc-method-middleware/createMethodMiddleware.js @@ -1,8 +1,12 @@ +import { permissionRpcMethods } from '@metamask/snap-controllers'; +import { selectHooks } from '@metamask/rpc-methods'; import { ethErrors } from 'eth-rpc-errors'; import { UNSUPPORTED_RPC_METHODS } from '../../../../shared/constants/network'; -import handlers from './handlers'; +import localHandlers from './handlers'; -const handlerMap = handlers.reduce((map, handler) => { +const allHandlers = [...localHandlers, ...permissionRpcMethods.handlers]; + +const handlerMap = allHandlers.reduce((map, handler) => { for (const methodName of handler.methodNames) { map.set(methodName, handler); } @@ -10,23 +14,17 @@ const handlerMap = handlers.reduce((map, handler) => { }, new Map()); /** - * Returns a middleware that implements the RPC methods defined in the handlers - * directory. - * - * The purpose of this middleware is to create portable RPC method - * implementations that are decoupled from the rest of our background - * architecture. + * Creates a json-rpc-engine middleware of RPC method implementations. * * Handlers consume functions that hook into the background, and only depend * on their signatures, not e.g. controller internals. * - * Eventually, we'll want to extract this middleware into its own package. - * - * @param {Object} opts - The middleware options + * @param {Record} hooks - Required "hooks" into our + * controllers. * @returns {(req: Object, res: Object, next: Function, end: Function) => void} */ -export default function createMethodMiddleware(opts) { - return function methodMiddleware(req, res, next, end) { +export default function createMethodMiddleware(hooks) { + return async function methodMiddleware(req, res, next, end) { // Reject unsupported methods. if (UNSUPPORTED_RPC_METHODS.has(req.method)) { return end(ethErrors.rpc.methodNotSupported()); @@ -35,29 +33,20 @@ export default function createMethodMiddleware(opts) { const handler = handlerMap.get(req.method); if (handler) { const { implementation, hookNames } = handler; - return implementation(req, res, next, end, selectHooks(opts, hookNames)); + try { + // Implementations may or may not be async, so we must await them. + return await implementation( + req, + res, + next, + end, + selectHooks(hooks, hookNames), + ); + } catch (error) { + return end(error); + } } 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} hooks - The hooks to select from. - * @param {Record} hookNames - The names of the hooks to select. - * @returns {Record | 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; -} diff --git a/app/scripts/lib/rpc-method-middleware/handlers/eth-accounts.js b/app/scripts/lib/rpc-method-middleware/handlers/eth-accounts.js new file mode 100644 index 000000000..abfed89cf --- /dev/null +++ b/app/scripts/lib/rpc-method-middleware/handlers/eth-accounts.js @@ -0,0 +1,33 @@ +import { MESSAGE_TYPE } from '../../../../../shared/constants/app'; + +/** + * A wrapper for `eth_accounts` that returns an empty array when permission is denied. + */ + +const requestEthereumAccounts = { + methodNames: [MESSAGE_TYPE.ETH_ACCOUNTS], + implementation: ethAccountsHandler, + hookNames: { + getAccounts: true, + }, +}; +export default requestEthereumAccounts; + +/** + * @typedef {Record} EthAccountsOptions + * @property {Function} getAccounts - Gets the accounts for the requesting + * origin. + */ + +/** + * + * @param {import('json-rpc-engine').JsonRpcRequest} req - The JSON-RPC request object. + * @param {import('json-rpc-engine').JsonRpcResponse} res - The JSON-RPC response object. + * @param {Function} _next - The json-rpc-engine 'next' callback. + * @param {Function} end - The json-rpc-engine 'end' callback. + * @param {EthAccountsOptions} options - The RPC method hooks. + */ +async function ethAccountsHandler(_req, res, _next, end, { getAccounts }) { + res.result = await getAccounts(); + return end(); +} diff --git a/app/scripts/lib/rpc-method-middleware/handlers/index.js b/app/scripts/lib/rpc-method-middleware/handlers/index.js index fb0c1ef8f..9b0fc02fe 100644 --- a/app/scripts/lib/rpc-method-middleware/handlers/index.js +++ b/app/scripts/lib/rpc-method-middleware/handlers/index.js @@ -1,14 +1,20 @@ import addEthereumChain from './add-ethereum-chain'; -import switchEthereumChain from './switch-ethereum-chain'; +import ethAccounts from './eth-accounts'; import getProviderState from './get-provider-state'; import logWeb3ShimUsage from './log-web3-shim-usage'; +import requestAccounts from './request-accounts'; +import sendMetadata from './send-metadata'; +import switchEthereumChain from './switch-ethereum-chain'; import watchAsset from './watch-asset'; const handlers = [ addEthereumChain, - switchEthereumChain, + ethAccounts, getProviderState, logWeb3ShimUsage, + requestAccounts, + sendMetadata, + switchEthereumChain, watchAsset, ]; export default handlers; diff --git a/app/scripts/lib/rpc-method-middleware/handlers/request-accounts.js b/app/scripts/lib/rpc-method-middleware/handlers/request-accounts.js new file mode 100644 index 000000000..d9cb68f4b --- /dev/null +++ b/app/scripts/lib/rpc-method-middleware/handlers/request-accounts.js @@ -0,0 +1,108 @@ +import { ethErrors } from 'eth-rpc-errors'; +import { MESSAGE_TYPE } from '../../../../../shared/constants/app'; + +/** + * This method attempts to retrieve the Ethereum accounts available to the + * requester, or initiate a request for account access if none are currently + * available. It is essentially a wrapper of wallet_requestPermissions that + * only errors if the user rejects the request. We maintain the method for + * backwards compatibility reasons. + */ + +const requestEthereumAccounts = { + methodNames: [MESSAGE_TYPE.ETH_REQUEST_ACCOUNTS], + implementation: requestEthereumAccountsHandler, + hookNames: { + origin: true, + getAccounts: true, + getUnlockPromise: true, + hasPermission: true, + requestAccountsPermission: true, + }, +}; +export default requestEthereumAccounts; + +// Used to rate-limit pending requests to one per origin +const locks = new Set(); + +/** + * @typedef {Record} RequestEthereumAccountsOptions + * @property {string} origin - The requesting origin. + * @property {Function} getAccounts - Gets the accounts for the requesting + * origin. + * @property {Function} getUnlockPromise - Gets a promise that resolves when + * the extension unlocks. + * @property {Function} hasPermission - Returns whether the requesting origin + * has the specified permission. + * @property {Function} requestAccountsPermission - Requests the `eth_accounts` + * permission for the requesting origin. + */ + +/** + * + * @param {import('json-rpc-engine').JsonRpcRequest} _req - The JSON-RPC request object. + * @param {import('json-rpc-engine').JsonRpcResponse} res - The JSON-RPC response object. + * @param {Function} _next - The json-rpc-engine 'next' callback. + * @param {Function} end - The json-rpc-engine 'end' callback. + * @param {RequestEthereumAccountsOptions} options - The RPC method hooks. + */ +async function requestEthereumAccountsHandler( + _req, + res, + _next, + end, + { + origin, + getAccounts, + getUnlockPromise, + hasPermission, + requestAccountsPermission, + }, +) { + if (locks.has(origin)) { + res.error = ethErrors.rpc.resourceUnavailable( + `Already processing ${MESSAGE_TYPE.ETH_REQUEST_ACCOUNTS}. Please wait.`, + ); + return end(); + } + + if (hasPermission(MESSAGE_TYPE.ETH_ACCOUNTS)) { + // We wait for the extension to unlock in this case only, because permission + // requests are handled when the extension is unlocked, regardless of the + // lock state when they were received. + try { + locks.add(origin); + await getUnlockPromise(); + res.result = await getAccounts(); + end(); + } catch (error) { + end(error); + } finally { + locks.delete(origin); + } + return undefined; + } + + // If no accounts, request the accounts permission + try { + await requestAccountsPermission(); + } catch (err) { + res.error = err; + return end(); + } + + // Get the approved accounts + const accounts = await getAccounts(); + /* istanbul ignore else: too hard to induce, see below comment */ + if (accounts.length > 0) { + res.result = accounts; + } else { + // This should never happen, because it should be caught in the + // above catch clause + res.error = ethErrors.rpc.internal( + 'Accounts unexpectedly unavailable. Please report this bug.', + ); + } + + return end(); +} diff --git a/app/scripts/lib/rpc-method-middleware/handlers/send-metadata.js b/app/scripts/lib/rpc-method-middleware/handlers/send-metadata.js new file mode 100644 index 000000000..a32fa497f --- /dev/null +++ b/app/scripts/lib/rpc-method-middleware/handlers/send-metadata.js @@ -0,0 +1,58 @@ +import { ethErrors } from 'eth-rpc-errors'; +import { MESSAGE_TYPE } from '../../../../../shared/constants/app'; + +/** + * This internal method is used by our external provider to send metadata about + * permission subjects so that we can e.g. display a proper name and icon in + * our UI. + */ + +const sendMetadata = { + methodNames: [MESSAGE_TYPE.SEND_METADATA], + implementation: sendMetadataHandler, + hookNames: { + addSubjectMetadata: true, + subjectType: true, + }, +}; +export default sendMetadata; + +/** + * @typedef {Record} SendMetadataOptions + * @property {Function} addSubjectMetadata - A function that records subject + * metadata, bound to the requesting origin. + * @property {string} subjectType - The type of the requesting origin / subject. + */ + +/** + * @param {import('json-rpc-engine').JsonRpcRequest} req - The JSON-RPC request object. + * @param {import('json-rpc-engine').JsonRpcResponse} res - The JSON-RPC response object. + * @param {Function} _next - The json-rpc-engine 'next' callback. + * @param {Function} end - The json-rpc-engine 'end' callback. + * @param {SendMetadataOptions} options + */ +function sendMetadataHandler( + req, + res, + _next, + end, + { addSubjectMetadata, subjectType }, +) { + const { origin, params } = req; + if (params && typeof params === 'object' && !Array.isArray(params)) { + const { icon = null, name = null, ...remainingParams } = params; + + addSubjectMetadata({ + ...remainingParams, + iconUrl: icon, + name, + subjectType, + origin, + }); + } else { + return end(ethErrors.rpc.invalidParams({ data: params })); + } + + res.result = true; + return end(); +} diff --git a/app/scripts/lib/seed-phrase-verifier.test.js b/app/scripts/lib/seed-phrase-verifier.test.js index 1a6443935..2f56c4b9a 100644 --- a/app/scripts/lib/seed-phrase-verifier.test.js +++ b/app/scripts/lib/seed-phrase-verifier.test.js @@ -1,127 +1,118 @@ -import { strict as assert } from 'assert'; +/** + * @jest-environment node + * https://github.com/facebook/jest/issues/7780 + */ import { cloneDeep } from 'lodash'; import KeyringController from 'eth-keyring-controller'; import firstTimeState from '../first-time-state'; import mockEncryptor from '../../../test/lib/mock-encryptor'; import seedPhraseVerifier from './seed-phrase-verifier'; -describe('SeedPhraseVerifier', function () { - describe('verifyAccounts', function () { +describe('SeedPhraseVerifier', () => { + describe('verifyAccounts', () => { const password = 'passw0rd1'; const hdKeyTree = 'HD Key Tree'; let keyringController; let primaryKeyring; - beforeEach(async function () { + beforeEach(async () => { keyringController = new KeyringController({ initState: cloneDeep(firstTimeState), encryptor: mockEncryptor, }); - assert(keyringController); + expect.any(keyringController); await keyringController.createNewVaultAndKeychain(password); primaryKeyring = keyringController.getKeyringsByType(hdKeyTree)[0]; }); - it('should be able to verify created account with seed words', async function () { + it('should be able to verify created account with seed words', async () => { const createdAccounts = await primaryKeyring.getAccounts(); - assert.equal(createdAccounts.length, 1); + expect(createdAccounts).toHaveLength(1); const serialized = await primaryKeyring.serialize(); const seedWords = serialized.mnemonic; - assert.notEqual(seedWords.length, 0); + expect(seedWords).not.toHaveLength(0); await seedPhraseVerifier.verifyAccounts(createdAccounts, seedWords); }); - it('should be able to verify created account (upper case) with seed words', async function () { + it('should be able to verify created account (upper case) with seed words', async () => { const createdAccounts = await primaryKeyring.getAccounts(); - assert.equal(createdAccounts.length, 1); + expect(createdAccounts).toHaveLength(1); const upperCaseAccounts = [createdAccounts[0].toUpperCase()]; const serialized = await primaryKeyring.serialize(); const seedWords = serialized.mnemonic; - assert.notEqual(seedWords.length, 0); + expect(seedWords).not.toHaveLength(0); await seedPhraseVerifier.verifyAccounts(upperCaseAccounts, seedWords); }); - it('should be able to verify created account (lower case) with seed words', async function () { + it('should be able to verify created account (lower case) with seed words', async () => { const createdAccounts = await primaryKeyring.getAccounts(); - assert.equal(createdAccounts.length, 1); + expect(createdAccounts).toHaveLength(1); const lowerCaseAccounts = [createdAccounts[0].toLowerCase()]; const serialized = await primaryKeyring.serialize(); const seedWords = serialized.mnemonic; - assert.notEqual(seedWords.length, 0); + expect(seedWords).not.toHaveLength(0); await seedPhraseVerifier.verifyAccounts(lowerCaseAccounts, seedWords); }); - it('should return error with good but different seed words', async function () { + it('should return error with good but different seed words', async () => { const createdAccounts = await primaryKeyring.getAccounts(); - assert.equal(createdAccounts.length, 1); + expect(createdAccounts).toHaveLength(1); await primaryKeyring.serialize(); const seedWords = 'debris dizzy just program just float decrease vacant alarm reduce speak stadium'; - try { + await expect(async () => { await seedPhraseVerifier.verifyAccounts(createdAccounts, seedWords); - assert.fail('Should reject'); - } catch (err) { - assert.ok( - err.message.indexOf('Not identical accounts!') >= 0, - 'Wrong error message', - ); - } + }).rejects.toThrow('Not identical accounts!'); }); - it('should return error with undefined existing accounts', async function () { + it('should return error with undefined existing accounts', async () => { const createdAccounts = await primaryKeyring.getAccounts(); - assert.equal(createdAccounts.length, 1); + expect(createdAccounts).toHaveLength(1); await primaryKeyring.serialize(); const seedWords = 'debris dizzy just program just float decrease vacant alarm reduce speak stadium'; - try { + await expect(async () => { await seedPhraseVerifier.verifyAccounts(undefined, seedWords); - assert.fail('Should reject'); - } catch (err) { - assert.equal(err.message, 'No created accounts defined.'); - } + }).rejects.toThrow('No created accounts defined.'); }); - it('should return error with empty accounts array', async function () { + it('should return error with empty accounts array', async () => { const createdAccounts = await primaryKeyring.getAccounts(); - assert.equal(createdAccounts.length, 1); + expect(createdAccounts).toHaveLength(1); await primaryKeyring.serialize(); const seedWords = 'debris dizzy just program just float decrease vacant alarm reduce speak stadium'; - try { + await expect(async () => { await seedPhraseVerifier.verifyAccounts([], seedWords); - assert.fail('Should reject'); - } catch (err) { - assert.equal(err.message, 'No created accounts defined.'); - } + }).rejects.toThrow('No created accounts defined.'); }); - it('should be able to verify more than one created account with seed words', async function () { + it('should be able to verify more than one created account with seed words', async () => { await keyringController.addNewAccount(primaryKeyring); await keyringController.addNewAccount(primaryKeyring); const createdAccounts = await primaryKeyring.getAccounts(); - assert.equal(createdAccounts.length, 3); + expect(createdAccounts).toHaveLength(3); const serialized = await primaryKeyring.serialize(); const seedWords = serialized.mnemonic; - assert.notEqual(seedWords.length, 0); + expect(seedWords).not.toHaveLength(0); await seedPhraseVerifier.verifyAccounts(createdAccounts, seedWords); }); diff --git a/app/scripts/lib/segment.js b/app/scripts/lib/segment.js index 5c5ab5bac..61742bd58 100644 --- a/app/scripts/lib/segment.js +++ b/app/scripts/lib/segment.js @@ -33,10 +33,7 @@ const SEGMENT_FLUSH_INTERVAL = SECOND * 5; * @param {number} flushInterval - ms interval to flush queue and send to segment * @returns {SegmentInterface} */ -export const createSegmentMock = ( - flushAt = SEGMENT_FLUSH_AT, - flushInterval = SEGMENT_FLUSH_INTERVAL, -) => { +export const createSegmentMock = (flushAt = SEGMENT_FLUSH_AT) => { const segmentMock = { // Internal queue to keep track of events and properly mimic segment's // queueing behavior. @@ -77,8 +74,7 @@ export const createSegmentMock = ( // noop }, }; - // Mimic the flushInterval behavior with an interval - setInterval(segmentMock.flush, flushInterval); + return segmentMock; }; diff --git a/app/scripts/lib/typed-message-manager.test.js b/app/scripts/lib/typed-message-manager.test.js index ce70011ce..1098f7f79 100644 --- a/app/scripts/lib/typed-message-manager.test.js +++ b/app/scripts/lib/typed-message-manager.test.js @@ -1,9 +1,8 @@ -import { strict as assert } from 'assert'; import sinon from 'sinon'; import { TRANSACTION_STATUSES } from '../../../shared/constants/transaction'; import TypedMessageManager from './typed-message-manager'; -describe('Typed Message Manager', function () { +describe('Typed Message Manager', () => { let typedMessageManager, msgParamsV1, msgParamsV3, @@ -14,7 +13,7 @@ describe('Typed Message Manager', function () { const address = '0xc42edfcc21ed14dda456aa0756c153f7985d8813'; - beforeEach(async function () { + beforeEach(async () => { typedMessageManager = new TypedMessageManager({ getCurrentChainId: sinon.fake.returns('0x1'), metricsEvent: sinon.fake(), @@ -81,47 +80,48 @@ describe('Typed Message Manager', function () { numberMsgId = parseInt(msgId, 10); }); - it('supports version 1 of signedTypedData', function () { + it('supports version 1 of signedTypedData', () => { typedMessageManager.addUnapprovedMessage(msgParamsV1, null, 'V1'); - assert.equal( - messages[messages.length - 1].msgParams.data, + expect(messages[messages.length - 1].msgParams.data).toStrictEqual( msgParamsV1.data, ); }); - it('has params address', function () { - assert.equal(typedMsgs[msgId].msgParams.from, address); + it('has params address', () => { + expect(typedMsgs[msgId].msgParams.from).toStrictEqual(address); }); - it('adds to unapproved messages and sets status to unapproved', function () { - assert.equal(typedMsgs[msgId].status, TRANSACTION_STATUSES.UNAPPROVED); + it('adds to unapproved messages and sets status to unapproved', () => { + expect(typedMsgs[msgId].status).toStrictEqual( + TRANSACTION_STATUSES.UNAPPROVED, + ); }); - it('validates params', function () { - assert.doesNotThrow(() => { + it('validates params', async () => { + await expect(() => { typedMessageManager.validateParams(messages[0].msgParams); - }, 'Does not throw with valid parameters'); + }).not.toThrow(); }); - it('gets unapproved by id', function () { + it('gets unapproved by id', () => { const getMsg = typedMessageManager.getMsg(numberMsgId); - assert.equal(getMsg.id, numberMsgId); + expect(getMsg.id).toStrictEqual(numberMsgId); }); - it('approves messages', async function () { + it('approves messages', async () => { const messageMetaMaskId = messages[0].msgParams; typedMessageManager.approveMessage(messageMetaMaskId); - assert.equal(messages[0].status, TRANSACTION_STATUSES.APPROVED); + expect(messages[0].status).toStrictEqual(TRANSACTION_STATUSES.APPROVED); }); - it('sets msg status to signed and adds a raw sig to message details', function () { + it('sets msg status to signed and adds a raw sig to message details', () => { typedMessageManager.setMsgStatusSigned(numberMsgId, 'raw sig'); - assert.equal(messages[0].status, TRANSACTION_STATUSES.SIGNED); - assert.equal(messages[0].rawSig, 'raw sig'); + expect(messages[0].status).toStrictEqual(TRANSACTION_STATUSES.SIGNED); + expect(messages[0].rawSig).toStrictEqual('raw sig'); }); - it('rejects message', function () { + it('rejects message', () => { typedMessageManager.rejectMsg(numberMsgId); - assert.equal(messages[0].status, TRANSACTION_STATUSES.REJECTED); + expect(messages[0].status).toStrictEqual(TRANSACTION_STATUSES.REJECTED); }); }); diff --git a/app/scripts/lib/util.test.js b/app/scripts/lib/util.test.js index d25949bc4..62c33b5a5 100644 --- a/app/scripts/lib/util.test.js +++ b/app/scripts/lib/util.test.js @@ -1,5 +1,3 @@ -import { strict as assert } from 'assert'; -import sinon from 'sinon'; import { isPrefixedFormattedHexString } from '../../../shared/modules/network.utils'; import { ENVIRONMENT_TYPE_POPUP, @@ -13,201 +11,154 @@ import { } from '../../../shared/constants/app'; import { getEnvironmentType, getPlatform } from './util'; -describe('app utils', function () { - describe('getEnvironmentType', function () { - it('should return popup type', function () { +describe('app utils', () => { + describe('getEnvironmentType', () => { + it('should return popup type', () => { const environmentType = getEnvironmentType( 'http://extension-id/popup.html', ); - assert.equal(environmentType, ENVIRONMENT_TYPE_POPUP); + expect(environmentType).toStrictEqual(ENVIRONMENT_TYPE_POPUP); }); - it('should return notification type', function () { + it('should return notification type', () => { const environmentType = getEnvironmentType( 'http://extension-id/notification.html', ); - assert.equal(environmentType, ENVIRONMENT_TYPE_NOTIFICATION); + expect(environmentType).toStrictEqual(ENVIRONMENT_TYPE_NOTIFICATION); }); - it('should return fullscreen type for home.html', function () { + it('should return fullscreen type for home.html', () => { const environmentType = getEnvironmentType( 'http://extension-id/home.html', ); - assert.equal(environmentType, ENVIRONMENT_TYPE_FULLSCREEN); + expect(environmentType).toStrictEqual(ENVIRONMENT_TYPE_FULLSCREEN); }); - it('should return fullscreen type for phishing.html', function () { + it('should return fullscreen type for phishing.html', () => { const environmentType = getEnvironmentType( 'http://extension-id/phishing.html', ); - assert.equal(environmentType, ENVIRONMENT_TYPE_FULLSCREEN); + expect(environmentType).toStrictEqual(ENVIRONMENT_TYPE_FULLSCREEN); }); - it('should return background type', function () { + it('should return background type', () => { const environmentType = getEnvironmentType( 'http://extension-id/_generated_background_page.html', ); - assert.equal(environmentType, ENVIRONMENT_TYPE_BACKGROUND); + expect(environmentType).toStrictEqual(ENVIRONMENT_TYPE_BACKGROUND); }); - it('should return the correct type for a URL with a hash fragment', function () { + it('should return the correct type for a URL with a hash fragment', () => { const environmentType = getEnvironmentType( 'http://extension-id/popup.html#hash', ); - assert.equal(environmentType, ENVIRONMENT_TYPE_POPUP); + expect(environmentType).toStrictEqual(ENVIRONMENT_TYPE_POPUP); }); - it('should return the correct type for a URL with query parameters', function () { + it('should return the correct type for a URL with query parameters', () => { const environmentType = getEnvironmentType( 'http://extension-id/popup.html?param=foo', ); - assert.equal(environmentType, ENVIRONMENT_TYPE_POPUP); + expect(environmentType).toStrictEqual(ENVIRONMENT_TYPE_POPUP); }); - it('should return the correct type for a URL with query parameters and a hash fragment', function () { + it('should return the correct type for a URL with query parameters and a hash fragment', () => { const environmentType = getEnvironmentType( 'http://extension-id/popup.html?param=foo#hash', ); - assert.equal(environmentType, ENVIRONMENT_TYPE_POPUP); + expect(environmentType).toStrictEqual(ENVIRONMENT_TYPE_POPUP); }); }); - describe('isPrefixedFormattedHexString', function () { - it('should return true for valid hex strings', function () { - assert.equal( - isPrefixedFormattedHexString('0x1'), - true, - 'should return true', - ); + describe('isPrefixedFormattedHexString', () => { + it('should return true for valid hex strings', () => { + expect(isPrefixedFormattedHexString('0x1')).toStrictEqual(true); - assert.equal( - isPrefixedFormattedHexString('0xa'), - true, - 'should return true', - ); + expect(isPrefixedFormattedHexString('0xa')).toStrictEqual(true); - assert.equal( + expect( isPrefixedFormattedHexString('0xabcd1123fae909aad87452'), - true, - 'should return true', - ); + ).toStrictEqual(true); }); - it('should return false for invalid hex strings', function () { - assert.equal( - isPrefixedFormattedHexString('0x'), - false, - 'should return false', - ); + it('should return false for invalid hex strings', () => { + expect(isPrefixedFormattedHexString('0x')).toStrictEqual(false); - assert.equal( - isPrefixedFormattedHexString('0x0'), - false, - 'should return false', - ); + expect(isPrefixedFormattedHexString('0x0')).toStrictEqual(false); - assert.equal( - isPrefixedFormattedHexString('0x01'), - false, - 'should return false', - ); + expect(isPrefixedFormattedHexString('0x01')).toStrictEqual(false); - assert.equal( - isPrefixedFormattedHexString(' 0x1'), - false, - 'should return false', - ); + expect(isPrefixedFormattedHexString(' 0x1')).toStrictEqual(false); - assert.equal( - isPrefixedFormattedHexString('0x1 '), - false, - 'should return false', - ); + expect(isPrefixedFormattedHexString('0x1 ')).toStrictEqual(false); - assert.equal( - isPrefixedFormattedHexString('0x1afz'), - false, - 'should return false', - ); + expect(isPrefixedFormattedHexString('0x1afz')).toStrictEqual(false); - assert.equal( - isPrefixedFormattedHexString('z'), - false, - 'should return false', - ); + expect(isPrefixedFormattedHexString('z')).toStrictEqual(false); - assert.equal( - isPrefixedFormattedHexString(2), - false, - 'should return false', - ); + expect(isPrefixedFormattedHexString(2)).toStrictEqual(false); - assert.equal( - isPrefixedFormattedHexString(['0x1']), - false, - 'should return false', - ); + expect(isPrefixedFormattedHexString(['0x1'])).toStrictEqual(false); - assert.equal( - isPrefixedFormattedHexString(), - false, - 'should return false', - ); + expect(isPrefixedFormattedHexString()).toStrictEqual(false); }); }); - describe('getPlatform', function () { - const setBrowserSpecificWindow = (browser) => { - switch (browser) { - case 'firefox': { - sinon.stub(window, 'navigator').value({ - userAgent: + describe('getPlatform', () => { + let userAgent, setBrowserSpecificWindow; + + beforeEach(() => { + userAgent = jest.spyOn(window.navigator, 'userAgent', 'get'); + + setBrowserSpecificWindow = (browser) => { + switch (browser) { + case 'firefox': { + userAgent.mockReturnValue( 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:95.0) Gecko/20100101 Firefox/95.0', - }); - break; - } - case 'edge': { - sinon.stub(window, 'navigator').value({ - userAgent: + ); + break; + } + case 'edge': { + userAgent.mockReturnValue( 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.54 Safari/537.36 Edg/95.0.1020.30', - }); - break; - } - case 'opera': { - sinon.stub(window, 'navigator').value({ - userAgent: + ); + break; + } + case 'opera': { + userAgent.mockReturnValue( 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.81 Safari/537.36 OPR/80.0.4170.63', - }); - break; - } - default: { - sinon.stub(window, 'navigator').value({ - userAgent: + ); + + break; + } + default: { + userAgent.mockReturnValue( 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.81 Safari/537.36', - }); - break; + ); + break; + } } - } - }; + }; + }); - it('should detect Firefox', function () { + it('should detect Firefox', () => { setBrowserSpecificWindow('firefox'); - assert.equal(getPlatform(), PLATFORM_FIREFOX); + expect(getPlatform()).toStrictEqual(PLATFORM_FIREFOX); }); - it('should detect Edge', function () { + it('should detect Edge', () => { setBrowserSpecificWindow('edge'); - assert.equal(getPlatform(), PLATFORM_EDGE); + expect(getPlatform()).toStrictEqual(PLATFORM_EDGE); }); - it('should detect Opera', function () { + it('should detect Opera', () => { setBrowserSpecificWindow('opera'); - assert.equal(getPlatform(), PLATFORM_OPERA); + expect(getPlatform()).toStrictEqual(PLATFORM_OPERA); }); - it('should detect Chrome', function () { + it('should detect Chrome', () => { setBrowserSpecificWindow('chrome'); - assert.equal(getPlatform(), PLATFORM_CHROME); + expect(getPlatform()).toStrictEqual(PLATFORM_CHROME); }); }); }); diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index d1c17c240..9f4118f60 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -9,6 +9,7 @@ import createFilterMiddleware from 'eth-json-rpc-filters'; import createSubscriptionManager from 'eth-json-rpc-filters/subscriptionManager'; import { providerAsMiddleware } from 'eth-json-rpc-middleware'; import KeyringController from 'eth-keyring-controller'; +import { errorCodes as rpcErrorCodes, ethErrors } from 'eth-rpc-errors'; import { Mutex } from 'await-semaphore'; import { stripHexPrefix } from 'ethereumjs-util'; import log from 'loglevel'; @@ -18,7 +19,6 @@ import LatticeKeyring from 'eth-lattice-keyring'; import { MetaMaskKeyring as QRHardwareKeyring } from '@keystonehq/metamask-airgapped-keyring'; import EthQuery from 'eth-query'; import nanoid from 'nanoid'; -import { ethErrors } from 'eth-rpc-errors'; import { captureException } from '@sentry/browser'; import { AddressBookController, @@ -35,6 +35,11 @@ import { AssetsContractController, CollectibleDetectionController, } from '@metamask/controllers'; +import { + PermissionController, + SubjectMetadataController, +} from '@metamask/snap-controllers'; + import { TRANSACTION_STATUSES } from '../../shared/constants/transaction'; import { GAS_API_BASE_URL, @@ -46,10 +51,17 @@ import { DEVICE_NAMES, KEYRING_TYPES, } from '../../shared/constants/hardware-wallets'; +import { + CaveatTypes, + RestrictedMethods, +} from '../../shared/constants/permissions'; import { UI_NOTIFICATIONS } from '../../shared/notifications'; import { toChecksumHexAddress } from '../../shared/modules/hexstring-utils'; import { MILLISECOND } from '../../shared/constants/time'; -import { POLLING_TOKEN_ENVIRONMENT_TYPES } from '../../shared/constants/app'; +import { + POLLING_TOKEN_ENVIRONMENT_TYPES, + SUBJECT_TYPES, +} from '../../shared/constants/app'; import { hexToDecimal } from '../../ui/helpers/utils/conversions.util'; import ComposableObservableStore from './lib/ComposableObservableStore'; @@ -77,15 +89,22 @@ import TypedMessageManager from './lib/typed-message-manager'; import TransactionController from './controllers/transactions'; import DetectTokensController from './controllers/detect-tokens'; import SwapsController from './controllers/swaps'; -import { PermissionsController } from './controllers/permissions'; -import { NOTIFICATION_NAMES } from './controllers/permissions/enums'; -import getRestrictedMethods from './controllers/permissions/restrictedMethods'; -import nodeify from './lib/nodeify'; import accountImporter from './account-import-strategies'; import seedPhraseVerifier from './lib/seed-phrase-verifier'; import MetaMetricsController from './controllers/metametrics'; import { segment } from './lib/segment'; import createMetaRPCHandler from './lib/createMetaRPCHandler'; +import { + CaveatMutatorFactories, + getCaveatSpecifications, + getChangedAccounts, + getPermissionBackgroundApiMethods, + getPermissionSpecifications, + getPermittedAccountsByOrigin, + PermissionLogController, + NOTIFICATION_NAMES, + unrestrictedMethods, +} from './controllers/permissions'; export const METAMASK_CONTROLLER_EVENTS = { // Fired after state changes that impact the extension badge (unapproved msg count) @@ -187,32 +206,36 @@ export default class MetamaskController extends EventEmitter { provider: this.provider, }); - 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, - ), - }); + 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, + ), + }, + {}, + initState.CollectiblesController, + ); process.env.COLLECTIBLES_V1 && (this.collectibleDetectionController = new CollectibleDetectionController( @@ -454,24 +477,43 @@ export default class MetamaskController extends EventEmitter { this.keyringController.on('unlock', () => this._onUnlock()); this.keyringController.on('lock', () => this._onLock()); - this.permissionsController = new PermissionsController( - { - approvals: this.approvalController, - getKeyringAccounts: this.keyringController.getAccounts.bind( + const getIdentities = () => + this.preferencesController.store.getState().identities; + + this.permissionController = new PermissionController({ + messenger: this.controllerMessenger.getRestricted({ + name: 'PermissionController', + allowedActions: [ + `${this.approvalController.name}:addRequest`, + `${this.approvalController.name}:hasRequest`, + `${this.approvalController.name}:acceptRequest`, + `${this.approvalController.name}:rejectRequest`, + ], + }), + state: initState.PermissionController, + caveatSpecifications: getCaveatSpecifications({ getIdentities }), + permissionSpecifications: getPermissionSpecifications({ + getIdentities, + getAllAccounts: this.keyringController.getAccounts.bind( this.keyringController, ), - getRestrictedMethods, - getUnlockPromise: this.appStateController.getUnlockPromise.bind( - this.appStateController, - ), - isUnlocked: this.isUnlocked.bind(this), - notifyDomain: this.notifyConnections.bind(this), - notifyAllDomains: this.notifyAllConnections.bind(this), - preferences: this.preferencesController.store, - }, - initState.PermissionsController, - initState.PermissionsMetadata, - ); + }), + unrestrictedMethods, + }); + + this.permissionLogController = new PermissionLogController({ + restrictedMethods: new Set(Object.keys(RestrictedMethods)), + initState: initState.PermissionLogController, + }); + + this.subjectMetadataController = new SubjectMetadataController({ + messenger: this.controllerMessenger.getRestricted({ + name: 'SubjectMetadataController', + allowedActions: [`${this.permissionController.name}:hasPermissions`], + }), + state: initState.SubjectMetadataController, + subjectCacheLimit: 100, + }); this.detectTokensController = new DetectTokensController({ preferences: this.preferencesController, @@ -508,9 +550,7 @@ export default class MetamaskController extends EventEmitter { this.txController = new TransactionController({ initState: initState.TransactionController || initState.TransactionManager, - getPermittedAccounts: this.permissionsController.getAccounts.bind( - this.permissionsController, - ), + getPermittedAccounts: this.getPermittedAccounts.bind(this), getProviderConfig: this.networkController.getProviderConfig.bind( this.networkController, ), @@ -670,8 +710,9 @@ export default class MetamaskController extends EventEmitter { AlertController: this.alertController.store, OnboardingController: this.onboardingController.store, IncomingTransactionsController: this.incomingTransactionsController.store, - PermissionsController: this.permissionsController.permissions, - PermissionsMetadata: this.permissionsController.store, + PermissionController: this.permissionController, + PermissionLogController: this.permissionLogController.store, + SubjectMetadataController: this.subjectMetadataController, ThreeBoxController: this.threeBoxController.store, NotificationController: this.notificationController, GasFeeController: this.gasFeeController, @@ -702,8 +743,9 @@ export default class MetamaskController extends EventEmitter { OnboardingController: this.onboardingController.store, IncomingTransactionsController: this.incomingTransactionsController .store, - PermissionsController: this.permissionsController.permissions, - PermissionsMetadata: this.permissionsController.store, + PermissionController: this.permissionController, + PermissionLogController: this.permissionLogController.store, + SubjectMetadataController: this.subjectMetadataController, ThreeBoxController: this.threeBoxController.store, SwapsController: this.swapsController.store, EnsController: this.ensController.store, @@ -738,10 +780,77 @@ export default class MetamaskController extends EventEmitter { ); }); + this.setupControllerEventSubscriptions(); + // TODO:LegacyProvider: Delete this.publicConfigStore = this.createPublicConfigStore(); } + /** + * Sets up BaseController V2 event subscriptions. Currently, this includes + * the subscriptions necessary to notify permission subjects of account + * changes. + * + * Some of the subscriptions in this method are ControllerMessenger selector + * event subscriptions. See the relevant @metamask/controllers documentation + * for more information. + * + * Note that account-related notifications emitted when the extension + * becomes unlocked are handled in MetaMaskController._onUnlock. + */ + setupControllerEventSubscriptions() { + const handleAccountsChange = async (origin, newAccounts) => { + if (this.isUnlocked()) { + this.notifyConnections(origin, { + method: NOTIFICATION_NAMES.accountsChanged, + // This should be the same as the return value of `eth_accounts`, + // namely an array of the current / most recently selected Ethereum + // account. + params: + newAccounts.length < 2 + ? // If the length is 1 or 0, the accounts are sorted by definition. + newAccounts + : // If the length is 2 or greater, we have to execute + // `eth_accounts` vi this method. + await this.getPermittedAccounts(origin), + }); + } + + this.permissionLogController.updateAccountsHistory(origin, newAccounts); + }; + + // This handles account changes whenever the selected address changes. + let lastSelectedAddress; + this.preferencesController.store.subscribe(async ({ selectedAddress }) => { + if (selectedAddress && selectedAddress !== lastSelectedAddress) { + lastSelectedAddress = selectedAddress; + const permittedAccountsMap = getPermittedAccountsByOrigin( + this.permissionController.state, + ); + + for (const [origin, accounts] of permittedAccountsMap.entries()) { + if (accounts.includes(selectedAddress)) { + handleAccountsChange(origin, accounts); + } + } + } + }); + + // This handles account changes every time relevant permission state + // changes, for any reason. + this.controllerMessenger.subscribe( + `${this.permissionController.name}:stateChange`, + async (currentValue, previousValue) => { + const changedAccounts = getChangedAccounts(currentValue, previousValue); + + for (const [origin, accounts] of changedAccounts.entries()) { + handleAccountsChange(origin, accounts); + } + }, + getPermittedAccountsByOrigin, + ); + } + /** * Constructor helper: initialize a provider. */ @@ -759,7 +868,7 @@ export default class MetamaskController extends EventEmitter { const selectedAddress = this.preferencesController.getSelectedAddress(); return selectedAddress ? [selectedAddress] : []; } else if (this.isUnlocked()) { - return await this.permissionsController.getAccounts(origin); + return await this.getPermittedAccounts(origin); } return []; // changing this is a breaking change }, @@ -835,7 +944,7 @@ export default class MetamaskController extends EventEmitter { return { isUnlocked: this.isUnlocked(), ...this.getProviderNetworkState(), - accounts: await this.permissionsController.getAccounts(origin), + accounts: await this.getPermittedAccounts(origin), }; } @@ -882,496 +991,402 @@ export default class MetamaskController extends EventEmitter { */ getApi() { const { + addressBookController, alertController, approvalController, + appStateController, + collectiblesController, + collectibleDetectionController, + currencyRateController, + detectTokensController, + ensController, + gasFeeController, keyringController, metaMetricsController, networkController, + notificationController, onboardingController, - permissionsController, + permissionController, preferencesController, + qrHardwareKeyring, swapsController, threeBoxController, - txController, tokensController, - collectiblesController, + txController, } = this; return { // etc - getState: (cb) => cb(null, this.getState()), - setCurrentCurrency: nodeify( - this.currencyRateController.setCurrentCurrency.bind( - this.currencyRateController, - ), + getState: this.getState.bind(this), + setCurrentCurrency: currencyRateController.setCurrentCurrency.bind( + currencyRateController, + ), + setUseBlockie: preferencesController.setUseBlockie.bind( + preferencesController, + ), + setUseNonceField: preferencesController.setUseNonceField.bind( + preferencesController, + ), + setUsePhishDetect: preferencesController.setUsePhishDetect.bind( + preferencesController, + ), + setUseTokenDetection: preferencesController.setUseTokenDetection.bind( + preferencesController, + ), + setUseCollectibleDetection: preferencesController.setUseCollectibleDetection.bind( + preferencesController, + ), + setOpenSeaEnabled: preferencesController.setOpenSeaEnabled.bind( + preferencesController, ), - setUseBlockie: this.setUseBlockie.bind(this), - setUseNonceField: this.setUseNonceField.bind(this), - setUsePhishDetect: this.setUsePhishDetect.bind(this), - setUseTokenDetection: nodeify( - this.preferencesController.setUseTokenDetection, - this.preferencesController, + setIpfsGateway: preferencesController.setIpfsGateway.bind( + preferencesController, ), - setUseCollectibleDetection: nodeify( - this.preferencesController.setUseCollectibleDetection, - this.preferencesController, + setParticipateInMetaMetrics: metaMetricsController.setParticipateInMetaMetrics.bind( + metaMetricsController, ), - setOpenSeaEnabled: nodeify( - this.preferencesController.setOpenSeaEnabled, - this.preferencesController, + setCurrentLocale: preferencesController.setCurrentLocale.bind( + preferencesController, ), - setIpfsGateway: this.setIpfsGateway.bind(this), - setParticipateInMetaMetrics: this.setParticipateInMetaMetrics.bind(this), - setCurrentLocale: this.setCurrentLocale.bind(this), markPasswordForgotten: this.markPasswordForgotten.bind(this), unMarkPasswordForgotten: this.unMarkPasswordForgotten.bind(this), safelistPhishingDomain: this.safelistPhishingDomain.bind(this), - getRequestAccountTabIds: (cb) => cb(null, this.getRequestAccountTabIds()), - getOpenMetamaskTabsIds: (cb) => cb(null, this.getOpenMetamaskTabsIds()), + getRequestAccountTabIds: this.getRequestAccountTabIds, + getOpenMetamaskTabsIds: this.getOpenMetamaskTabsIds, // primary HD keyring management - addNewAccount: nodeify(this.addNewAccount, this), - verifySeedPhrase: nodeify(this.verifySeedPhrase, this), - resetAccount: nodeify(this.resetAccount, this), - removeAccount: nodeify(this.removeAccount, this), - importAccountWithStrategy: nodeify(this.importAccountWithStrategy, this), + addNewAccount: this.addNewAccount.bind(this), + verifySeedPhrase: this.verifySeedPhrase.bind(this), + resetAccount: this.resetAccount.bind(this), + removeAccount: this.removeAccount.bind(this), + importAccountWithStrategy: this.importAccountWithStrategy.bind(this), // hardware wallets - connectHardware: nodeify(this.connectHardware, this), - forgetDevice: nodeify(this.forgetDevice, this), - checkHardwareStatus: nodeify(this.checkHardwareStatus, this), - unlockHardwareWalletAccount: nodeify( - this.unlockHardwareWalletAccount, + connectHardware: this.connectHardware.bind(this), + forgetDevice: this.forgetDevice.bind(this), + checkHardwareStatus: this.checkHardwareStatus.bind(this), + unlockHardwareWalletAccount: this.unlockHardwareWalletAccount.bind(this), + setLedgerTransportPreference: this.setLedgerTransportPreference.bind( this, ), - setLedgerTransportPreference: nodeify( - this.setLedgerTransportPreference, + attemptLedgerTransportCreation: this.attemptLedgerTransportCreation.bind( this, ), - attemptLedgerTransportCreation: nodeify( - this.attemptLedgerTransportCreation, - this, - ), - establishLedgerTransportPreference: nodeify( - this.establishLedgerTransportPreference, + establishLedgerTransportPreference: this.establishLedgerTransportPreference.bind( this, ), // qr hardware devices - submitQRHardwareCryptoHDKey: nodeify( - this.qrHardwareKeyring.submitCryptoHDKey, - this.qrHardwareKeyring, + submitQRHardwareCryptoHDKey: qrHardwareKeyring.submitCryptoHDKey.bind( + qrHardwareKeyring, ), - submitQRHardwareCryptoAccount: nodeify( - this.qrHardwareKeyring.submitCryptoAccount, - this.qrHardwareKeyring, + submitQRHardwareCryptoAccount: qrHardwareKeyring.submitCryptoAccount.bind( + qrHardwareKeyring, ), - cancelSyncQRHardware: nodeify( - this.qrHardwareKeyring.cancelSync, - this.qrHardwareKeyring, + cancelSyncQRHardware: qrHardwareKeyring.cancelSync.bind( + qrHardwareKeyring, ), - submitQRHardwareSignature: nodeify( - this.qrHardwareKeyring.submitSignature, - this.qrHardwareKeyring, + submitQRHardwareSignature: qrHardwareKeyring.submitSignature.bind( + qrHardwareKeyring, ), - cancelQRHardwareSignRequest: nodeify( - this.qrHardwareKeyring.cancelSignRequest, - this.qrHardwareKeyring, + cancelQRHardwareSignRequest: qrHardwareKeyring.cancelSignRequest.bind( + qrHardwareKeyring, ), // mobile - fetchInfoToSync: nodeify(this.fetchInfoToSync, this), + fetchInfoToSync: this.fetchInfoToSync.bind(this), // vault management - submitPassword: nodeify(this.submitPassword, this), - verifyPassword: nodeify(this.verifyPassword, this), + submitPassword: this.submitPassword.bind(this), + verifyPassword: this.verifyPassword.bind(this), // network management - setProviderType: nodeify( - networkController.setProviderType, + setProviderType: networkController.setProviderType.bind( networkController, ), - rollbackToPreviousProvider: nodeify( - networkController.rollbackToPreviousProvider, + rollbackToPreviousProvider: networkController.rollbackToPreviousProvider.bind( networkController, ), - setCustomRpc: nodeify(this.setCustomRpc, this), - updateAndSetCustomRpc: nodeify(this.updateAndSetCustomRpc, this), - delCustomRpc: nodeify(this.delCustomRpc, this), + setCustomRpc: this.setCustomRpc.bind(this), + updateAndSetCustomRpc: this.updateAndSetCustomRpc.bind(this), + delCustomRpc: this.delCustomRpc.bind(this), // PreferencesController - setSelectedAddress: nodeify( - preferencesController.setSelectedAddress, + setSelectedAddress: preferencesController.setSelectedAddress.bind( preferencesController, ), - addToken: nodeify(tokensController.addToken, tokensController), - rejectWatchAsset: nodeify( - tokensController.rejectWatchAsset, - tokensController, - ), - acceptWatchAsset: nodeify( - tokensController.acceptWatchAsset, + addToken: tokensController.addToken.bind(tokensController), + rejectWatchAsset: tokensController.rejectWatchAsset.bind( tokensController, ), - updateTokenType: nodeify( - tokensController.updateTokenType, + acceptWatchAsset: tokensController.acceptWatchAsset.bind( tokensController, ), - removeToken: nodeify( - tokensController.removeAndIgnoreToken, - tokensController, - ), - setAccountLabel: nodeify( - preferencesController.setAccountLabel, + updateTokenType: tokensController.updateTokenType.bind(tokensController), + removeToken: tokensController.removeAndIgnoreToken.bind(tokensController), + setAccountLabel: preferencesController.setAccountLabel.bind( preferencesController, ), - setFeatureFlag: nodeify( - preferencesController.setFeatureFlag, + setFeatureFlag: preferencesController.setFeatureFlag.bind( preferencesController, ), - setPreference: nodeify( - preferencesController.setPreference, + setPreference: preferencesController.setPreference.bind( preferencesController, ), - addKnownMethodData: nodeify( - preferencesController.addKnownMethodData, + addKnownMethodData: preferencesController.addKnownMethodData.bind( preferencesController, ), - setDismissSeedBackUpReminder: nodeify( - this.preferencesController.setDismissSeedBackUpReminder, - this.preferencesController, + setDismissSeedBackUpReminder: preferencesController.setDismissSeedBackUpReminder.bind( + preferencesController, ), - setAdvancedGasFee: nodeify( - preferencesController.setAdvancedGasFee, + setAdvancedGasFee: preferencesController.setAdvancedGasFee.bind( preferencesController, ), // CollectiblesController - addCollectible: nodeify( - collectiblesController.addCollectible, + addCollectible: collectiblesController.addCollectible.bind( collectiblesController, ), - addCollectibleVerifyOwnership: nodeify( - collectiblesController.addCollectibleVerifyOwnership, + addCollectibleVerifyOwnership: collectiblesController.addCollectibleVerifyOwnership.bind( collectiblesController, ), - removeAndIgnoreCollectible: nodeify( - collectiblesController.removeAndIgnoreCollectible, + removeAndIgnoreCollectible: collectiblesController.removeAndIgnoreCollectible.bind( collectiblesController, ), - removeCollectible: nodeify( - collectiblesController.removeCollectible, + removeCollectible: collectiblesController.removeCollectible.bind( collectiblesController, ), // AddressController - setAddressBook: nodeify( - this.addressBookController.set, - this.addressBookController, - ), - removeFromAddressBook: nodeify( - this.addressBookController.delete, - this.addressBookController, + setAddressBook: addressBookController.set.bind(addressBookController), + removeFromAddressBook: addressBookController.delete.bind( + addressBookController, ), // AppStateController - setLastActiveTime: nodeify( - this.appStateController.setLastActiveTime, - this.appStateController, + setLastActiveTime: appStateController.setLastActiveTime.bind( + appStateController, ), - setDefaultHomeActiveTabName: nodeify( - this.appStateController.setDefaultHomeActiveTabName, - this.appStateController, + setDefaultHomeActiveTabName: appStateController.setDefaultHomeActiveTabName.bind( + appStateController, ), - setConnectedStatusPopoverHasBeenShown: nodeify( - this.appStateController.setConnectedStatusPopoverHasBeenShown, - this.appStateController, + setConnectedStatusPopoverHasBeenShown: appStateController.setConnectedStatusPopoverHasBeenShown.bind( + appStateController, ), - setRecoveryPhraseReminderHasBeenShown: nodeify( - this.appStateController.setRecoveryPhraseReminderHasBeenShown, - this.appStateController, + setRecoveryPhraseReminderHasBeenShown: appStateController.setRecoveryPhraseReminderHasBeenShown.bind( + appStateController, ), - setRecoveryPhraseReminderLastShown: nodeify( - this.appStateController.setRecoveryPhraseReminderLastShown, - this.appStateController, + setRecoveryPhraseReminderLastShown: appStateController.setRecoveryPhraseReminderLastShown.bind( + appStateController, ), - setShowTestnetMessageInDropdown: nodeify( - this.appStateController.setShowTestnetMessageInDropdown, - this.appStateController, + setShowTestnetMessageInDropdown: appStateController.setShowTestnetMessageInDropdown.bind( + appStateController, + ), + setCollectiblesDetectionNoticeDismissed: appStateController.setCollectiblesDetectionNoticeDismissed.bind( + appStateController, ), - // EnsController - tryReverseResolveAddress: nodeify( - this.ensController.reverseResolveAddress, - this.ensController, + tryReverseResolveAddress: ensController.reverseResolveAddress.bind( + ensController, ), // KeyringController - setLocked: nodeify(this.setLocked, this), - createNewVaultAndKeychain: nodeify(this.createNewVaultAndKeychain, this), - createNewVaultAndRestore: nodeify(this.createNewVaultAndRestore, this), - exportAccount: nodeify( - keyringController.exportAccount, - keyringController, - ), + setLocked: this.setLocked.bind(this), + createNewVaultAndKeychain: this.createNewVaultAndKeychain.bind(this), + createNewVaultAndRestore: this.createNewVaultAndRestore.bind(this), + exportAccount: keyringController.exportAccount.bind(keyringController), // txController - cancelTransaction: nodeify(txController.cancelTransaction, txController), - updateTransaction: nodeify(txController.updateTransaction, txController), - updateAndApproveTransaction: nodeify( - txController.updateAndApproveTransaction, + cancelTransaction: txController.cancelTransaction.bind(txController), + updateTransaction: txController.updateTransaction.bind(txController), + updateAndApproveTransaction: txController.updateAndApproveTransaction.bind( txController, ), - createCancelTransaction: nodeify(this.createCancelTransaction, this), - createSpeedUpTransaction: nodeify(this.createSpeedUpTransaction, this), - estimateGas: nodeify(this.estimateGas, this), - getNextNonce: nodeify(this.getNextNonce, this), - addUnapprovedTransaction: nodeify( - txController.addUnapprovedTransaction, + createCancelTransaction: this.createCancelTransaction.bind(this), + createSpeedUpTransaction: this.createSpeedUpTransaction.bind(this), + estimateGas: this.estimateGas.bind(this), + getNextNonce: this.getNextNonce.bind(this), + addUnapprovedTransaction: txController.addUnapprovedTransaction.bind( txController, ), // messageManager - signMessage: nodeify(this.signMessage, this), + signMessage: this.signMessage.bind(this), cancelMessage: this.cancelMessage.bind(this), // personalMessageManager - signPersonalMessage: nodeify(this.signPersonalMessage, this), + signPersonalMessage: this.signPersonalMessage.bind(this), cancelPersonalMessage: this.cancelPersonalMessage.bind(this), // typedMessageManager - signTypedMessage: nodeify(this.signTypedMessage, this), + signTypedMessage: this.signTypedMessage.bind(this), cancelTypedMessage: this.cancelTypedMessage.bind(this), // decryptMessageManager - decryptMessage: nodeify(this.decryptMessage, this), - decryptMessageInline: nodeify(this.decryptMessageInline, this), + decryptMessage: this.decryptMessage.bind(this), + decryptMessageInline: this.decryptMessageInline.bind(this), cancelDecryptMessage: this.cancelDecryptMessage.bind(this), // EncryptionPublicKeyManager - encryptionPublicKey: nodeify(this.encryptionPublicKey, this), + encryptionPublicKey: this.encryptionPublicKey.bind(this), cancelEncryptionPublicKey: this.cancelEncryptionPublicKey.bind(this), // onboarding controller - setSeedPhraseBackedUp: nodeify( - onboardingController.setSeedPhraseBackedUp, + setSeedPhraseBackedUp: onboardingController.setSeedPhraseBackedUp.bind( onboardingController, ), - completeOnboarding: nodeify( - onboardingController.completeOnboarding, + completeOnboarding: onboardingController.completeOnboarding.bind( onboardingController, ), - setFirstTimeFlowType: nodeify( - onboardingController.setFirstTimeFlowType, + setFirstTimeFlowType: onboardingController.setFirstTimeFlowType.bind( onboardingController, ), // alert controller - setAlertEnabledness: nodeify( - alertController.setAlertEnabledness, + setAlertEnabledness: alertController.setAlertEnabledness.bind( alertController, ), - setUnconnectedAccountAlertShown: nodeify( - alertController.setUnconnectedAccountAlertShown, + setUnconnectedAccountAlertShown: alertController.setUnconnectedAccountAlertShown.bind( alertController, ), - setWeb3ShimUsageAlertDismissed: nodeify( - alertController.setWeb3ShimUsageAlertDismissed, + setWeb3ShimUsageAlertDismissed: alertController.setWeb3ShimUsageAlertDismissed.bind( alertController, ), // 3Box - setThreeBoxSyncingPermission: nodeify( - threeBoxController.setThreeBoxSyncingPermission, + setThreeBoxSyncingPermission: threeBoxController.setThreeBoxSyncingPermission.bind( threeBoxController, ), - restoreFromThreeBox: nodeify( - threeBoxController.restoreFromThreeBox, + restoreFromThreeBox: threeBoxController.restoreFromThreeBox.bind( threeBoxController, ), - setShowRestorePromptToFalse: nodeify( - threeBoxController.setShowRestorePromptToFalse, + setShowRestorePromptToFalse: threeBoxController.setShowRestorePromptToFalse.bind( threeBoxController, ), - getThreeBoxLastUpdated: nodeify( - threeBoxController.getLastUpdated, + getThreeBoxLastUpdated: threeBoxController.getLastUpdated.bind( threeBoxController, ), - turnThreeBoxSyncingOn: nodeify( - threeBoxController.turnThreeBoxSyncingOn, + turnThreeBoxSyncingOn: threeBoxController.turnThreeBoxSyncingOn.bind( threeBoxController, ), - initializeThreeBox: nodeify(this.initializeThreeBox, this), + initializeThreeBox: this.initializeThreeBox.bind(this), // permissions - approvePermissionsRequest: nodeify( - permissionsController.approvePermissionsRequest, - permissionsController, - ), - rejectPermissionsRequest: nodeify( - permissionsController.rejectPermissionsRequest, - permissionsController, - ), - removePermissionsFor: permissionsController.removePermissionsFor.bind( - permissionsController, + removePermissionsFor: permissionController.revokePermissions.bind( + permissionController, ), - addPermittedAccount: nodeify( - permissionsController.addPermittedAccount, - permissionsController, + approvePermissionsRequest: permissionController.acceptPermissionsRequest.bind( + permissionController, ), - removePermittedAccount: nodeify( - permissionsController.removePermittedAccount, - permissionsController, - ), - requestAccountsPermissionWithId: nodeify( - permissionsController.requestAccountsPermissionWithId, - permissionsController, + rejectPermissionsRequest: permissionController.rejectPermissionsRequest.bind( + permissionController, ), + ...getPermissionBackgroundApiMethods(permissionController), // swaps - fetchAndSetQuotes: nodeify( - swapsController.fetchAndSetQuotes, - swapsController, - ), - setSelectedQuoteAggId: nodeify( - swapsController.setSelectedQuoteAggId, - swapsController, - ), - resetSwapsState: nodeify( - swapsController.resetSwapsState, - swapsController, - ), - setSwapsTokens: nodeify(swapsController.setSwapsTokens, swapsController), - clearSwapsQuotes: nodeify( - swapsController.clearSwapsQuotes, - swapsController, - ), - setApproveTxId: nodeify(swapsController.setApproveTxId, swapsController), - setTradeTxId: nodeify(swapsController.setTradeTxId, swapsController), - setSwapsTxGasPrice: nodeify( - swapsController.setSwapsTxGasPrice, + fetchAndSetQuotes: swapsController.fetchAndSetQuotes.bind( swapsController, ), - setSwapsTxGasLimit: nodeify( - swapsController.setSwapsTxGasLimit, + setSelectedQuoteAggId: swapsController.setSelectedQuoteAggId.bind( swapsController, ), - setSwapsTxMaxFeePerGas: nodeify( - swapsController.setSwapsTxMaxFeePerGas, + resetSwapsState: swapsController.resetSwapsState.bind(swapsController), + setSwapsTokens: swapsController.setSwapsTokens.bind(swapsController), + clearSwapsQuotes: swapsController.clearSwapsQuotes.bind(swapsController), + setApproveTxId: swapsController.setApproveTxId.bind(swapsController), + setTradeTxId: swapsController.setTradeTxId.bind(swapsController), + setSwapsTxGasPrice: swapsController.setSwapsTxGasPrice.bind( swapsController, ), - setSwapsTxMaxFeePriorityPerGas: nodeify( - swapsController.setSwapsTxMaxFeePriorityPerGas, + setSwapsTxGasLimit: swapsController.setSwapsTxGasLimit.bind( swapsController, ), - safeRefetchQuotes: nodeify( - swapsController.safeRefetchQuotes, + setSwapsTxMaxFeePerGas: swapsController.setSwapsTxMaxFeePerGas.bind( swapsController, ), - stopPollingForQuotes: nodeify( - swapsController.stopPollingForQuotes, + setSwapsTxMaxFeePriorityPerGas: swapsController.setSwapsTxMaxFeePriorityPerGas.bind( swapsController, ), - setBackgroundSwapRouteState: nodeify( - swapsController.setBackgroundSwapRouteState, + safeRefetchQuotes: swapsController.safeRefetchQuotes.bind( swapsController, ), - resetPostFetchState: nodeify( - swapsController.resetPostFetchState, + stopPollingForQuotes: swapsController.stopPollingForQuotes.bind( swapsController, ), - setSwapsErrorKey: nodeify( - swapsController.setSwapsErrorKey, + setBackgroundSwapRouteState: swapsController.setBackgroundSwapRouteState.bind( swapsController, ), - setInitialGasEstimate: nodeify( - swapsController.setInitialGasEstimate, + resetPostFetchState: swapsController.resetPostFetchState.bind( swapsController, ), - setCustomApproveTxData: nodeify( - swapsController.setCustomApproveTxData, + setSwapsErrorKey: swapsController.setSwapsErrorKey.bind(swapsController), + setInitialGasEstimate: swapsController.setInitialGasEstimate.bind( swapsController, ), - setSwapsLiveness: nodeify( - swapsController.setSwapsLiveness, + setCustomApproveTxData: swapsController.setCustomApproveTxData.bind( swapsController, ), - setSwapsUserFeeLevel: nodeify( - swapsController.setSwapsUserFeeLevel, + setSwapsLiveness: swapsController.setSwapsLiveness.bind(swapsController), + setSwapsUserFeeLevel: swapsController.setSwapsUserFeeLevel.bind( swapsController, ), - setSwapsQuotesPollingLimitEnabled: nodeify( - swapsController.setSwapsQuotesPollingLimitEnabled, + setSwapsQuotesPollingLimitEnabled: swapsController.setSwapsQuotesPollingLimitEnabled.bind( swapsController, ), // MetaMetrics - trackMetaMetricsEvent: nodeify( - metaMetricsController.trackEvent, + trackMetaMetricsEvent: metaMetricsController.trackEvent.bind( metaMetricsController, ), - trackMetaMetricsPage: nodeify( - metaMetricsController.trackPage, + trackMetaMetricsPage: metaMetricsController.trackPage.bind( metaMetricsController, ), // approval controller - resolvePendingApproval: nodeify( - approvalController.accept, - approvalController, - ), - rejectPendingApproval: nodeify( - approvalController.reject, + resolvePendingApproval: approvalController.accept.bind( approvalController, ), + rejectPendingApproval: approvalController.reject.bind(approvalController), // Notifications - updateViewedNotifications: nodeify( - this.notificationController.updateViewed, - this.notificationController, + updateViewedNotifications: notificationController.updateViewed.bind( + notificationController, ), // GasFeeController - getGasFeeEstimatesAndStartPolling: nodeify( - this.gasFeeController.getGasFeeEstimatesAndStartPolling, - this.gasFeeController, + getGasFeeEstimatesAndStartPolling: gasFeeController.getGasFeeEstimatesAndStartPolling.bind( + gasFeeController, ), - disconnectGasFeeEstimatePoller: nodeify( - this.gasFeeController.disconnectPoller, - this.gasFeeController, + disconnectGasFeeEstimatePoller: gasFeeController.disconnectPoller.bind( + gasFeeController, ), - getGasFeeTimeEstimate: nodeify( - this.gasFeeController.getTimeEstimate, - this.gasFeeController, + getGasFeeTimeEstimate: gasFeeController.getTimeEstimate.bind( + gasFeeController, ), - addPollingTokenToAppState: nodeify( - this.appStateController.addPollingToken, - this.appStateController, + addPollingTokenToAppState: appStateController.addPollingToken.bind( + appStateController, ), - removePollingTokenFromAppState: nodeify( - this.appStateController.removePollingToken, - this.appStateController, + removePollingTokenFromAppState: appStateController.removePollingToken.bind( + appStateController, ), // DetectTokenController - detectNewTokens: nodeify( - this.detectTokensController.detectNewTokens, - this.detectTokensController, + detectNewTokens: detectTokensController.detectNewTokens.bind( + detectTokensController, ), // DetectCollectibleController detectCollectibles: process.env.COLLECTIBLES_V1 - ? nodeify( - this.collectibleDetectionController.detectCollectibles, - this.collectibleDetectionController, + ? collectibleDetectionController.detectCollectibles.bind( + collectibleDetectionController, ) : null, }; @@ -1432,7 +1447,7 @@ export default class MetamaskController extends EventEmitter { this.preferencesController.setAddresses([]); // clear permissions - this.permissionsController.clearPermissions(); + this.permissionController.clearState(); // clear accounts in accountTracker this.accountTracker.clearAccounts(); @@ -1925,6 +1940,48 @@ export default class MetamaskController extends EventEmitter { return selectedAddress; } + /** + * Gets the permitted accounts for the specified origin. Returns an empty + * array if no accounts are permitted. + * + * @param {string} origin - The origin whose exposed accounts to retrieve. + * @returns {Promise} The origin's permitted accounts, or an empty + * array. + */ + async getPermittedAccounts(origin) { + try { + return await this.permissionController.executeRestrictedMethod( + origin, + RestrictedMethods.eth_accounts, + ); + } catch (error) { + if (error.code === rpcErrorCodes.provider.unauthorized) { + return []; + } + throw error; + } + } + + /** + * Stops exposing the account with the specified address to all third parties. + * Exposed accounts are stored in caveats of the eth_accounts permission. This + * method uses `PermissionController.updatePermissionsByCaveat` to + * remove the specified address from every eth_accounts permission. If a + * permission only included this address, the permission is revoked entirely. + * + * @param {string} targetAccount - The address of the account to stop exposing + * to third parties. + */ + removeAllAccountPermissions(targetAccount) { + this.permissionController.updatePermissionsByCaveat( + CaveatTypes.restrictReturnedAccounts, + (existingAccounts) => + CaveatMutatorFactories[ + CaveatTypes.restrictReturnedAccounts + ].removeAccount(targetAccount, existingAccounts), + ); + } + /** * Removes an account from state / storage. * @@ -1933,7 +1990,7 @@ export default class MetamaskController extends EventEmitter { */ async removeAccount(address) { // Remove all associated permissions - await this.permissionsController.removeAllAccountPermissions(address); + this.removeAllAccountPermissions(address); // Remove account from the preferences controller this.preferencesController.removeAddress(address); // Remove account from the account tracker controller @@ -2041,13 +2098,10 @@ export default class MetamaskController extends EventEmitter { * * @param {string} msgId - The id of the message to cancel. */ - cancelMessage(msgId, cb) { + cancelMessage(msgId) { const { messageManager } = this; messageManager.rejectMsg(msgId); - if (!cb || typeof cb !== 'function') { - return; - } - cb(null, this.getState()); + return this.getState(); } // personal_sign methods: @@ -2106,15 +2160,11 @@ export default class MetamaskController extends EventEmitter { /** * Used to cancel a personal_sign type message. * @param {string} msgId - The ID of the message to cancel. - * @param {Function} cb - The callback function called with a full state update. */ - cancelPersonalMessage(msgId, cb) { + cancelPersonalMessage(msgId) { const messageManager = this.personalMessageManager; messageManager.rejectMsg(msgId); - if (!cb || typeof cb !== 'function') { - return; - } - cb(null, this.getState()); + return this.getState(); } // eth_decrypt methods @@ -2198,15 +2248,11 @@ export default class MetamaskController extends EventEmitter { /** * Used to cancel a eth_decrypt type message. * @param {string} msgId - The ID of the message to cancel. - * @param {Function} cb - The callback function called with a full state update. */ - cancelDecryptMessage(msgId, cb) { + cancelDecryptMessage(msgId) { const messageManager = this.decryptMessageManager; messageManager.rejectMsg(msgId); - if (!cb || typeof cb !== 'function') { - return; - } - cb(null, this.getState()); + return this.getState(); } // eth_getEncryptionPublicKey methods @@ -2303,15 +2349,11 @@ export default class MetamaskController extends EventEmitter { /** * Used to cancel a eth_getEncryptionPublicKey type message. * @param {string} msgId - The ID of the message to cancel. - * @param {Function} cb - The callback function called with a full state update. */ - cancelEncryptionPublicKey(msgId, cb) { + cancelEncryptionPublicKey(msgId) { const messageManager = this.encryptionPublicKeyManager; messageManager.rejectMsg(msgId); - if (!cb || typeof cb !== 'function') { - return; - } - cb(null, this.getState()); + return this.getState(); } // eth_signTypedData methods @@ -2373,15 +2415,11 @@ export default class MetamaskController extends EventEmitter { /** * Used to cancel a eth_signTypedData type message. * @param {string} msgId - The ID of the message to cancel. - * @param {Function} cb - The callback function called with a full state update. */ - cancelTypedMessage(msgId, cb) { + cancelTypedMessage(msgId) { const messageManager = this.typedMessageManager; messageManager.rejectMsg(msgId); - if (!cb || typeof cb !== 'function') { - return; - } - cb(null, this.getState()); + return this.getState(); } /** @@ -2468,20 +2506,18 @@ export default class MetamaskController extends EventEmitter { * Allows a user to begin the seed phrase recovery process. * @param {Function} cb - A callback function called when complete. */ - markPasswordForgotten(cb) { + markPasswordForgotten() { this.preferencesController.setPasswordForgotten(true); this.sendUpdate(); - cb(); } /** * Allows a user to end the seed phrase recovery process. * @param {Function} cb - A callback function called when complete. */ - unMarkPasswordForgotten(cb) { + unMarkPasswordForgotten() { this.preferencesController.setPasswordForgotten(false); this.sendUpdate(); - cb(); } //============================================================================= @@ -2596,10 +2632,18 @@ export default class MetamaskController extends EventEmitter { */ setupProviderConnection(outStream, sender, isInternal) { const origin = isInternal ? 'metamask' : new URL(sender.url).origin; - let extensionId; + let subjectType = isInternal + ? SUBJECT_TYPES.INTERNAL + : SUBJECT_TYPES.WEBSITE; + if (sender.id !== this.extension.runtime.id) { - extensionId = sender.id; + subjectType = SUBJECT_TYPES.EXTENSION; + this.subjectMetadataController.addSubjectMetadata(origin, { + extensionId: sender.id, + subjectType: SUBJECT_TYPES.EXTENSION, + }); } + let tabId; if (sender.tab && sender.tab.id) { tabId = sender.tab.id; @@ -2608,9 +2652,8 @@ export default class MetamaskController extends EventEmitter { const engine = this.setupProviderEngine({ origin, location: sender.url, - extensionId, tabId, - isInternal, + subjectType, }); // setup connection @@ -2633,21 +2676,15 @@ export default class MetamaskController extends EventEmitter { } /** - * A method for creating a provider that is safely restricted for the requesting domain. + * A method for creating a provider that is safely restricted for the requesting subject. + * * @param {Object} options - Provider engine options * @param {string} options.origin - The origin of the sender * @param {string} options.location - The full URL of the sender - * @param {extensionId} [options.extensionId] - The extension ID of the sender, if the sender is an external extension + * @param {string} options.subjectType - The type of the sender subject. * @param {tabId} [options.tabId] - The tab ID of the sender - if the sender is within a tab - * @param {boolean} [options.isInternal] - True if called for a connection to an internal process **/ - setupProviderEngine({ - origin, - location, - extensionId, - tabId, - isInternal = false, - }) { + setupProviderEngine({ origin, location, subjectType, tabId }) { // setup json rpc engine stack const engine = new JsonRpcEngine(); const { provider, blockTracker } = this; @@ -2678,40 +2715,48 @@ export default class MetamaskController extends EventEmitter { registerOnboarding: this.onboardingController.registerOnboarding, }), ); + engine.push(this.permissionLogController.createMiddleware()); engine.push( createMethodMiddleware({ origin, + + subjectType, + + // Miscellaneous + addSubjectMetadata: this.subjectMetadataController.addSubjectMetadata.bind( + this.subjectMetadataController, + ), getProviderState: this.getProviderState.bind(this), - sendMetrics: this.metaMetricsController.trackEvent.bind( - this.metaMetricsController, + getUnlockPromise: this.appStateController.getUnlockPromise.bind( + this.appStateController, ), handleWatchAssetRequest: this.tokensController.watchAsset.bind( this.tokensController, ), - getWeb3ShimUsageState: this.alertController.getWeb3ShimUsageState.bind( - this.alertController, + requestUserApproval: this.approvalController.addAndShowApprovalRequest.bind( + this.approvalController, ), - setWeb3ShimUsageRecorded: this.alertController.setWeb3ShimUsageRecorded.bind( - this.alertController, + sendMetrics: this.metaMetricsController.trackEvent.bind( + this.metaMetricsController, ), - findCustomRpcBy: this.findCustomRpcBy.bind(this), - getCurrentChainId: this.networkController.getCurrentChainId.bind( - this.networkController, + + // Permission-related + getAccounts: this.getPermittedAccounts.bind(this, origin), + getPermissionsForOrigin: this.permissionController.getPermissions.bind( + this.permissionController, + origin, ), - requestUserApproval: this.approvalController.addAndShowApprovalRequest.bind( - this.approvalController, + hasPermission: this.permissionController.hasPermission.bind( + this.permissionController, + origin, ), - updateRpcTarget: ({ rpcUrl, chainId, ticker, nickname }) => { - this.networkController.setRpcTarget( - rpcUrl, - chainId, - ticker, - nickname, - ); - }, - setProviderType: this.networkController.setProviderType.bind( - this.networkController, + requestAccountsPermission: this.permissionController.requestPermissions.bind( + this.permissionController, + { origin }, + { eth_accounts: {} }, ), + + // Custom RPC-related addCustomRpc: async ({ chainId, blockExplorerUrl, @@ -2729,15 +2774,40 @@ export default class MetamaskController extends EventEmitter { }, ); }, + findCustomRpcBy: this.findCustomRpcBy.bind(this), + getCurrentChainId: this.networkController.getCurrentChainId.bind( + this.networkController, + ), + setProviderType: this.networkController.setProviderType.bind( + this.networkController, + ), + updateRpcTarget: ({ rpcUrl, chainId, ticker, nickname }) => { + this.networkController.setRpcTarget( + rpcUrl, + chainId, + ticker, + nickname, + ); + }, + + // Web3 shim-related + getWeb3ShimUsageState: this.alertController.getWeb3ShimUsageState.bind( + this.alertController, + ), + setWeb3ShimUsageRecorded: this.alertController.setWeb3ShimUsageRecorded.bind( + this.alertController, + ), }), ); // filter and subscription polyfills engine.push(filterMiddleware); engine.push(subscriptionManager.middleware); - if (!isInternal) { + if (subjectType !== SUBJECT_TYPES.INTERNAL) { // permissions engine.push( - this.permissionsController.createMiddleware({ origin, extensionId }), + this.permissionController.createPermissionMiddleware({ + origin, + }), ); } // forward to metamask primary provider @@ -2824,7 +2894,7 @@ export default class MetamaskController extends EventEmitter { * Ignores unknown origins. * * @param {string} origin - The connection's origin string. - * @param {any} payload - The event payload. + * @param {unknown} payload - The event payload. */ notifyConnections(origin, payload) { const connections = this.connections[origin]; @@ -2849,7 +2919,7 @@ export default class MetamaskController extends EventEmitter { * The caller is responsible for ensuring that only permitted notifications * are sent. * - * @param {any} payload - The event payload, or payload getter function. + * @param {unknown} payload - The event payload, or payload getter function. */ notifyAllConnections(payload) { const getPayload = @@ -2891,8 +2961,9 @@ export default class MetamaskController extends EventEmitter { } /** - * Handle global unlock, triggered by KeyringController unlock. - * Notifies all connections that the extension is unlocked. + * Handle global application unlock. + * Notifies all connections that the extension is unlocked, and which + * account(s) are currently accessible, if any. */ _onUnlock() { this.notifyAllConnections(async (origin) => { @@ -2900,15 +2971,19 @@ export default class MetamaskController extends EventEmitter { method: NOTIFICATION_NAMES.unlockStateChanged, params: { isUnlocked: true, - accounts: await this.permissionsController.getAccounts(origin), + accounts: await this.getPermittedAccounts(origin), }, }; }); + + // In the current implementation, this handler is triggered by a + // KeyringController event. Other controllers subscribe to the 'unlock' + // event of the MetaMaskController itself. this.emit('unlock'); } /** - * Handle global lock, triggered by KeyringController lock. + * Handle global application lock. * Notifies all connections that the extension is locked. */ _onLock() { @@ -2918,6 +2993,10 @@ export default class MetamaskController extends EventEmitter { isUnlocked: false, }, }); + + // In the current implementation, this handler is triggered by a + // KeyringController event. Other controllers subscribe to the 'lock' + // event of the MetaMaskController itself. this.emit('lock'); } @@ -3140,74 +3219,6 @@ export default class MetamaskController extends EventEmitter { await this.threeBoxController.init(); } - /** - * Sets whether or not to use the blockie identicon format. - * @param {boolean} val - True for bockie, false for jazzicon. - * @param {Function} cb - A callback function called when complete. - */ - setUseBlockie(val, cb) { - try { - this.preferencesController.setUseBlockie(val); - cb(null); - return; - } catch (err) { - cb(err); - // eslint-disable-next-line no-useless-return - return; - } - } - - /** - * Sets whether or not to use the nonce field. - * @param {boolean} val - True for nonce field, false for not nonce field. - * @param {Function} cb - A callback function called when complete. - */ - setUseNonceField(val, cb) { - try { - this.preferencesController.setUseNonceField(val); - cb(null); - return; - } catch (err) { - cb(err); - // eslint-disable-next-line no-useless-return - return; - } - } - - /** - * Sets whether or not to use phishing detection. - * @param {boolean} val - * @param {Function} cb - */ - setUsePhishDetect(val, cb) { - try { - this.preferencesController.setUsePhishDetect(val); - cb(null); - return; - } catch (err) { - cb(err); - // eslint-disable-next-line no-useless-return - return; - } - } - - /** - * Sets the IPFS gateway to use for ENS content resolution. - * @param {string} val - the host of the gateway to set - * @param {Function} cb - A callback function called when complete. - */ - setIpfsGateway(val, cb) { - try { - this.preferencesController.setIpfsGateway(val); - cb(null); - return; - } catch (err) { - cb(err); - // eslint-disable-next-line no-useless-return - return; - } - } - /** * Sets the Ledger Live preference to use for Ledger hardware wallet support * @param {bool} bool - the value representing if the users wants to use Ledger Live @@ -3231,42 +3242,6 @@ export default class MetamaskController extends EventEmitter { return undefined; } - /** - * Sets whether or not the user will have usage data tracked with MetaMetrics - * @param {boolean} bool - True for users that wish to opt-in, false for users that wish to remain out. - * @param {Function} cb - A callback function called when complete. - */ - setParticipateInMetaMetrics(bool, cb) { - try { - const metaMetricsId = this.metaMetricsController.setParticipateInMetaMetrics( - bool, - ); - cb(null, metaMetricsId); - return; - } catch (err) { - cb(err); - // eslint-disable-next-line no-useless-return - return; - } - } - - /** - * A method for setting a user's current locale, affecting the language rendered. - * @param {string} key - Locale identifier. - * @param {Function} cb - A callback function called when complete. - */ - setCurrentLocale(key, cb) { - try { - const direction = this.preferencesController.setCurrentLocale(key); - cb(null, direction); - return; - } catch (err) { - cb(err); - // eslint-disable-next-line no-useless-return - return; - } - } - /** * A method for initializing storage the first time. * @param {Object} initState - The default state to initialize with. diff --git a/app/scripts/metamask-controller.test.js b/app/scripts/metamask-controller.test.js index 12b91fd8b..f7323388d 100644 --- a/app/scripts/metamask-controller.test.js +++ b/app/scripts/metamask-controller.test.js @@ -33,22 +33,32 @@ const firstTimeState = { const ganacheServer = new Ganache(); const threeBoxSpies = { + _registerUpdates: sinon.spy(), init: sinon.stub(), + getLastUpdated: sinon.stub(), getThreeBoxSyncingState: sinon.stub().returns(true), + restoreFromThreeBox: sinon.stub(), + setShowRestorePromptToFalse: sinon.stub(), + setThreeBoxSyncingPermission: sinon.stub(), turnThreeBoxSyncingOn: sinon.stub(), - _registerUpdates: sinon.spy(), }; class ThreeBoxControllerMock { constructor() { + this._registerUpdates = threeBoxSpies._registerUpdates; + this.init = threeBoxSpies.init; + this.getLastUpdated = threeBoxSpies.getLastUpdated; + this.getThreeBoxSyncingState = threeBoxSpies.getThreeBoxSyncingState; + this.restoreFromThreeBox = threeBoxSpies.restoreFromThreeBox; + this.setShowRestorePromptToFalse = + threeBoxSpies.setShowRestorePromptToFalse; + this.setThreeBoxSyncingPermission = + threeBoxSpies.setThreeBoxSyncingPermission; this.store = { subscribe: () => undefined, getState: () => ({}), }; - this.init = threeBoxSpies.init; - this.getThreeBoxSyncingState = threeBoxSpies.getThreeBoxSyncingState; this.turnThreeBoxSyncingOn = threeBoxSpies.turnThreeBoxSyncingOn; - this._registerUpdates = threeBoxSpies._registerUpdates; } } @@ -423,35 +433,10 @@ describe('MetaMaskController', function () { }); describe('#getApi', function () { - it('getState', function (done) { - let state; + it('getState', function () { const getApi = metamaskController.getApi(); - getApi.getState((err, res) => { - if (err) { - done(err); - } else { - state = res; - } - }); + const state = getApi.getState(); assert.deepEqual(state, metamaskController.getState()); - done(); - }); - }); - - describe('preferencesController', function () { - it('defaults useBlockie to false', function () { - assert.equal( - metamaskController.preferencesController.store.getState().useBlockie, - false, - ); - }); - - it('setUseBlockie to true', function () { - metamaskController.setUseBlockie(true, noop); - assert.equal( - metamaskController.preferencesController.store.getState().useBlockie, - true, - ); }); }); @@ -768,10 +753,7 @@ describe('MetaMaskController', function () { sinon.stub(metamaskController.preferencesController, 'removeAddress'); sinon.stub(metamaskController.accountTracker, 'removeAccount'); sinon.stub(metamaskController.keyringController, 'removeAccount'); - sinon.stub( - metamaskController.permissionsController, - 'removeAllAccountPermissions', - ); + sinon.stub(metamaskController, 'removeAllAccountPermissions'); ret = await metamaskController.removeAccount(addressToRemove); }); @@ -780,7 +762,7 @@ describe('MetaMaskController', function () { metamaskController.keyringController.removeAccount.restore(); metamaskController.accountTracker.removeAccount.restore(); metamaskController.preferencesController.removeAddress.restore(); - metamaskController.permissionsController.removeAllAccountPermissions.restore(); + metamaskController.removeAllAccountPermissions.restore(); }); it('should call preferencesController.removeAddress', async function () { @@ -804,9 +786,9 @@ describe('MetaMaskController', function () { ), ); }); - it('should call permissionsController.removeAllAccountPermissions', async function () { + it('should call metamaskController.removeAllAccountPermissions', async function () { assert( - metamaskController.permissionsController.removeAllAccountPermissions.calledWith( + metamaskController.removeAllAccountPermissions.calledWith( addressToRemove, ), ); @@ -816,21 +798,6 @@ describe('MetaMaskController', function () { }); }); - describe('#setCurrentLocale', function () { - it('checks the default currentLocale', function () { - const preferenceCurrentLocale = metamaskController.preferencesController.store.getState() - .currentLocale; - assert.equal(preferenceCurrentLocale, 'en_US'); - }); - - it('sets current locale in preferences controller', function () { - metamaskController.setCurrentLocale('ja', noop); - const preferenceCurrentLocale = metamaskController.preferencesController.store.getState() - .currentLocale; - assert.equal(preferenceCurrentLocale, 'ja'); - }); - }); - describe('#newUnsignedMessage', function () { let msgParams, metamaskMsgs, messages, msgId; diff --git a/app/scripts/migrations/048.test.js b/app/scripts/migrations/048.test.js index 881d4abd7..ddd7a1760 100644 --- a/app/scripts/migrations/048.test.js +++ b/app/scripts/migrations/048.test.js @@ -351,13 +351,13 @@ describe('migration #48', () => { data: { AddressBookController: { addressBook: { - '1': { + 1: { address1: { chainId: '1', foo: 'bar', }, }, - '100': { + 100: { address1: { chainId: '100', foo: 'bar', @@ -416,7 +416,7 @@ describe('migration #48', () => { data: { AddressBookController: { addressBook: { - '2': { + 2: { address1: { chainId: '2', key2: 'kaplar', @@ -489,7 +489,7 @@ describe('migration #48', () => { AddressBookController: { addressBook: { '0x1': { foo: { bar: 'baz' } }, - 'kaplar': { foo: { bar: 'baz' } }, + kaplar: { foo: { bar: 'baz' } }, }, bar: { baz: 'buzz', @@ -505,7 +505,7 @@ describe('migration #48', () => { AddressBookController: { addressBook: { '0x1': { foo: { bar: 'baz' } }, - 'kaplar': { foo: { bar: 'baz' } }, + kaplar: { foo: { bar: 'baz' } }, }, bar: { baz: 'buzz', diff --git a/app/scripts/migrations/068.js b/app/scripts/migrations/068.js new file mode 100644 index 000000000..c2abbef76 --- /dev/null +++ b/app/scripts/migrations/068.js @@ -0,0 +1,161 @@ +import { cloneDeep } from 'lodash'; + +const version = 68; + +/** + * Transforms the PermissionsController and PermissionsMetadata substates + * to match the new permission system. + */ +export default { + version, + async migrate(originalVersionedData) { + const versionedData = cloneDeep(originalVersionedData); + versionedData.meta.version = version; + const state = versionedData.data; + const newState = transformState(state); + versionedData.data = newState; + return versionedData; + }, +}; + +function transformState(state) { + const { + PermissionsController = {}, + PermissionsMetadata = {}, + ...remainingState + } = state; + + const { + domainMetadata = {}, + permissionsHistory = {}, + permissionsLog = [], + } = PermissionsMetadata; + + return { + ...remainingState, + PermissionController: getPermissionControllerState(PermissionsController), + PermissionLogController: { + permissionActivityLog: permissionsLog, + permissionHistory: permissionsHistory, + }, + SubjectMetadataController: getSubjectMetadataControllerState( + domainMetadata, + ), + }; +} + +function getPermissionControllerState(PermissionsController) { + const { domains = {} } = PermissionsController; + + /** + * Example existing domain entry. Every existing domain will have a single + * eth_accounts permission, which simplifies the transform. + * + * 'https://metamask.github.io': { + * permissions: [ + * { + * '@context': ['https://github.com/MetaMask/rpc-cap'], + * 'caveats': [ + * { + * name: 'primaryAccountOnly', + * type: 'limitResponseLength', + * value: 1, + * }, + * { + * name: 'exposedAccounts', + * type: 'filterResponse', + * value: ['0x0c97a5c81e50a02ff8be73cc3f0a0569e61f4ed8'], + * }, + * ], + * 'date': 1616006369498, + * 'id': '3d0bdc27-e8e4-4fb0-a24b-340d61f6a3fa', + * 'invoker': 'https://metamask.github.io', + * 'parentCapability': 'eth_accounts', + * }, + * ], + * }, + */ + + const ETH_ACCOUNTS = 'eth_accounts'; + const NEW_CAVEAT_TYPE = 'restrictReturnedAccounts'; + const OLD_CAVEAT_NAME = 'exposedAccounts'; + + const subjects = Object.entries(domains).reduce( + (transformed, [origin, domainEntry]) => { + const { + permissions: [ethAccountsPermission], + } = domainEntry; + + // There are two caveats for each eth_accounts permission, but we only + // need the value of one of them in the new permission system. + const oldCaveat = ethAccountsPermission.caveats.find( + (caveat) => caveat.name === OLD_CAVEAT_NAME, + ); + + const newPermission = { + ...ethAccountsPermission, + caveats: [{ type: NEW_CAVEAT_TYPE, value: oldCaveat.value }], + }; + + // We never used this, and just omit it in the new system. + delete newPermission['@context']; + + transformed[origin] = { + origin, + permissions: { + [ETH_ACCOUNTS]: newPermission, + }, + }; + return transformed; + }, + {}, + ); + + return { + subjects, + }; +} + +function getSubjectMetadataControllerState(domainMetadata) { + /** + * Example existing domainMetadata entry. + * + * "https://www.youtube.com": { + * "host": "www.youtube.com", + * "icon": null, + * "lastUpdated": 1637697914908, + * "name": "YouTube" + * } + */ + + const subjectMetadata = Object.entries(domainMetadata).reduce( + (transformed, [origin, metadata]) => { + const { + name = null, + icon = null, + extensionId = null, + ...other + } = metadata; + + // We're getting rid of these. + delete other.lastUpdated; + delete other.host; + + if (origin) { + transformed[origin] = { + name, + iconUrl: icon, + extensionId, + ...other, + origin, + }; + } + return transformed; + }, + {}, + ); + + return { + subjectMetadata, + }; +} diff --git a/app/scripts/migrations/068.test.js b/app/scripts/migrations/068.test.js new file mode 100644 index 000000000..0540c5b48 --- /dev/null +++ b/app/scripts/migrations/068.test.js @@ -0,0 +1,450 @@ +import migration68 from './068'; + +describe('migration #68', () => { + it('should update the version metadata', async () => { + const oldStorage = { + meta: { + version: 67, + }, + data: {}, + }; + + const newStorage = await migration68.migrate(oldStorage); + expect(newStorage.meta).toStrictEqual({ + version: 68, + }); + }); + + it('should migrate all data', async () => { + const oldStorage = { + meta: { + version: 67, + }, + data: getOldState(), + }; + + const newStorage = await migration68.migrate(oldStorage); + expect(newStorage).toMatchObject({ + meta: { + version: 68, + }, + data: { + FooController: { a: 'b' }, + PermissionController: { subjects: expect.any(Object) }, + PermissionLogController: { + permissionActivityLog: expect.any(Object), + permissionHistory: expect.any(Object), + }, + SubjectMetadataController: { subjectMetadata: expect.any(Object) }, + }, + }); + expect(newStorage.PermissionsController).toBeUndefined(); + expect(newStorage.PermissionsMetadata).toBeUndefined(); + }); + + it('should migrate the PermissionsController state', async () => { + const oldStorage = { + meta: {}, + data: { + PermissionsController: getOldState().PermissionsController, + }, + }; + + const newStorage = await migration68.migrate(oldStorage); + const { PermissionController } = newStorage.data; + + expect(PermissionController).toStrictEqual({ + subjects: { + 'https://faucet.metamask.io': { + origin: 'https://faucet.metamask.io', + permissions: { + eth_accounts: { + caveats: [ + { + type: 'restrictReturnedAccounts', + value: ['0xc42edfcc21ed14dda456aa0756c153f7985d8813'], + }, + ], + date: 1597334833084, + id: 'e01bada4-ddc7-47b6-be67-d4603733e0e9', + invoker: 'https://faucet.metamask.io', + parentCapability: 'eth_accounts', + }, + }, + }, + 'https://metamask.github.io': { + origin: 'https://metamask.github.io', + permissions: { + eth_accounts: { + caveats: [ + { + type: 'restrictReturnedAccounts', + value: ['0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'], + }, + ], + date: 1616006369498, + id: '3d0bdc27-e8e4-4fb0-a24b-340d61f6a3fa', + invoker: 'https://metamask.github.io', + parentCapability: 'eth_accounts', + }, + }, + }, + 'https://xdai.io': { + origin: 'https://xdai.io', + permissions: { + eth_accounts: { + caveats: [ + { + type: 'restrictReturnedAccounts', + value: ['0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'], + }, + ], + date: 1605908022382, + id: '88c5de24-11a9-4f1e-9651-b072f4c11928', + invoker: 'https://xdai.io', + parentCapability: 'eth_accounts', + }, + }, + }, + }, + }); + }); + + it('should migrate the PermissionsMetadata state', async () => { + const oldStorage = { + meta: {}, + data: { + PermissionsMetadata: getOldState().PermissionsMetadata, + }, + }; + + const newStorage = await migration68.migrate(oldStorage); + const { + PermissionLogController, + SubjectMetadataController, + } = newStorage.data; + const expected = getOldState().PermissionsMetadata; + + expect(PermissionLogController.permissionHistory).toStrictEqual( + expected.permissionsHistory, + ); + expect(PermissionLogController.permissionActivityLog).toStrictEqual( + expected.permissionsLog, + ); + + expect(SubjectMetadataController).toStrictEqual({ + subjectMetadata: { + 'https://1inch.exchange': { + iconUrl: 'https://1inch.exchange/assets/favicon/favicon-32x32.png', + name: 'DEX Aggregator - 1inch.exchange', + origin: 'https://1inch.exchange', + extensionId: null, + }, + 'https://ascii-tree-generator.com': { + iconUrl: 'https://ascii-tree-generator.com/favicon.ico', + name: 'ASCII Tree Generator', + origin: 'https://ascii-tree-generator.com', + extensionId: null, + }, + 'https://caniuse.com': { + iconUrl: 'https://caniuse.com/img/favicon-128.png', + name: 'Can I use... Support tables for HTML5, CSS3, etc', + origin: 'https://caniuse.com', + extensionId: null, + }, + 'https://core-geth.org': { + iconUrl: 'https://core-geth.org/icons/icon-48x48.png', + name: 'core-geth.org', + origin: 'https://core-geth.org', + extensionId: null, + }, + 'https://docs.npmjs.com': { + iconUrl: 'https://docs.npmjs.com/favicon-32x32.png', + name: 'package-locks | npm Docs', + origin: 'https://docs.npmjs.com', + extensionId: null, + }, + }, + }); + }); + + it('should handle domain metadata edge cases', async () => { + const oldStorage = { + meta: {}, + data: { + PermissionsMetadata: { + domainMetadata: { + 'foo.bar': { + // no name + icon: 'fooIcon', + extensionId: 'fooExtension', // non-null + origin: null, // should get overwritten + extraProperty: 'bar', // should be preserved + }, + }, + }, + }, + }; + + const newStorage = await migration68.migrate(oldStorage); + expect( + newStorage.data.SubjectMetadataController.subjectMetadata, + ).toStrictEqual({ + 'foo.bar': { + name: null, // replaced with null + iconUrl: 'fooIcon', // preserved value, changed name + extensionId: 'fooExtension', // preserved + origin: 'foo.bar', // overwritten with correct origin + extraProperty: 'bar', // preserved + }, + }); + }); +}); + +function getOldState() { + return { + FooController: { a: 'b' }, // just to ensure it's not touched + PermissionsController: { + domains: { + 'https://faucet.metamask.io': { + permissions: [ + { + '@context': ['https://github.com/MetaMask/rpc-cap'], + caveats: [ + { + name: 'primaryAccountOnly', + type: 'limitResponseLength', + value: 1, + }, + { + name: 'exposedAccounts', + type: 'filterResponse', + value: ['0xc42edfcc21ed14dda456aa0756c153f7985d8813'], + }, + ], + date: 1597334833084, + id: 'e01bada4-ddc7-47b6-be67-d4603733e0e9', + invoker: 'https://faucet.metamask.io', + parentCapability: 'eth_accounts', + }, + ], + }, + 'https://metamask.github.io': { + permissions: [ + { + '@context': ['https://github.com/MetaMask/rpc-cap'], + caveats: [ + { + name: 'primaryAccountOnly', + type: 'limitResponseLength', + value: 1, + }, + { + name: 'exposedAccounts', + type: 'filterResponse', + value: ['0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'], + }, + ], + date: 1616006369498, + id: '3d0bdc27-e8e4-4fb0-a24b-340d61f6a3fa', + invoker: 'https://metamask.github.io', + parentCapability: 'eth_accounts', + }, + ], + }, + 'https://xdai.io': { + permissions: [ + { + '@context': ['https://github.com/MetaMask/rpc-cap'], + caveats: [ + { + name: 'primaryAccountOnly', + type: 'limitResponseLength', + value: 1, + }, + { + name: 'exposedAccounts', + type: 'filterResponse', + value: ['0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'], + }, + ], + date: 1605908022382, + id: '88c5de24-11a9-4f1e-9651-b072f4c11928', + invoker: 'https://xdai.io', + parentCapability: 'eth_accounts', + }, + ], + }, + }, + permissionsDescriptions: {}, + permissionsRequests: [], + }, + PermissionsMetadata: { + domainMetadata: { + 'https://1inch.exchange': { + host: '1inch.exchange', + icon: 'https://1inch.exchange/assets/favicon/favicon-32x32.png', + lastUpdated: 1605489265143, + name: 'DEX Aggregator - 1inch.exchange', + }, + 'https://ascii-tree-generator.com': { + host: 'ascii-tree-generator.com', + icon: 'https://ascii-tree-generator.com/favicon.ico', + lastUpdated: 1637721988618, + name: 'ASCII Tree Generator', + }, + 'https://caniuse.com': { + host: 'caniuse.com', + icon: 'https://caniuse.com/img/favicon-128.png', + lastUpdated: 1637692936599, + name: 'Can I use... Support tables for HTML5, CSS3, etc', + }, + 'https://core-geth.org': { + host: 'core-geth.org', + icon: 'https://core-geth.org/icons/icon-48x48.png', + lastUpdated: 1637692093173, + name: 'core-geth.org', + }, + 'https://docs.npmjs.com': { + host: 'docs.npmjs.com', + icon: 'https://docs.npmjs.com/favicon-32x32.png', + lastUpdated: 1637721451476, + name: 'package-locks | npm Docs', + }, + }, + permissionsHistory: { + 'https://opensea.io': { + eth_accounts: { + accounts: { + '0xc42edfcc21ed14dda456aa0756c153f7985d8813': 1617399873696, + }, + lastApproved: 1617399873696, + }, + }, + 'https://faucet.metamask.io': { + eth_accounts: { + accounts: { + '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': 1620369333736, + }, + lastApproved: 1610405614031, + }, + }, + 'https://metamask.github.io': { + eth_accounts: { + accounts: { + '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': 1620759882723, + '0xf9eab18b7db3adf8cd6bd5f4aed9e1d5e0e7f926': 1616005950557, + }, + lastApproved: 1620759882723, + }, + }, + 'https://xdai.io': { + eth_accounts: { + accounts: { + '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': 1620369333736, + }, + lastApproved: 1605908022384, + }, + }, + }, + permissionsLog: [ + { + id: 3642448888, + method: 'eth_accounts', + methodType: 'restricted', + origin: 'https://metamask.github.io', + request: { + id: 3642448888, + jsonrpc: '2.0', + method: 'eth_accounts', + origin: 'https://metamask.github.io', + tabId: 489, + }, + requestTime: 1615325885561, + response: { + id: 3642448888, + jsonrpc: '2.0', + result: [], + }, + responseTime: 1615325885561, + success: true, + }, + { + id: 2960964763, + method: 'wallet_getPermissions', + methodType: 'internal', + origin: 'https://metamask.github.io', + request: { + id: 2960964763, + jsonrpc: '2.0', + method: 'wallet_getPermissions', + origin: 'https://metamask.github.io', + tabId: 145, + }, + requestTime: 1620759866273, + response: { + id: 2960964763, + jsonrpc: '2.0', + result: [ + { + '@context': ['https://github.com/MetaMask/rpc-cap'], + caveats: [ + { + name: 'primaryAccountOnly', + type: 'limitResponseLength', + value: 1, + }, + { + name: 'exposedAccounts', + type: 'filterResponse', + value: ['0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'], + }, + ], + date: 1616006369498, + id: '3d0bdc27-e8e4-4fb0-a24b-340d61f6a3fa', + invoker: 'https://metamask.github.io', + parentCapability: 'eth_accounts', + }, + ], + }, + responseTime: 1620759866273, + success: true, + }, + { + id: 2960964764, + method: 'eth_accounts', + methodType: 'restricted', + origin: 'https://metamask.github.io', + request: { + id: 2960964764, + jsonrpc: '2.0', + method: 'eth_accounts', + origin: 'https://metamask.github.io', + tabId: 145, + }, + requestTime: 1620759866280, + response: { + id: 2960964764, + jsonrpc: '2.0', + result: [], + }, + responseTime: 1620759866280, + success: true, + }, + { + id: 519616456, + method: 'eth_accounts', + methodType: 'restricted', + origin: 'http://localhost:9011', + request: + '{\n "method": "eth_accounts",\n "jsonrpc": "2.0",\n "id": 519616456,\n "origin": "http://localhost:9011",\n "tabId": 1020\n}', + requestTime: 1636479612050, + response: + '{\n "id": 519616456,\n "jsonrpc": "2.0",\n "result": []\n}', + responseTime: 1636479612051, + success: true, + }, + ], + }, + }; +} diff --git a/app/scripts/migrations/069.js b/app/scripts/migrations/069.js new file mode 100644 index 000000000..8635fb9cd --- /dev/null +++ b/app/scripts/migrations/069.js @@ -0,0 +1,41 @@ +import { cloneDeep } from 'lodash'; +import { SUBJECT_TYPES } from '../../../shared/constants/app'; + +const version = 69; + +/** + * Adds the `subjectType` property to all subject metadata. + */ +export default { + version, + async migrate(originalVersionedData) { + const versionedData = cloneDeep(originalVersionedData); + versionedData.meta.version = version; + const state = versionedData.data; + const newState = transformState(state); + versionedData.data = newState; + return versionedData; + }, +}; + +function transformState(state) { + if (typeof state?.SubjectMetadataController?.subjectMetadata === 'object') { + const { + SubjectMetadataController: { subjectMetadata }, + } = state; + + // mutate SubjectMetadataController.subjectMetadata in place + Object.values(subjectMetadata).forEach((metadata) => { + if ( + metadata && + typeof metadata === 'object' && + !Array.isArray(metadata) + ) { + metadata.subjectType = metadata.extensionId + ? SUBJECT_TYPES.EXTENSION + : SUBJECT_TYPES.WEBSITE; + } + }); + } + return state; +} diff --git a/app/scripts/migrations/069.test.js b/app/scripts/migrations/069.test.js new file mode 100644 index 000000000..8a830d693 --- /dev/null +++ b/app/scripts/migrations/069.test.js @@ -0,0 +1,102 @@ +import { SUBJECT_TYPES } from '../../../shared/constants/app'; +import migration69 from './069'; + +describe('migration #69', () => { + it('should update the version metadata', async () => { + const oldStorage = { + meta: { + version: 68, + }, + data: {}, + }; + + const newStorage = await migration69.migrate(oldStorage); + expect(newStorage.meta).toStrictEqual({ + version: 69, + }); + }); + + it('should migrate all data', async () => { + const oldStorage = { + meta: { + version: 68, + }, + data: { + FooController: { a: 'b' }, + SubjectMetadataController: { + subjectMetadata: { + 'https://1inch.exchange': { + iconUrl: + 'https://1inch.exchange/assets/favicon/favicon-32x32.png', + name: 'DEX Aggregator - 1inch.exchange', + origin: 'https://1inch.exchange', + extensionId: null, + }, + 'https://ascii-tree-generator.com': { + iconUrl: 'https://ascii-tree-generator.com/favicon.ico', + name: 'ASCII Tree Generator', + origin: 'https://ascii-tree-generator.com', + extensionId: 'ascii-tree-generator-extension', + }, + 'https://null.com': null, + 'https://foo.com': 'bad data', + 'https://bar.com': ['bad data'], + }, + }, + }, + }; + + const newStorage = await migration69.migrate(oldStorage); + expect(newStorage).toStrictEqual({ + meta: { + version: 69, + }, + data: { + FooController: { a: 'b' }, + SubjectMetadataController: { + subjectMetadata: { + 'https://1inch.exchange': { + iconUrl: + 'https://1inch.exchange/assets/favicon/favicon-32x32.png', + name: 'DEX Aggregator - 1inch.exchange', + origin: 'https://1inch.exchange', + extensionId: null, + subjectType: SUBJECT_TYPES.WEBSITE, + }, + 'https://ascii-tree-generator.com': { + iconUrl: 'https://ascii-tree-generator.com/favicon.ico', + name: 'ASCII Tree Generator', + origin: 'https://ascii-tree-generator.com', + extensionId: 'ascii-tree-generator-extension', + subjectType: SUBJECT_TYPES.EXTENSION, + }, + 'https://null.com': null, + 'https://foo.com': 'bad data', + 'https://bar.com': ['bad data'], + }, + }, + }, + }); + }); + + it('should handle missing SubjectMetadataController', async () => { + const oldStorage = { + meta: { + version: 68, + }, + data: { + FooController: { a: 'b' }, + }, + }; + + const newStorage = await migration69.migrate(oldStorage); + expect(newStorage).toStrictEqual({ + meta: { + version: 69, + }, + data: { + FooController: { a: 'b' }, + }, + }); + }); +}); diff --git a/app/scripts/migrations/index.js b/app/scripts/migrations/index.js index 2471381f5..384b3a306 100644 --- a/app/scripts/migrations/index.js +++ b/app/scripts/migrations/index.js @@ -71,6 +71,8 @@ import m064 from './064'; import m065 from './065'; import m066 from './066'; import m067 from './067'; +import m068 from './068'; +import m069 from './069'; const migrations = [ m002, @@ -139,6 +141,8 @@ const migrations = [ m065, m066, m067, + m068, + m069, ]; export default migrations; diff --git a/crowdin.yml b/crowdin.yml new file mode 100644 index 000000000..60bc9c2fc --- /dev/null +++ b/crowdin.yml @@ -0,0 +1,13 @@ +"project_id_env": CROWDIN_PROJECT_ID +"api_token_env": CROWDIN_PERSONAL_TOKEN +"base_path" : "." +"base_url" : "https://metamask.crowdin.com" + +"preserve_hierarchy": true + +files: [ + { + "source" : "app/_locales/en/messages.json", + "translation" : "/app/_locales/%two_letters_code%/%original_file_name%", + } +] diff --git a/development/build/index.js b/development/build/index.js index 669cf6826..1c6fbf43c 100755 --- a/development/build/index.js +++ b/development/build/index.js @@ -23,6 +23,7 @@ const { BuildType, getBrowserVersionMap } = require('./utils'); // Packages required dynamically via browserify configuration in dependencies // Required for LavaMoat policy generation require('loose-envify'); +require('globalthis'); require('@babel/plugin-proposal-object-rest-spread'); require('@babel/plugin-transform-runtime'); require('@babel/plugin-proposal-class-properties'); diff --git a/development/build/manifest.js b/development/build/manifest.js index a87fd53de..524f2ef3b 100644 --- a/development/build/manifest.js +++ b/development/build/manifest.js @@ -1,6 +1,6 @@ const { promises: fs } = require('fs'); const path = require('path'); -const { merge, cloneDeep } = require('lodash'); +const { mergeWith, cloneDeep } = require('lodash'); const baseManifest = require('../../app/manifest/_base.json'); @@ -28,11 +28,12 @@ function createManifestTasks({ `${platform}.json`, ), ); - const result = merge( + const result = mergeWith( cloneDeep(baseManifest), platformModifications, browserVersionMap[platform], await getBuildModifications(buildType, platform), + customArrayMerge, ); const dir = path.join('.', 'dist', platform); await fs.mkdir(dir, { recursive: true }); @@ -99,6 +100,14 @@ function createManifestTasks({ ); }; } + + // helper for merging obj value + function customArrayMerge(objValue, srcValue) { + if (Array.isArray(objValue)) { + return [...new Set([...objValue, ...srcValue])]; + } + return undefined; + } } // helper for reading and deserializing json from fs diff --git a/development/build/scripts.js b/development/build/scripts.js index cf49dd302..596497766 100644 --- a/development/build/scripts.js +++ b/development/build/scripts.js @@ -366,6 +366,7 @@ function createFactoredBuild({ minify, reloadOnChange, shouldLintFenceFiles, + testing, }); // set bundle entries @@ -543,6 +544,7 @@ function createNormalBundle({ minify, reloadOnChange, shouldLintFenceFiles, + testing, }); // set bundle entries @@ -599,6 +601,7 @@ function setupBundlerDefaults( minify, reloadOnChange, shouldLintFenceFiles, + testing, }, ) { const { bundlerOpts } = buildConfiguration; @@ -620,8 +623,9 @@ function setupBundlerDefaults( }); // Ensure react-devtools are not included in non-dev builds - if (!devMode) { + if (!devMode || testing) { bundlerOpts.manualIgnore.push('react-devtools'); + bundlerOpts.manualIgnore.push('remote-redux-devtools'); } // Inject environment variables via node-style `process.env` @@ -779,7 +783,7 @@ function getEnvironmentVariables({ buildType, devMode, testing }) { METAMASK_VERSION: version, METAMASK_BUILD_TYPE: buildType, NODE_ENV: devMode ? ENVIRONMENT.DEVELOPMENT : ENVIRONMENT.PRODUCTION, - IN_TEST: testing ? 'true' : false, + IN_TEST: testing, PUBNUB_SUB_KEY: process.env.PUBNUB_SUB_KEY || '', PUBNUB_PUB_KEY: process.env.PUBNUB_PUB_KEY || '', CONF: devMode ? metamaskrc : {}, diff --git a/development/build/transforms/remove-fenced-code.js b/development/build/transforms/remove-fenced-code.js index 632660ec2..6cf67b70f 100644 --- a/development/build/transforms/remove-fenced-code.js +++ b/development/build/transforms/remove-fenced-code.js @@ -3,7 +3,7 @@ const { PassThrough, Transform } = require('stream'); const { BuildType } = require('../utils'); const { lintTransformedFile } = require('./utils'); -const hasOwnProperty = (obj, key) => Reflect.hasOwnProperty.call(obj, key); +const hasKey = (obj, key) => Reflect.hasOwnProperty.call(obj, key); module.exports = { createRemoveFencedCodeTransform, @@ -90,7 +90,7 @@ function createRemoveFencedCodeTransform( buildType, shouldLintTransformedFiles = true, ) { - if (!hasOwnProperty(BuildType, buildType)) { + if (!hasKey(BuildType, buildType)) { throw new Error( `Code fencing transform received unrecognized build type "${buildType}".`, ); @@ -140,7 +140,7 @@ const CommandValidators = { } params.forEach((param) => { - if (!hasOwnProperty(BuildType, param)) { + if (!hasKey(BuildType, param)) { throw new Error( getInvalidParamsMessage( filePath, @@ -250,7 +250,7 @@ function removeFencedCode(filePath, typeOfCurrentBuild, fileContent) { // The first element of a RegEx match array is the input const [, terminus, command, parameters] = directiveMatches; - if (!hasOwnProperty(DirectiveTerminuses, terminus)) { + if (!hasKey(DirectiveTerminuses, terminus)) { throw new Error( getInvalidFenceLineMessage( filePath, @@ -259,7 +259,8 @@ function removeFencedCode(filePath, typeOfCurrentBuild, fileContent) { ), ); } - if (!hasOwnProperty(DirectiveCommands, command)) { + + if (!hasKey(DirectiveCommands, command)) { throw new Error( getInvalidFenceLineMessage( filePath, diff --git a/development/metamaskbot-build-announce.js b/development/metamaskbot-build-announce.js index 373190b76..2d09a93d1 100755 --- a/development/metamaskbot-build-announce.js +++ b/development/metamaskbot-build-announce.js @@ -240,7 +240,7 @@ async function start() { body: JSON_PAYLOAD, headers: { 'User-Agent': 'metamaskbot', - 'Authorization': `token ${GITHUB_COMMENT_TOKEN}`, + Authorization: `token ${GITHUB_COMMENT_TOKEN}`, }, }); if (!response.ok) { diff --git a/docs/add-to-firefox.md b/docs/add-to-firefox.md index 8eff28222..20810f9a6 100644 --- a/docs/add-to-firefox.md +++ b/docs/add-to-firefox.md @@ -1,6 +1,6 @@ # Add Custom Build to Firefox -Go to the url `about:debugging`. +Go to the url `about:debugging#addons`. Click the button `Load Temporary Add-On`. diff --git a/jest.config.js b/jest.config.js index 186afbfe9..f7bcdf51f 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,5 +1,9 @@ module.exports = { - collectCoverageFrom: ['/ui/**/*.js', '/shared/**/*.js'], + collectCoverageFrom: [ + '/app/scripts/controllers/permissions/*.js', + '/shared/**/*.js', + '/ui/**/*.js', + ], coverageDirectory: './jest-coverage/main', coveragePathIgnorePatterns: ['.stories.js', '.snap'], coverageReporters: ['html', 'text-summary'], @@ -10,6 +14,12 @@ module.exports = { lines: 43, statements: 43, }, + './app/scripts/controllers/permissions/*.js': { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, }, // TODO: enable resetMocks // resetMocks: true, @@ -19,8 +29,11 @@ module.exports = { testMatch: [ '/ui/**/*.test.js', '/shared/**/*.test.js', + '/app/scripts/lib/**/*.test.js', '/app/scripts/migrations/*.test.js', '/app/scripts/platforms/*.test.js', + 'app/scripts/controllers/network/**/*.test.js', + '/app/scripts/controllers/permissions/*.test.js', ], testTimeout: 2500, transform: { diff --git a/lavamoat/browserify/beta/policy.json b/lavamoat/browserify/beta/policy.json index 7c0b7ce6d..82bea2bfe 100644 --- a/lavamoat/browserify/beta/policy.json +++ b/lavamoat/browserify/beta/policy.json @@ -534,6 +534,7 @@ "@metamask/controllers": { "globals": { "Headers": true, + "URL": true, "clearInterval": true, "clearTimeout": true, "console.error": true, @@ -548,7 +549,6 @@ "@metamask/contract-metadata": true, "abort-controller": true, "async-mutex": true, - "await-semaphore": true, "buffer": true, "eth-ens-namehash": true, "eth-json-rpc-infura": true, @@ -558,11 +558,9 @@ "eth-query": true, "eth-rpc-errors": true, "eth-sig-util": true, - "ethereumjs-tx": true, "ethereumjs-util": true, "ethereumjs-wallet": true, "ethers": true, - "ethjs-query": true, "ethjs-unit": true, "ethjs-util": true, "events": true, @@ -572,6 +570,7 @@ "immer": true, "isomorphic-fetch": true, "jsonschema": true, + "multiformats": true, "nanoid": true, "punycode": true, "single-call-balance-checker-abi": true, @@ -628,6 +627,16 @@ "mersenne-twister": true } }, + "@metamask/key-tree": { + "packages": { + "bip39": true, + "buffer": true, + "crypto-browserify": true, + "is-buffer": true, + "keccak": true, + "secp256k1": true + } + }, "@metamask/logo": { "globals": { "addEventListener": true, @@ -642,6 +651,16 @@ "gl-vec3": true } }, + "@metamask/object-multiplex": { + "globals": { + "console.warn": true + }, + "packages": { + "end-of-stream": true, + "once": true, + "readable-stream": true + } + }, "@metamask/obs-store": { "globals": { "localStorage": true @@ -652,6 +671,25 @@ "through2": true } }, + "@metamask/post-message-stream": { + "globals": { + "addEventListener": true, + "location.origin": true, + "onmessage": "write", + "postMessage": true, + "removeEventListener": true + }, + "packages": { + "readable-stream": true + } + }, + "@metamask/rpc-methods": { + "packages": { + "@metamask/key-tree": true, + "@metamask/snap-controllers": true, + "eth-rpc-errors": true + } + }, "@metamask/safe-event-emitter": { "globals": { "setTimeout": true @@ -660,6 +698,33 @@ "events": true } }, + "@metamask/snap-controllers": { + "globals": { + "Worker": true, + "clearTimeout": true, + "console.error": true, + "console.log": true, + "console.warn": true, + "fetch": true, + "setTimeout": true + }, + "packages": { + "@metamask/controllers": true, + "@metamask/object-multiplex": true, + "@metamask/obs-store": true, + "@metamask/post-message-stream": true, + "@metamask/safe-event-emitter": true, + "@metamask/snap-workers": true, + "deep-freeze-strict": true, + "eth-rpc-errors": true, + "fast-deep-equal": true, + "immer": true, + "json-rpc-engine": true, + "json-rpc-middleware-stream": true, + "nanoid": true, + "pump": true + } + }, "@ngraveio/bc-ur": { "packages": { "@apocentre/alias-sampling": true, @@ -1069,6 +1134,7 @@ }, "bip39": { "packages": { + "buffer": true, "create-hash": true, "pbkdf2": true, "randombytes": true, @@ -1902,13 +1968,6 @@ "ethereumjs-util": true } }, - "ethereumjs-tx": { - "packages": { - "buffer": true, - "ethereum-common": true, - "ethereumjs-util": true - } - }, "ethereumjs-util": { "packages": { "assert": true, @@ -2183,11 +2242,6 @@ "has-symbols": true } }, - "get-params": { - "globals": { - "GetParams": "write" - } - }, "graphql-request": { "globals": { "fetch": true @@ -2826,11 +2880,6 @@ "process": true } }, - "jsan": { - "globals": { - "console.warn": true - } - }, "jsbi": { "globals": { "define": true @@ -2849,7 +2898,11 @@ } }, "json-rpc-middleware-stream": { + "globals": { + "setTimeout": true + }, "packages": { + "@metamask/safe-event-emitter": true, "readable-stream": true } }, @@ -3497,6 +3550,13 @@ "varint": true } }, + "multiformats": { + "globals": { + "TextDecoder": true, + "TextEncoder": true, + "console.warn": true + } + }, "multihashes": { "packages": { "bs58": true, @@ -3562,10 +3622,11 @@ "crypto": true, "msCrypto": true, "navigator": true - }, + } + }, + "no-case": { "packages": { - "buffer": true, - "crypto-browserify": true + "lower-case": true } }, "no-case": { @@ -4415,35 +4476,6 @@ "@babel/runtime": true } }, - "redux-devtools-core": { - "globals": { - "ErrorUtils": true, - "console": true, - "devToolsOptions": true, - "onerror": "write", - "serializeState": true - }, - "packages": { - "get-params": true, - "jsan": true, - "lodash": true, - "nanoid": true, - "remotedev-serialize": true - } - }, - "redux-devtools-instrument": { - "globals": { - "chrome": true, - "console.error": true, - "process": true, - "setTimeout": true - }, - "packages": { - "lodash": true, - "process": true, - "symbol-observable": true - } - }, "regenerator-runtime": { "globals": { "regeneratorRuntime": "write" @@ -4460,21 +4492,6 @@ "url": true } }, - "remote-redux-devtools": { - "globals": { - "console.log": true, - "console.warn": true, - "fetch": true, - "setTimeout": true - }, - "packages": { - "jsan": true, - "redux-devtools-core": true, - "redux-devtools-instrument": true, - "rn-host-detect": true, - "socketcluster-client": true - } - }, "retimer": { "globals": { "clearTimeout": true, @@ -4500,22 +4517,6 @@ "buffer": true } }, - "rn-host-detect": { - "globals": { - "__DEV__": true, - "__fbBatchedBridgeConfig": true, - "console": true - } - }, - "rpc-cap": { - "packages": { - "@metamask/controllers": true, - "eth-rpc-errors": true, - "is-subset": true, - "json-rpc-engine": true, - "uuid": true - } - }, "safe-buffer": { "packages": { "buffer": true @@ -4535,16 +4536,6 @@ "truncate-utf8-bytes": true } }, - "sc-channel": { - "packages": { - "component-emitter": true - } - }, - "sc-formatter": { - "globals": { - "Buffer": true - } - }, "scheduler": { "globals": { "MessageChannel": true, @@ -4701,29 +4692,6 @@ "uuid": true } }, - "socketcluster-client": { - "globals": { - "WebSocket": true, - "WorkerGlobalScope": true, - "addEventListener": true, - "clearTimeout": true, - "localStorage": true, - "location": true, - "removeEventListener": true, - "setTimeout": true - }, - "packages": { - "buffer": true, - "clone": true, - "component-emitter": true, - "linked-list": true, - "querystring-es3": true, - "sc-channel": true, - "sc-errors": true, - "sc-formatter": true, - "uuid": true - } - }, "sort-keys": { "packages": { "is-plain-obj": true diff --git a/lavamoat/browserify/flask/policy.json b/lavamoat/browserify/flask/policy.json index 7c0b7ce6d..82bea2bfe 100644 --- a/lavamoat/browserify/flask/policy.json +++ b/lavamoat/browserify/flask/policy.json @@ -534,6 +534,7 @@ "@metamask/controllers": { "globals": { "Headers": true, + "URL": true, "clearInterval": true, "clearTimeout": true, "console.error": true, @@ -548,7 +549,6 @@ "@metamask/contract-metadata": true, "abort-controller": true, "async-mutex": true, - "await-semaphore": true, "buffer": true, "eth-ens-namehash": true, "eth-json-rpc-infura": true, @@ -558,11 +558,9 @@ "eth-query": true, "eth-rpc-errors": true, "eth-sig-util": true, - "ethereumjs-tx": true, "ethereumjs-util": true, "ethereumjs-wallet": true, "ethers": true, - "ethjs-query": true, "ethjs-unit": true, "ethjs-util": true, "events": true, @@ -572,6 +570,7 @@ "immer": true, "isomorphic-fetch": true, "jsonschema": true, + "multiformats": true, "nanoid": true, "punycode": true, "single-call-balance-checker-abi": true, @@ -628,6 +627,16 @@ "mersenne-twister": true } }, + "@metamask/key-tree": { + "packages": { + "bip39": true, + "buffer": true, + "crypto-browserify": true, + "is-buffer": true, + "keccak": true, + "secp256k1": true + } + }, "@metamask/logo": { "globals": { "addEventListener": true, @@ -642,6 +651,16 @@ "gl-vec3": true } }, + "@metamask/object-multiplex": { + "globals": { + "console.warn": true + }, + "packages": { + "end-of-stream": true, + "once": true, + "readable-stream": true + } + }, "@metamask/obs-store": { "globals": { "localStorage": true @@ -652,6 +671,25 @@ "through2": true } }, + "@metamask/post-message-stream": { + "globals": { + "addEventListener": true, + "location.origin": true, + "onmessage": "write", + "postMessage": true, + "removeEventListener": true + }, + "packages": { + "readable-stream": true + } + }, + "@metamask/rpc-methods": { + "packages": { + "@metamask/key-tree": true, + "@metamask/snap-controllers": true, + "eth-rpc-errors": true + } + }, "@metamask/safe-event-emitter": { "globals": { "setTimeout": true @@ -660,6 +698,33 @@ "events": true } }, + "@metamask/snap-controllers": { + "globals": { + "Worker": true, + "clearTimeout": true, + "console.error": true, + "console.log": true, + "console.warn": true, + "fetch": true, + "setTimeout": true + }, + "packages": { + "@metamask/controllers": true, + "@metamask/object-multiplex": true, + "@metamask/obs-store": true, + "@metamask/post-message-stream": true, + "@metamask/safe-event-emitter": true, + "@metamask/snap-workers": true, + "deep-freeze-strict": true, + "eth-rpc-errors": true, + "fast-deep-equal": true, + "immer": true, + "json-rpc-engine": true, + "json-rpc-middleware-stream": true, + "nanoid": true, + "pump": true + } + }, "@ngraveio/bc-ur": { "packages": { "@apocentre/alias-sampling": true, @@ -1069,6 +1134,7 @@ }, "bip39": { "packages": { + "buffer": true, "create-hash": true, "pbkdf2": true, "randombytes": true, @@ -1902,13 +1968,6 @@ "ethereumjs-util": true } }, - "ethereumjs-tx": { - "packages": { - "buffer": true, - "ethereum-common": true, - "ethereumjs-util": true - } - }, "ethereumjs-util": { "packages": { "assert": true, @@ -2183,11 +2242,6 @@ "has-symbols": true } }, - "get-params": { - "globals": { - "GetParams": "write" - } - }, "graphql-request": { "globals": { "fetch": true @@ -2826,11 +2880,6 @@ "process": true } }, - "jsan": { - "globals": { - "console.warn": true - } - }, "jsbi": { "globals": { "define": true @@ -2849,7 +2898,11 @@ } }, "json-rpc-middleware-stream": { + "globals": { + "setTimeout": true + }, "packages": { + "@metamask/safe-event-emitter": true, "readable-stream": true } }, @@ -3497,6 +3550,13 @@ "varint": true } }, + "multiformats": { + "globals": { + "TextDecoder": true, + "TextEncoder": true, + "console.warn": true + } + }, "multihashes": { "packages": { "bs58": true, @@ -3562,10 +3622,11 @@ "crypto": true, "msCrypto": true, "navigator": true - }, + } + }, + "no-case": { "packages": { - "buffer": true, - "crypto-browserify": true + "lower-case": true } }, "no-case": { @@ -4415,35 +4476,6 @@ "@babel/runtime": true } }, - "redux-devtools-core": { - "globals": { - "ErrorUtils": true, - "console": true, - "devToolsOptions": true, - "onerror": "write", - "serializeState": true - }, - "packages": { - "get-params": true, - "jsan": true, - "lodash": true, - "nanoid": true, - "remotedev-serialize": true - } - }, - "redux-devtools-instrument": { - "globals": { - "chrome": true, - "console.error": true, - "process": true, - "setTimeout": true - }, - "packages": { - "lodash": true, - "process": true, - "symbol-observable": true - } - }, "regenerator-runtime": { "globals": { "regeneratorRuntime": "write" @@ -4460,21 +4492,6 @@ "url": true } }, - "remote-redux-devtools": { - "globals": { - "console.log": true, - "console.warn": true, - "fetch": true, - "setTimeout": true - }, - "packages": { - "jsan": true, - "redux-devtools-core": true, - "redux-devtools-instrument": true, - "rn-host-detect": true, - "socketcluster-client": true - } - }, "retimer": { "globals": { "clearTimeout": true, @@ -4500,22 +4517,6 @@ "buffer": true } }, - "rn-host-detect": { - "globals": { - "__DEV__": true, - "__fbBatchedBridgeConfig": true, - "console": true - } - }, - "rpc-cap": { - "packages": { - "@metamask/controllers": true, - "eth-rpc-errors": true, - "is-subset": true, - "json-rpc-engine": true, - "uuid": true - } - }, "safe-buffer": { "packages": { "buffer": true @@ -4535,16 +4536,6 @@ "truncate-utf8-bytes": true } }, - "sc-channel": { - "packages": { - "component-emitter": true - } - }, - "sc-formatter": { - "globals": { - "Buffer": true - } - }, "scheduler": { "globals": { "MessageChannel": true, @@ -4701,29 +4692,6 @@ "uuid": true } }, - "socketcluster-client": { - "globals": { - "WebSocket": true, - "WorkerGlobalScope": true, - "addEventListener": true, - "clearTimeout": true, - "localStorage": true, - "location": true, - "removeEventListener": true, - "setTimeout": true - }, - "packages": { - "buffer": true, - "clone": true, - "component-emitter": true, - "linked-list": true, - "querystring-es3": true, - "sc-channel": true, - "sc-errors": true, - "sc-formatter": true, - "uuid": true - } - }, "sort-keys": { "packages": { "is-plain-obj": true diff --git a/lavamoat/browserify/main/policy.json b/lavamoat/browserify/main/policy.json index 7c0b7ce6d..82bea2bfe 100644 --- a/lavamoat/browserify/main/policy.json +++ b/lavamoat/browserify/main/policy.json @@ -534,6 +534,7 @@ "@metamask/controllers": { "globals": { "Headers": true, + "URL": true, "clearInterval": true, "clearTimeout": true, "console.error": true, @@ -548,7 +549,6 @@ "@metamask/contract-metadata": true, "abort-controller": true, "async-mutex": true, - "await-semaphore": true, "buffer": true, "eth-ens-namehash": true, "eth-json-rpc-infura": true, @@ -558,11 +558,9 @@ "eth-query": true, "eth-rpc-errors": true, "eth-sig-util": true, - "ethereumjs-tx": true, "ethereumjs-util": true, "ethereumjs-wallet": true, "ethers": true, - "ethjs-query": true, "ethjs-unit": true, "ethjs-util": true, "events": true, @@ -572,6 +570,7 @@ "immer": true, "isomorphic-fetch": true, "jsonschema": true, + "multiformats": true, "nanoid": true, "punycode": true, "single-call-balance-checker-abi": true, @@ -628,6 +627,16 @@ "mersenne-twister": true } }, + "@metamask/key-tree": { + "packages": { + "bip39": true, + "buffer": true, + "crypto-browserify": true, + "is-buffer": true, + "keccak": true, + "secp256k1": true + } + }, "@metamask/logo": { "globals": { "addEventListener": true, @@ -642,6 +651,16 @@ "gl-vec3": true } }, + "@metamask/object-multiplex": { + "globals": { + "console.warn": true + }, + "packages": { + "end-of-stream": true, + "once": true, + "readable-stream": true + } + }, "@metamask/obs-store": { "globals": { "localStorage": true @@ -652,6 +671,25 @@ "through2": true } }, + "@metamask/post-message-stream": { + "globals": { + "addEventListener": true, + "location.origin": true, + "onmessage": "write", + "postMessage": true, + "removeEventListener": true + }, + "packages": { + "readable-stream": true + } + }, + "@metamask/rpc-methods": { + "packages": { + "@metamask/key-tree": true, + "@metamask/snap-controllers": true, + "eth-rpc-errors": true + } + }, "@metamask/safe-event-emitter": { "globals": { "setTimeout": true @@ -660,6 +698,33 @@ "events": true } }, + "@metamask/snap-controllers": { + "globals": { + "Worker": true, + "clearTimeout": true, + "console.error": true, + "console.log": true, + "console.warn": true, + "fetch": true, + "setTimeout": true + }, + "packages": { + "@metamask/controllers": true, + "@metamask/object-multiplex": true, + "@metamask/obs-store": true, + "@metamask/post-message-stream": true, + "@metamask/safe-event-emitter": true, + "@metamask/snap-workers": true, + "deep-freeze-strict": true, + "eth-rpc-errors": true, + "fast-deep-equal": true, + "immer": true, + "json-rpc-engine": true, + "json-rpc-middleware-stream": true, + "nanoid": true, + "pump": true + } + }, "@ngraveio/bc-ur": { "packages": { "@apocentre/alias-sampling": true, @@ -1069,6 +1134,7 @@ }, "bip39": { "packages": { + "buffer": true, "create-hash": true, "pbkdf2": true, "randombytes": true, @@ -1902,13 +1968,6 @@ "ethereumjs-util": true } }, - "ethereumjs-tx": { - "packages": { - "buffer": true, - "ethereum-common": true, - "ethereumjs-util": true - } - }, "ethereumjs-util": { "packages": { "assert": true, @@ -2183,11 +2242,6 @@ "has-symbols": true } }, - "get-params": { - "globals": { - "GetParams": "write" - } - }, "graphql-request": { "globals": { "fetch": true @@ -2826,11 +2880,6 @@ "process": true } }, - "jsan": { - "globals": { - "console.warn": true - } - }, "jsbi": { "globals": { "define": true @@ -2849,7 +2898,11 @@ } }, "json-rpc-middleware-stream": { + "globals": { + "setTimeout": true + }, "packages": { + "@metamask/safe-event-emitter": true, "readable-stream": true } }, @@ -3497,6 +3550,13 @@ "varint": true } }, + "multiformats": { + "globals": { + "TextDecoder": true, + "TextEncoder": true, + "console.warn": true + } + }, "multihashes": { "packages": { "bs58": true, @@ -3562,10 +3622,11 @@ "crypto": true, "msCrypto": true, "navigator": true - }, + } + }, + "no-case": { "packages": { - "buffer": true, - "crypto-browserify": true + "lower-case": true } }, "no-case": { @@ -4415,35 +4476,6 @@ "@babel/runtime": true } }, - "redux-devtools-core": { - "globals": { - "ErrorUtils": true, - "console": true, - "devToolsOptions": true, - "onerror": "write", - "serializeState": true - }, - "packages": { - "get-params": true, - "jsan": true, - "lodash": true, - "nanoid": true, - "remotedev-serialize": true - } - }, - "redux-devtools-instrument": { - "globals": { - "chrome": true, - "console.error": true, - "process": true, - "setTimeout": true - }, - "packages": { - "lodash": true, - "process": true, - "symbol-observable": true - } - }, "regenerator-runtime": { "globals": { "regeneratorRuntime": "write" @@ -4460,21 +4492,6 @@ "url": true } }, - "remote-redux-devtools": { - "globals": { - "console.log": true, - "console.warn": true, - "fetch": true, - "setTimeout": true - }, - "packages": { - "jsan": true, - "redux-devtools-core": true, - "redux-devtools-instrument": true, - "rn-host-detect": true, - "socketcluster-client": true - } - }, "retimer": { "globals": { "clearTimeout": true, @@ -4500,22 +4517,6 @@ "buffer": true } }, - "rn-host-detect": { - "globals": { - "__DEV__": true, - "__fbBatchedBridgeConfig": true, - "console": true - } - }, - "rpc-cap": { - "packages": { - "@metamask/controllers": true, - "eth-rpc-errors": true, - "is-subset": true, - "json-rpc-engine": true, - "uuid": true - } - }, "safe-buffer": { "packages": { "buffer": true @@ -4535,16 +4536,6 @@ "truncate-utf8-bytes": true } }, - "sc-channel": { - "packages": { - "component-emitter": true - } - }, - "sc-formatter": { - "globals": { - "Buffer": true - } - }, "scheduler": { "globals": { "MessageChannel": true, @@ -4701,29 +4692,6 @@ "uuid": true } }, - "socketcluster-client": { - "globals": { - "WebSocket": true, - "WorkerGlobalScope": true, - "addEventListener": true, - "clearTimeout": true, - "localStorage": true, - "location": true, - "removeEventListener": true, - "setTimeout": true - }, - "packages": { - "buffer": true, - "clone": true, - "component-emitter": true, - "linked-list": true, - "querystring-es3": true, - "sc-channel": true, - "sc-errors": true, - "sc-formatter": true, - "uuid": true - } - }, "sort-keys": { "packages": { "is-plain-obj": true diff --git a/lavamoat/build-system/policy.json b/lavamoat/build-system/policy.json index e7cf27f5e..8635670a5 100644 --- a/lavamoat/build-system/policy.json +++ b/lavamoat/build-system/policy.json @@ -2547,6 +2547,11 @@ "which": true } }, + "globalthis": { + "packages": { + "define-properties": true + } + }, "globby": { "builtin": { "fs.Stats": true, @@ -3128,7 +3133,8 @@ }, "globals": { "console.warn": true, - "process.cwd": true + "process.cwd": true, + "setTimeout": true }, "packages": { "@lavamoat/lavapack": true, diff --git a/package.json b/package.json index 0fd694cc7..b81ebf73b 100644 --- a/package.json +++ b/package.json @@ -28,17 +28,13 @@ "test:unit:jest": "./test/test-unit-jest.sh", "test:unit:global": "mocha test/unit-global/*.test.js", "test:unit:mocha": "mocha './app/**/*.test.js'", - "test:unit:lax": "mocha --config '.mocharc.lax.js' './app/**/*.test.js'", - "test:unit:strict": "mocha './app/scripts/controllers/permissions/*.test.js'", "test:e2e:chrome": "SELENIUM_BROWSER=chrome node test/e2e/run-all.js", "test:e2e:chrome:metrics": "SELENIUM_BROWSER=chrome node test/e2e/run-e2e-test.js test/e2e/metrics.spec.js", "test:e2e:firefox": "SELENIUM_BROWSER=firefox node test/e2e/run-all.js", "test:e2e:firefox:metrics": "SELENIUM_BROWSER=firefox node test/e2e/run-e2e-test.js test/e2e/metrics.spec.js", "test:e2e:single": "node test/e2e/run-e2e-test.js", - "test:coverage": "nyc --silent --check-coverage yarn test:unit:strict && nyc --silent --no-clean yarn test:unit:lax && nyc report --reporter=text --reporter=html", + "test:coverage:mocha": "nyc --reporter=text --reporter=html yarn test:unit:mocha", "test:coverage:jest": "yarn test:unit:jest --coverage --maxWorkers=2", - "test:coverage:strict": "nyc --check-coverage yarn test:unit:strict", - "test:coverage:path": "nyc --check-coverage yarn test:unit:path", "ganache:start": "./development/run-ganache.sh", "sentry:publish": "node ./development/sentry-publish.js", "lint:prettier": "prettier '**/*.json'", @@ -110,8 +106,8 @@ "@keystonehq/bc-ur-registry-eth": "^0.6.8", "@keystonehq/metamask-airgapped-keyring": "0.2.1", "@material-ui/core": "^4.11.0", - "@metamask/contract-metadata": "^1.28.0", - "@metamask/controllers": "^20.1.0", + "@metamask/contract-metadata": "^1.31.0", + "@metamask/controllers": "^22.0.0", "@metamask/eth-ledger-bridge-keyring": "^0.10.0", "@metamask/eth-token-tracker": "^3.0.1", "@metamask/etherscan-link": "^2.1.0", @@ -120,6 +116,8 @@ "@metamask/obs-store": "^5.0.0", "@metamask/post-message-stream": "^4.0.0", "@metamask/providers": "^8.1.1", + "@metamask/rpc-methods": "^0.5.0", + "@metamask/snap-controllers": "^0.4.0", "@ngraveio/bc-ur": "^1.1.6", "@popperjs/core": "^2.4.0", "@reduxjs/toolkit": "^1.6.2", @@ -207,7 +205,6 @@ "redux": "^4.0.5", "redux-thunk": "^2.3.0", "reselect": "^3.0.1", - "rpc-cap": "^3.2.1", "safe-event-emitter": "^1.0.1", "ses": "^0.12.4", "single-call-balance-checker-abi": "^1.0.0", @@ -235,10 +232,10 @@ "@lavamoat/allow-scripts": "^1.0.6", "@lavamoat/lavapack": "^2.0.4", "@metamask/auto-changelog": "^2.1.0", - "@metamask/eslint-config": "^6.0.0", - "@metamask/eslint-config-jest": "^6.0.0", - "@metamask/eslint-config-mocha": "^6.0.0", - "@metamask/eslint-config-nodejs": "^6.0.0", + "@metamask/eslint-config": "^8.0.0", + "@metamask/eslint-config-jest": "^8.0.0", + "@metamask/eslint-config-mocha": "^8.0.0", + "@metamask/eslint-config-nodejs": "^8.0.0", "@metamask/forwarder": "^1.1.0", "@metamask/test-dapp": "^4.0.1", "@sentry/cli": "^1.58.0", @@ -248,6 +245,7 @@ "@storybook/addon-knobs": "^6.3.1", "@storybook/addons": "^6.3.12", "@storybook/api": "^6.3.12", + "@storybook/client-api": "^6.3.12", "@storybook/components": "^6.3.12", "@storybook/core": "^6.3.12", "@storybook/core-events": "^6.3.0", @@ -310,7 +308,7 @@ "jsdom": "^11.2.0", "koa": "^2.7.0", "lavamoat": "^5.3.5", - "lavamoat-browserify": "^14.0.3", + "lavamoat-browserify": "^14.1.0", "lavamoat-viz": "^6.0.9", "lockfile-lint": "^4.0.0", "loose-envify": "^1.4.0", @@ -334,7 +332,7 @@ "resolve-url-loader": "^3.1.2", "sass": "^1.32.4", "sass-loader": "^10.1.1", - "selenium-webdriver": "4.0.0-alpha.7", + "selenium-webdriver": "^4.1.0", "semver": "^7.3.5", "serve-handler": "^6.1.2", "sinon": "^9.0.0", diff --git a/patches/@babel+runtime+7.15.4.patch b/patches/@babel+runtime+7.16.3.patch similarity index 100% rename from patches/@babel+runtime+7.15.4.patch rename to patches/@babel+runtime+7.16.3.patch diff --git a/patches/selenium-webdriver+4.0.0-alpha.7.patch b/patches/selenium-webdriver+4.0.0-alpha.7.patch deleted file mode 100644 index 53144785b..000000000 --- a/patches/selenium-webdriver+4.0.0-alpha.7.patch +++ /dev/null @@ -1,19 +0,0 @@ -diff --git a/node_modules/selenium-webdriver/chromium.js b/node_modules/selenium-webdriver/chromium.js -index d828ce5..87176f4 100644 ---- a/node_modules/selenium-webdriver/chromium.js -+++ b/node_modules/selenium-webdriver/chromium.js -@@ -197,6 +197,14 @@ class ServiceBuilder extends remote.DriverService.Builder { - return this.addArguments('--log-path=' + path); - } - -+ /** -+ * Enables Chrome logging. -+ * @returns {!ServiceBuilder} A self reference. -+ */ -+ enableChromeLogging() { -+ return this.addArguments('--enable-chrome-logs'); -+ } -+ - /** - * Enables verbose logging. - * @return {!ServiceBuilder} A self reference. diff --git a/shared/constants/app.js b/shared/constants/app.js index 33cf8cfd6..e9d201806 100644 --- a/shared/constants/app.js +++ b/shared/constants/app.js @@ -29,17 +29,31 @@ export const PLATFORM_FIREFOX = 'Firefox'; export const PLATFORM_OPERA = 'Opera'; export const MESSAGE_TYPE = { + ADD_ETHEREUM_CHAIN: 'wallet_addEthereumChain', + ETH_ACCOUNTS: 'eth_accounts', ETH_DECRYPT: 'eth_decrypt', ETH_GET_ENCRYPTION_PUBLIC_KEY: 'eth_getEncryptionPublicKey', + ETH_REQUEST_ACCOUNTS: 'eth_requestAccounts', ETH_SIGN: 'eth_sign', ETH_SIGN_TYPED_DATA: 'eth_signTypedData', GET_PROVIDER_STATE: 'metamask_getProviderState', LOG_WEB3_SHIM_USAGE: 'metamask_logWeb3ShimUsage', PERSONAL_SIGN: 'personal_sign', + SEND_METADATA: 'metamask_sendDomainMetadata', + SWITCH_ETHEREUM_CHAIN: 'wallet_switchEthereumChain', WATCH_ASSET: 'wallet_watchAsset', WATCH_ASSET_LEGACY: 'metamask_watchAsset', - ADD_ETHEREUM_CHAIN: 'wallet_addEthereumChain', - SWITCH_ETHEREUM_CHAIN: 'wallet_switchEthereumChain', +}; + +/** + * The different kinds of subjects that MetaMask may interact with, including + * third parties and itself (e.g. when the background communicated with the UI). + */ +export const SUBJECT_TYPES = { + EXTENSION: 'extension', + INTERNAL: 'internal', + UNKNOWN: 'unknown', + WEBSITE: 'website', }; export const POLLING_TOKEN_ENVIRONMENT_TYPES = { diff --git a/shared/constants/network.js b/shared/constants/network.js index 2210605d0..d6dc7c58d 100644 --- a/shared/constants/network.js +++ b/shared/constants/network.js @@ -171,3 +171,5 @@ export const UNSUPPORTED_RPC_METHODS = new Set([ // eth-json-rpc-middleware – but our UI does not support it. 'eth_signTransaction', ]); + +export const IPFS_DEFAULT_GATEWAY_URL = 'dweb.link'; diff --git a/shared/constants/permissions.js b/shared/constants/permissions.js index 443ba2c29..546945a4f 100644 --- a/shared/constants/permissions.js +++ b/shared/constants/permissions.js @@ -1,4 +1,7 @@ -export const CAVEAT_NAMES = { - exposedAccounts: 'exposedAccounts', - primaryAccountOnly: 'primaryAccountOnly', -}; +export const CaveatTypes = Object.freeze({ + restrictReturnedAccounts: 'restrictReturnedAccounts', +}); + +export const RestrictedMethods = Object.freeze({ + eth_accounts: 'eth_accounts', +}); diff --git a/shared/modules/conversion.utils.js b/shared/modules/conversion.utils.js index eba33fc8e..1fdc87e60 100644 --- a/shared/modules/conversion.utils.js +++ b/shared/modules/conversion.utils.js @@ -170,7 +170,7 @@ const conversionUtil = ( const getBigNumber = (value, base) => { if (!isValidBase(base)) { - throw new Error('Must specificy valid base'); + throw new Error('Must specify valid base'); } // We don't include 'number' here, because BigNumber will throw if passed diff --git a/stylelint.config.js b/stylelint.config.js index 04b62427e..b27e13ec9 100644 --- a/stylelint.config.js +++ b/stylelint.config.js @@ -73,7 +73,7 @@ module.exports = { 'function-parentheses-newline-inside': 'always-multi-line', 'function-parentheses-space-inside': 'never-single-line', 'function-whitespace-after': 'always', - 'indentation': 2, + indentation: 2, 'length-zero-no-unit': true, // 'max-empty-lines': 1, 'media-feature-colon-space-after': 'always', diff --git a/test/e2e/tests/address-book.spec.js b/test/e2e/tests/address-book.spec.js index 1468c5734..707c72e00 100644 --- a/test/e2e/tests/address-book.spec.js +++ b/test/e2e/tests/address-book.spec.js @@ -50,7 +50,7 @@ describe('Address Book', function () { const inputAmount = await driver.findElement('.unit-input__input'); await inputAmount.fill('1'); - const inputValue = await inputAmount.getAttribute('value'); + const inputValue = await inputAmount.getProperty('value'); assert.equal(inputValue, '1'); await driver.clickElement({ text: 'Next', tag: 'button' }); diff --git a/test/e2e/tests/metamask-responsive-ui.spec.js b/test/e2e/tests/metamask-responsive-ui.spec.js index adb5ad644..1be3d0c9c 100644 --- a/test/e2e/tests/metamask-responsive-ui.spec.js +++ b/test/e2e/tests/metamask-responsive-ui.spec.js @@ -178,10 +178,7 @@ describe('Metamask Responsive UI', function () { await driver.fill('#password', 'correct horse battery staple'); await driver.fill('#confirm-password', 'correct horse battery staple'); - await driver.clickElement({ - text: enLocaleMessages.restore.message, - tag: 'button', - }); + await driver.press('#confirm-password', driver.Key.ENTER); // balance renders await driver.waitForSelector({ @@ -226,7 +223,7 @@ describe('Metamask Responsive UI', function () { const inputAmount = await driver.fill('.unit-input__input', '1'); - const inputValue = await inputAmount.getAttribute('value'); + const inputValue = await inputAmount.getProperty('value'); assert.equal(inputValue, '1'); // confirming transcation diff --git a/test/e2e/tests/permissions.spec.js b/test/e2e/tests/permissions.spec.js index 7b7b12bb3..95636934d 100644 --- a/test/e2e/tests/permissions.spec.js +++ b/test/e2e/tests/permissions.spec.js @@ -62,11 +62,11 @@ describe('Permissions', function () { tag: 'h2', }); await driver.waitForSelector({ - css: '.connected-sites-list__domain-name', + css: '.connected-sites-list__subject-name', text: '127.0.0.1:8080', }); const domains = await driver.findClickableElements( - '.connected-sites-list__domain-name', + '.connected-sites-list__subject-name', ); assert.equal(domains.length, 1); diff --git a/test/e2e/tests/personal-sign.spec.js b/test/e2e/tests/personal-sign.spec.js index 7f3480aa7..a1812892d 100644 --- a/test/e2e/tests/personal-sign.spec.js +++ b/test/e2e/tests/personal-sign.spec.js @@ -28,7 +28,6 @@ describe('Personal sign', function () { await driver.clickElement('#personalSign'); await driver.waitUntilXWindowHandles(3); - const windowHandles = await driver.getAllWindowHandles(); await driver.switchToWindowWithTitle( 'MetaMask Notification', diff --git a/test/e2e/tests/send-eth.spec.js b/test/e2e/tests/send-eth.spec.js index 1fbebfaa7..7d88f121a 100644 --- a/test/e2e/tests/send-eth.spec.js +++ b/test/e2e/tests/send-eth.spec.js @@ -52,7 +52,7 @@ describe('Send ETH from inside MetaMask using default gas', function () { ); await amountMax.click(); - let inputValue = await inputAmount.getAttribute('value'); + let inputValue = await inputAmount.getProperty('value'); assert(Number(inputValue) > 24); @@ -62,7 +62,7 @@ describe('Send ETH from inside MetaMask using default gas', function () { await inputAmount.fill('1'); - inputValue = await inputAmount.getAttribute('value'); + inputValue = await inputAmount.getProperty('value'); assert.equal(inputValue, '1'); // Continue to next screen @@ -119,7 +119,7 @@ describe('Send ETH from inside MetaMask using advanced gas modal', function () { const inputAmount = await driver.findElement('.unit-input__input'); await inputAmount.fill('1'); - const inputValue = await inputAmount.getAttribute('value'); + const inputValue = await inputAmount.getProperty('value'); assert.equal(inputValue, '1'); // Continue to next screen diff --git a/test/helpers/permission-controller-helpers.js b/test/helpers/permission-controller-helpers.js deleted file mode 100644 index bc57f73d2..000000000 --- a/test/helpers/permission-controller-helpers.js +++ /dev/null @@ -1,123 +0,0 @@ -import { strict as assert } from 'assert'; -import stringify from 'fast-safe-stringify'; - -import { noop } from '../mocks/permission-controller'; - -/** - * Grants the given permissions to the given origin, using the given permissions - * controller. - * - * Just a wrapper for an rpc-cap middleware function. - * - * @param {PermissionsController} permController - The permissions controller. - * @param {string} origin - The origin to grant permissions to. - * @param {Object} permissions - The permissions to grant. - */ -export function grantPermissions(permController, origin, permissions) { - permController.permissions.grantNewPermissions(origin, permissions, {}, noop); -} - -/** - * Returns a wrapper for the given permissions controller's requestUserApproval - * function, so we don't have to worry about its internals. - * - * @param {PermissionsController} permController - The permissions controller. - * @return {Function} A convenient wrapper for the requestUserApproval function. - */ -export function getRequestUserApprovalHelper(permController) { - /** - * Returns a request object that can be passed to requestUserApproval. - * - * @param {string} id - The internal permissions request ID (not the RPC request ID). - * @param {string} [origin] - The origin of the request, if necessary. - * @returns {Object} The corresponding request object. - */ - return (id, origin = 'defaultOrigin') => { - return permController.permissions.requestUserApproval({ - metadata: { id, origin, type: 'NO_TYPE' }, - }); - }; -} - -/** - * Returns a Promise that resolves once a pending user approval has been set. - * Calls the underlying requestUserApproval function as normal, and restores it - * once the Promise is resolved. - * - * This function must be called on the permissions controller for each request. - * - * @param {PermissionsController} permController - A permissions controller. - * @returns {Promise} A Promise that resolves once a pending approval - * has been set. - */ -export function getUserApprovalPromise(permController) { - const originalFunction = permController.permissions.requestUserApproval; - return new Promise((resolveHelperPromise) => { - permController.permissions.requestUserApproval = (req) => { - const userApprovalPromise = originalFunction(req); - permController.permissions.requestUserApproval = originalFunction; - resolveHelperPromise(); - return userApprovalPromise; - }; - }); -} - -/** - * Validates an activity log entry with respect to a request, response, and - * relevant metadata. - * - * @param {Object} entry - The activity log entry to validate. - * @param {Object} req - The request that generated the entry. - * @param {Object} [res] - The response for the request, if any. - * @param {'restricted'|'internal'} methodType - The method log controller method type of the request. - * @param {boolean} success - Whether the request succeeded or not. - */ -export function validateActivityEntry(entry, req, res, methodType, success) { - assert.doesNotThrow(() => { - _validateActivityEntry(entry, req, res, methodType, success); - }, 'should have expected activity entry'); -} - -function _validateActivityEntry(entry, req, res, methodType, success) { - assert.ok(entry, 'entry should exist'); - - assert.equal(entry.id, req.id); - assert.equal(entry.method, req.method); - assert.equal(entry.origin, req.origin); - assert.equal(entry.methodType, methodType); - assert.equal( - entry.request, - stringify(req, null, 2), - 'entry.request should equal the request', - ); - - if (res) { - assert.ok( - Number.isInteger(entry.requestTime) && - Number.isInteger(entry.responseTime), - 'request and response times should be numbers', - ); - assert.ok( - entry.requestTime <= entry.responseTime, - 'request time should be less than response time', - ); - - assert.equal(entry.success, success); - assert.deepEqual( - entry.response, - stringify(res, null, 2), - 'entry.response should equal the response', - ); - } else { - assert.ok( - Number.isInteger(entry.requestTime) && entry.requestTime > 0, - 'entry should have non-zero request time', - ); - assert.ok( - entry.success === null && - entry.responseTime === null && - entry.response === null, - 'entry response values should be null', - ); - } -} diff --git a/test/mocks/permission-controller.js b/test/mocks/permission-controller.js deleted file mode 100644 index 048fcde01..000000000 --- a/test/mocks/permission-controller.js +++ /dev/null @@ -1,736 +0,0 @@ -import { ethErrors, errorCodes } from 'eth-rpc-errors'; -import deepFreeze from 'deep-freeze-strict'; - -import { ApprovalController, ControllerMessenger } from '@metamask/controllers'; - -import _getRestrictedMethods from '../../app/scripts/controllers/permissions/restrictedMethods'; - -import { CAVEAT_NAMES } from '../../shared/constants/permissions'; -import { - CAVEAT_TYPES, - NOTIFICATION_NAMES, -} from '../../app/scripts/controllers/permissions/enums'; - -/** - * README - * This file contains three primary kinds of mocks: - * - Mocks for initializing a permissions controller and getting a permissions - * middleware - * - Functions for getting various mock objects consumed or produced by - * permissions controller methods - * - Immutable mock values like Ethereum accounts and expected states - */ - -export const noop = () => undefined; - -/** - * Mock Permissions Controller and Middleware - */ - -const keyringAccounts = deepFreeze([ - '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', - '0xc42edfcc21ed14dda456aa0756c153f7985d8813', - '0x7ae1cdd37bcbdb0e1f491974da8022bfdbf9c2bf', - '0xcc74c7a59194e5d9268476955650d1e285be703c', -]); - -const getIdentities = () => { - return keyringAccounts.reduce((identities, address, index) => { - identities[address] = { address, name: `Account ${index}` }; - return identities; - }, {}); -}; - -// perm controller initialization helper -const getRestrictedMethods = (permController) => { - return { - // the actual, production restricted methods - ..._getRestrictedMethods(permController), - - // our own dummy method for testing - test_method: { - description: `This method is only for testing.`, - method: (req, res, __, end) => { - if (req.params[0]) { - res.result = 1; - } else { - res.result = 0; - } - end(); - }, - }, - }; -}; - -/** - * Gets default mock constructor options for a permissions controller. - * - * @returns {Object} A PermissionsController constructor options object. - */ -export function getPermControllerOpts() { - return { - approvals: new ApprovalController({ - messenger: new ControllerMessenger(), - showApprovalRequest: noop, - }), - getKeyringAccounts: async () => [...keyringAccounts], - getUnlockPromise: () => Promise.resolve(), - getRestrictedMethods, - isUnlocked: () => true, - notifyDomain: noop, - notifyAllDomains: noop, - preferences: { - getState: () => { - return { - identities: getIdentities(), - selectedAddress: keyringAccounts[0], - }; - }, - subscribe: noop, - }, - showPermissionRequest: noop, - }; -} - -/** - * Gets a Promise-wrapped permissions controller middleware function. - * - * @param {PermissionsController} permController - The permissions controller to get a - * middleware for. - * @param {string} origin - The origin for the middleware. - * @param {string} extensionId - The extension id for the middleware. - * @returns {Function} A Promise-wrapped middleware function with convenient default args. - */ -export function getPermissionsMiddleware(permController, origin, extensionId) { - const middleware = permController.createMiddleware({ origin, extensionId }); - return (req, res = {}, next = noop, end) => { - return new Promise((resolve, reject) => { - // eslint-disable-next-line no-param-reassign - end = end || _end; - - middleware(req, res, next, end); - - // emulates json-rpc-engine error handling - function _end(err) { - if (err || res.error) { - reject(err || res.error); - } else { - resolve(res); - } - } - }); - }; -} - -/** - * @param {Object} notifications - An object that will store notifications produced - * by the permissions controller. - * @returns {Function} A function passed to the permissions controller at initialization, - * for recording notifications. - */ -export const getNotifyDomain = (notifications = {}) => ( - origin, - notification, -) => { - notifications[origin].push(notification); -}; - -/** - * @param {Object} notifications - An object that will store notifications produced - * by the permissions controller. - * @returns {Function} A function passed to the permissions controller at initialization, - * for recording notifications. - */ -export const getNotifyAllDomains = (notifications = {}) => (notification) => { - Object.keys(notifications).forEach((origin) => { - notifications[origin].push(notification); - }); -}; - -/** - * Constants and Mock Objects - * - e.g. permissions, caveats, and permission requests - */ - -const DOMAINS = { - a: { origin: 'https://foo.xyz', host: 'foo.xyz' }, - b: { origin: 'https://bar.abc', host: 'bar.abc' }, - c: { origin: 'https://baz.def', host: 'baz.def' }, -}; - -const PERM_NAMES = { - eth_accounts: 'eth_accounts', - test_method: 'test_method', - does_not_exist: 'does_not_exist', -}; - -const ACCOUNTS = { - a: { - permitted: keyringAccounts.slice(0, 3), - primary: keyringAccounts[0], - }, - b: { - permitted: [keyringAccounts[0]], - primary: keyringAccounts[0], - }, - c: { - permitted: [keyringAccounts[1]], - primary: keyringAccounts[1], - }, -}; - -/** - * Helpers for getting mock caveats. - */ -const CAVEATS = { - /** - * Gets a correctly formatted eth_accounts exposedAccounts caveat. - * - * @param {Array} accounts - The accounts for the caveat - * @returns {Object} An eth_accounts exposedAccounts caveats - */ - eth_accounts: (accounts) => { - return [ - { - type: CAVEAT_TYPES.limitResponseLength, - value: 1, - name: CAVEAT_NAMES.primaryAccountOnly, - }, - { - type: CAVEAT_TYPES.filterResponse, - value: accounts, - name: CAVEAT_NAMES.exposedAccounts, - }, - ]; - }, -}; - -/** - * Each function here corresponds to what would be a type or interface consumed - * by permissions controller functions if we used TypeScript. - */ -const PERMS = { - /** - * The argument to approvePermissionsRequest - * @param {string} id - The rpc-cap permissions request id. - * @param {Object} permissions - The approved permissions, request-formatted. - */ - approvedRequest: (id, permissions = {}) => { - return { - permissions: { ...permissions }, - metadata: { id }, - }; - }, - - /** - * Requested permissions objects, as passed to wallet_requestPermissions. - */ - requests: { - /** - * @returns {Object} A permissions request object with eth_accounts - */ - eth_accounts: () => { - return { eth_accounts: {} }; - }, - - /** - * @returns {Object} A permissions request object with test_method - */ - test_method: () => { - return { test_method: {} }; - }, - - /** - * @returns {Object} A permissions request object with does_not_exist - */ - does_not_exist: () => { - return { does_not_exist: {} }; - }, - }, - - /** - * Finalized permission requests, as returned by finalizePermissionsRequest - */ - finalizedRequests: { - /** - * @param {Array} accounts - The accounts for the eth_accounts permission caveat - * @returns {Object} A finalized permissions request object with eth_accounts and its caveat - */ - eth_accounts: (accounts) => { - return { - eth_accounts: { - caveats: CAVEATS.eth_accounts(accounts), - }, - }; - }, - - /** - * @returns {Object} A finalized permissions request object with test_method - */ - test_method: () => { - return { - test_method: {}, - }; - }, - }, - - /** - * Partial members of res.result for successful: - * - wallet_requestPermissions - * - wallet_getPermissions - */ - granted: { - /** - * @param {Array} accounts - The accounts for the eth_accounts permission caveat - * @returns {Object} A granted permissions object with eth_accounts and its caveat - */ - eth_accounts: (accounts) => { - return { - parentCapability: PERM_NAMES.eth_accounts, - caveats: CAVEATS.eth_accounts(accounts), - }; - }, - - /** - * @returns {Object} A granted permissions object with test_method - */ - test_method: () => { - return { - parentCapability: PERM_NAMES.test_method, - }; - }, - }, -}; - -/** - * Objects with function values for getting correctly formatted permissions, - * caveats, errors, permissions requests etc. - */ -export const getters = deepFreeze({ - CAVEATS, - - PERMS, - - /** - * Getters for errors by the method or workflow that throws them. - */ - ERRORS: { - validatePermittedAccounts: { - invalidParam: () => { - return { - name: 'Error', - message: 'Must provide non-empty array of account(s).', - }; - }, - - nonKeyringAccount: (account) => { - return { - name: 'Error', - message: `Unknown account: ${account}`, - }; - }, - }, - - finalizePermissionsRequest: { - grantEthAcountsFailure: (origin) => { - return { - // name: 'EthereumRpcError', - message: `Failed to add 'eth_accounts' to '${origin}'.`, - code: errorCodes.rpc.internal, - }; - }, - }, - - addPermittedAccount: { - alreadyPermitted: () => { - return { - message: 'Account is already permitted for origin', - }; - }, - invalidOrigin: () => { - return { - message: 'Unrecognized domain', - }; - }, - noEthAccountsPermission: () => { - return { - message: `Origin does not have 'eth_accounts' permission`, - }; - }, - }, - - removePermittedAccount: { - notPermitted: () => { - return { - message: 'Account is not permitted for origin', - }; - }, - invalidOrigin: () => { - return { - message: 'Unrecognized domain', - }; - }, - noEthAccountsPermission: () => { - return { - message: `Origin does not have 'eth_accounts' permission`, - }; - }, - }, - - _handleAccountSelected: { - invalidParams: () => { - return { - name: 'Error', - message: 'Selected account should be a non-empty string.', - }; - }, - }, - - approvePermissionsRequest: { - noPermsRequested: () => { - return { - message: 'Must request at least one permission.', - }; - }, - }, - - rejectPermissionsRequest: { - rejection: () => { - return { - message: ethErrors.provider.userRejectedRequest().message, - }; - }, - methodNotFound: (methodName) => { - return { - message: `The method '${methodName}' does not exist / is not available.`, - }; - }, - }, - - createMiddleware: { - badOrigin: () => { - return { - message: 'Must provide non-empty string origin.', - }; - }, - }, - - rpcCap: { - unauthorized: () => { - return { - code: 4100, - }; - }, - }, - - pendingApprovals: { - duplicateOriginOrId: (id, origin) => { - return { - message: `Pending approval with id '${id}' or origin '${origin}' already exists.`, - }; - }, - requestAlreadyPending: (origin) => { - return { - message: `Request of type 'wallet_requestPermissions' already pending for origin ${origin}. Please wait.`, - }; - }, - }, - - eth_requestAccounts: { - requestAlreadyPending: () => { - return { - message: 'Already processing eth_requestAccounts. Please wait.', - }; - }, - }, - - notifyAccountsChanged: { - invalidOrigin: (origin) => { - return { - message: `Invalid origin: '${origin}'`, - }; - }, - invalidAccounts: () => { - return { - message: 'Invalid accounts', - }; - }, - }, - }, - - /** - * Getters for notifications produced by the permissions controller. - */ - NOTIFICATIONS: { - /** - * Gets a removed accounts notification. - * - * @returns {Object} An accountsChanged notification with an empty array as its result - */ - removedAccounts: () => { - return { - method: NOTIFICATION_NAMES.accountsChanged, - params: [], - }; - }, - - /** - * Gets a new accounts notification. - * - * @param {Array} accounts - The accounts added to the notification. - * @returns {Object} An accountsChanged notification with the given accounts as its result - */ - newAccounts: (accounts) => { - return { - method: NOTIFICATION_NAMES.accountsChanged, - params: accounts, - }; - }, - }, - - /** - * Getters for mock RPC request objects. - */ - RPC_REQUESTS: { - /** - * Gets an arbitrary RPC request object. - * - * @param {string} origin - The origin of the request - * @param {string} method - The request method - * @param {Array} params - The request parameters - * @param {string} [id] - The request id - * @returns {Object} An RPC request object - */ - custom: (origin, method, params = [], id) => { - const req = { - origin, - method, - params, - }; - if (id !== undefined) { - req.id = id; - } - return req; - }, - - /** - * Gets an eth_accounts RPC request object. - * - * @param {string} origin - The origin of the request - * @returns {Object} An RPC request object - */ - eth_accounts: (origin) => { - return { - origin, - method: 'eth_accounts', - params: [], - }; - }, - - /** - * Gets a test_method RPC request object. - * - * @param {string} origin - The origin of the request - * @param {boolean} param - The request param - * @returns {Object} An RPC request object - */ - test_method: (origin, param = false) => { - return { - origin, - method: 'test_method', - params: [param], - }; - }, - - /** - * Gets an eth_requestAccounts RPC request object. - * - * @param {string} origin - The origin of the request - * @returns {Object} An RPC request object - */ - eth_requestAccounts: (origin) => { - return { - origin, - method: 'eth_requestAccounts', - params: [], - }; - }, - - /** - * Gets a wallet_requestPermissions RPC request object, - * for a single permission. - * - * @param {string} origin - The origin of the request - * @param {string} permissionName - The name of the permission to request - * @returns {Object} An RPC request object - */ - requestPermission: (origin, permissionName) => { - return { - origin, - method: 'wallet_requestPermissions', - params: [PERMS.requests[permissionName]()], - }; - }, - - /** - * Gets a wallet_requestPermissions RPC request object, - * for multiple permissions. - * - * @param {string} origin - The origin of the request - * @param {Object} permissions - A permission request object - * @returns {Object} An RPC request object - */ - requestPermissions: (origin, permissions = {}) => { - return { - origin, - method: 'wallet_requestPermissions', - params: [permissions], - }; - }, - - /** - * Gets a metamask_sendDomainMetadata RPC request object. - * - * @param {string} origin - The origin of the request - * @param {Object} name - The domainMetadata name - * @param {Array} [args] - Any other data for the request's domainMetadata - * @returns {Object} An RPC request object - */ - metamask_sendDomainMetadata: (origin, name, ...args) => { - return { - origin, - method: 'metamask_sendDomainMetadata', - params: { - ...args, - name, - }, - }; - }, - }, -}); - -/** - * Objects with immutable mock values. - */ -export const constants = deepFreeze({ - ALL_ACCOUNTS: keyringAccounts, - - DUMMY_ACCOUNT: '0xabc', - - EXTRA_ACCOUNT: keyringAccounts[3], - - REQUEST_IDS: { - a: '1', - b: '2', - c: '3', - }, - - DOMAINS: { ...DOMAINS }, - - ACCOUNTS: { ...ACCOUNTS }, - - PERM_NAMES: { ...PERM_NAMES }, - - RESTRICTED_METHODS: ['eth_accounts', 'test_method'], - - /** - * Mock permissions history objects. - */ - EXPECTED_HISTORIES: { - case1: [ - { - [DOMAINS.a.origin]: { - [PERM_NAMES.eth_accounts]: { - lastApproved: 1, - accounts: { - [ACCOUNTS.a.permitted[0]]: 1, - [ACCOUNTS.a.permitted[1]]: 1, - [ACCOUNTS.a.permitted[2]]: 1, - }, - }, - }, - }, - { - [DOMAINS.a.origin]: { - [PERM_NAMES.eth_accounts]: { - lastApproved: 2, - accounts: { - [ACCOUNTS.a.permitted[0]]: 2, - [ACCOUNTS.a.permitted[1]]: 1, - [ACCOUNTS.a.permitted[2]]: 1, - }, - }, - }, - }, - ], - - case2: [ - { - [DOMAINS.a.origin]: { - [PERM_NAMES.eth_accounts]: { - lastApproved: 1, - accounts: {}, - }, - }, - }, - ], - - case3: [ - { - [DOMAINS.a.origin]: { - [PERM_NAMES.test_method]: { lastApproved: 1 }, - }, - [DOMAINS.b.origin]: { - [PERM_NAMES.eth_accounts]: { - lastApproved: 1, - accounts: { - [ACCOUNTS.b.permitted[0]]: 1, - }, - }, - }, - [DOMAINS.c.origin]: { - [PERM_NAMES.test_method]: { lastApproved: 1 }, - [PERM_NAMES.eth_accounts]: { - lastApproved: 1, - accounts: { - [ACCOUNTS.c.permitted[0]]: 1, - }, - }, - }, - }, - { - [DOMAINS.a.origin]: { - [PERM_NAMES.test_method]: { lastApproved: 2 }, - }, - [DOMAINS.b.origin]: { - [PERM_NAMES.eth_accounts]: { - lastApproved: 1, - accounts: { - [ACCOUNTS.b.permitted[0]]: 1, - }, - }, - }, - [DOMAINS.c.origin]: { - [PERM_NAMES.test_method]: { lastApproved: 1 }, - [PERM_NAMES.eth_accounts]: { - lastApproved: 2, - accounts: { - [ACCOUNTS.c.permitted[0]]: 1, - [ACCOUNTS.b.permitted[0]]: 2, - }, - }, - }, - }, - ], - - case4: [ - { - [DOMAINS.a.origin]: { - [PERM_NAMES.test_method]: { - lastApproved: 1, - }, - }, - }, - ], - }, -}); diff --git a/test/mocks/permissions.js b/test/mocks/permissions.js new file mode 100644 index 000000000..e17655b5f --- /dev/null +++ b/test/mocks/permissions.js @@ -0,0 +1,369 @@ +import deepFreeze from 'deep-freeze-strict'; +import { CaveatTypes } from '../../shared/constants/permissions'; + +/** + * This file contains mocks for the PermissionLogController tests. + */ + +export const noop = () => undefined; + +const keyringAccounts = deepFreeze([ + '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + '0xc42edfcc21ed14dda456aa0756c153f7985d8813', + '0x7ae1cdd37bcbdb0e1f491974da8022bfdbf9c2bf', + '0xcc74c7a59194e5d9268476955650d1e285be703c', +]); + +const SUBJECTS = { + a: { origin: 'https://foo.xyz' }, + b: { origin: 'https://bar.abc' }, + c: { origin: 'https://baz.def' }, +}; + +const PERM_NAMES = { + eth_accounts: 'eth_accounts', + test_method: 'test_method', + does_not_exist: 'does_not_exist', +}; + +const ACCOUNTS = { + a: { + permitted: keyringAccounts.slice(0, 3), + primary: keyringAccounts[0], + }, + b: { + permitted: [keyringAccounts[0]], + primary: keyringAccounts[0], + }, + c: { + permitted: [keyringAccounts[1]], + primary: keyringAccounts[1], + }, +}; + +/** + * Helpers for getting mock caveats. + */ +const CAVEATS = { + /** + * Gets a correctly formatted eth_accounts restrictReturnedAccounts caveat. + * + * @param {Array} accounts - The accounts for the caveat + * @returns {Object} An eth_accounts restrictReturnedAccounts caveats + */ + eth_accounts: (accounts) => { + return [ + { + type: CaveatTypes.restrictReturnedAccounts, + value: accounts, + }, + ]; + }, +}; + +/** + * Each function here corresponds to what would be a type or interface consumed + * by permissions controller functions if we used TypeScript. + */ +const PERMS = { + /** + * Requested permissions objects, as passed to wallet_requestPermissions. + */ + requests: { + /** + * @returns {Object} A permissions request object with eth_accounts + */ + eth_accounts: () => { + return { eth_accounts: {} }; + }, + + /** + * @returns {Object} A permissions request object with test_method + */ + test_method: () => { + return { test_method: {} }; + }, + + /** + * @returns {Object} A permissions request object with does_not_exist + */ + does_not_exist: () => { + return { does_not_exist: {} }; + }, + }, + + /** + * Partial members of res.result for successful: + * - wallet_requestPermissions + * - wallet_getPermissions + */ + granted: { + /** + * @param {Array} accounts - The accounts for the eth_accounts permission caveat + * @returns {Object} A granted permissions object with eth_accounts and its caveat + */ + eth_accounts: (accounts) => { + return { + parentCapability: PERM_NAMES.eth_accounts, + caveats: CAVEATS.eth_accounts(accounts), + }; + }, + + /** + * @returns {Object} A granted permissions object with test_method + */ + test_method: () => { + return { + parentCapability: PERM_NAMES.test_method, + }; + }, + }, +}; + +/** + * Objects with function values for getting correctly formatted permissions, + * caveats, errors, permissions requests etc. + */ +export const getters = deepFreeze({ + PERMS, + + /** + * Getters for mock RPC request objects. + */ + RPC_REQUESTS: { + /** + * Gets an arbitrary RPC request object. + * + * @param {string} origin - The origin of the request + * @param {string} method - The request method + * @param {Array} params - The request parameters + * @param {string} [id] - The request id + * @returns {Object} An RPC request object + */ + custom: (origin, method, params = [], id) => { + const req = { + origin, + method, + params, + }; + if (id !== undefined) { + req.id = id; + } + return req; + }, + + /** + * Gets an eth_accounts RPC request object. + * + * @param {string} origin - The origin of the request + * @returns {Object} An RPC request object + */ + eth_accounts: (origin) => { + return { + origin, + method: 'eth_accounts', + params: [], + }; + }, + + /** + * Gets a test_method RPC request object. + * + * @param {string} origin - The origin of the request + * @param {boolean} param - The request param + * @returns {Object} An RPC request object + */ + test_method: (origin, param = false) => { + return { + origin, + method: 'test_method', + params: [param], + }; + }, + + /** + * Gets an eth_requestAccounts RPC request object. + * + * @param {string} origin - The origin of the request + * @returns {Object} An RPC request object + */ + eth_requestAccounts: (origin) => { + return { + origin, + method: 'eth_requestAccounts', + params: [], + }; + }, + + /** + * Gets a wallet_requestPermissions RPC request object, + * for a single permission. + * + * @param {string} origin - The origin of the request + * @param {string} permissionName - The name of the permission to request + * @returns {Object} An RPC request object + */ + requestPermission: (origin, permissionName) => { + return { + origin, + method: 'wallet_requestPermissions', + params: [PERMS.requests[permissionName]()], + }; + }, + + /** + * Gets a wallet_requestPermissions RPC request object, + * for multiple permissions. + * + * @param {string} origin - The origin of the request + * @param {Object} permissions - A permission request object + * @returns {Object} An RPC request object + */ + requestPermissions: (origin, permissions = {}) => { + return { + origin, + method: 'wallet_requestPermissions', + params: [permissions], + }; + }, + + /** + * Gets a metamask_sendDomainMetadata RPC request object. + * + * @param {string} origin - The origin of the request + * @param {Object} name - The subjectMetadata name + * @param {Array} [args] - Any other data for the request's subjectMetadata + * @returns {Object} An RPC request object + */ + metamask_sendDomainMetadata: (origin, name, ...args) => { + return { + origin, + method: 'metamask_sendDomainMetadata', + params: { + ...args, + name, + }, + }; + }, + }, +}); + +/** + * Objects with immutable mock values. + */ +export const constants = deepFreeze({ + REQUEST_IDS: { + a: '1', + b: '2', + c: '3', + }, + + SUBJECTS: { ...SUBJECTS }, + + ACCOUNTS: { ...ACCOUNTS }, + + PERM_NAMES: { ...PERM_NAMES }, + + RESTRICTED_METHODS: new Set(['eth_accounts', 'test_method']), + + /** + * Mock permissions history objects. + */ + EXPECTED_HISTORIES: { + case1: [ + { + [SUBJECTS.a.origin]: { + [PERM_NAMES.eth_accounts]: { + lastApproved: 1, + accounts: { + [ACCOUNTS.a.permitted[0]]: 1, + [ACCOUNTS.a.permitted[1]]: 1, + [ACCOUNTS.a.permitted[2]]: 1, + }, + }, + }, + }, + { + [SUBJECTS.a.origin]: { + [PERM_NAMES.eth_accounts]: { + lastApproved: 2, + accounts: { + [ACCOUNTS.a.permitted[0]]: 2, + [ACCOUNTS.a.permitted[1]]: 1, + [ACCOUNTS.a.permitted[2]]: 1, + }, + }, + }, + }, + ], + + case2: [ + { + [SUBJECTS.a.origin]: { + [PERM_NAMES.eth_accounts]: { + lastApproved: 1, + accounts: {}, + }, + }, + }, + ], + + case3: [ + { + [SUBJECTS.a.origin]: { + [PERM_NAMES.test_method]: { lastApproved: 1 }, + }, + [SUBJECTS.b.origin]: { + [PERM_NAMES.eth_accounts]: { + lastApproved: 1, + accounts: { + [ACCOUNTS.b.permitted[0]]: 1, + }, + }, + }, + [SUBJECTS.c.origin]: { + [PERM_NAMES.test_method]: { lastApproved: 1 }, + [PERM_NAMES.eth_accounts]: { + lastApproved: 1, + accounts: { + [ACCOUNTS.c.permitted[0]]: 1, + }, + }, + }, + }, + { + [SUBJECTS.a.origin]: { + [PERM_NAMES.test_method]: { lastApproved: 2 }, + }, + [SUBJECTS.b.origin]: { + [PERM_NAMES.eth_accounts]: { + lastApproved: 1, + accounts: { + [ACCOUNTS.b.permitted[0]]: 1, + }, + }, + }, + [SUBJECTS.c.origin]: { + [PERM_NAMES.test_method]: { lastApproved: 1 }, + [PERM_NAMES.eth_accounts]: { + lastApproved: 2, + accounts: { + [ACCOUNTS.c.permitted[0]]: 1, + [ACCOUNTS.b.permitted[0]]: 2, + }, + }, + }, + }, + ], + + case4: [ + { + [SUBJECTS.a.origin]: { + [PERM_NAMES.test_method]: { + lastApproved: 1, + }, + }, + }, + ], + }, +}); diff --git a/ui/2.DOCUMENTATION.stories.mdx b/ui/2.DOCUMENTATION.stories.mdx index 056edb647..6415f9031 100644 --- a/ui/2.DOCUMENTATION.stories.mdx +++ b/ui/2.DOCUMENTATION.stories.mdx @@ -23,7 +23,26 @@ See the [Button](https://metamask.github.io/metamask-storybook/index.html?path=/ ## Creating a Story -[Component Story Format (CSF)](https://storybook.js.org/docs/react/api/csf) is the recommended way to write stories. It's an open standard based on ES6 modules. The below example is of the Button component and it explains how we should write our stories. +[Component Story Format (CSF)](https://storybook.js.org/docs/react/api/csf) is the recommended way to write stories. It's an open standard based on ES6 modules. + +A story can be as simple as: + +```jsx +import React from 'react'; +import MyComponent from '.'; + +export default { + title: 'Components/UI/MyComponent', // title should follow the folder structure location of the component. Don't use spaces. + id: __filename, +}; + +export const DefaultStory = () => ; + +DefaultStory.storyName = 'Default'; +``` + +For a more in-depth and higher quality form of story and documentation, you can use controls and MDX docs. +The example below displays the Button component and it explains how we should write our stories: ```jsx // Button component example story @@ -38,12 +57,14 @@ import Button from '.'; // The default storybook component export should always follow the same template export default { - title: 'Button', // The `title` effects the components tile and location in storybook + // The `title` effects the components tile and location in storybook + // It should follow the folder structure location of the component. Don't use spaces. + title: 'Components/UI/Button', id: __filename, // The file name id is used to track what storybook files have changed in CI component: Button, // The component you are documenting parameters: { docs: { - page: README, // Reference to the mdx file docs page + page: README, // Reference to the docs page MDX file }, }, // the controls plugin argTypes are used for the interactivity of the component @@ -148,7 +169,7 @@ Buttons communicate actions that users can take. ## Component API - + diff --git a/ui/components/app/account-list-item/README.mdx b/ui/components/app/account-list-item/README.mdx new file mode 100644 index 000000000..a0eb00386 --- /dev/null +++ b/ui/components/app/account-list-item/README.mdx @@ -0,0 +1,14 @@ +import { Story, Canvas, ArgsTable } from '@storybook/addon-docs'; +import AccountListItem from '.'; + +# Account List Item + +Account List Item is referred for each account item on the Account List's component + + + + + +## Component API + + diff --git a/ui/components/app/account-list-item/account-list-item.js b/ui/components/app/account-list-item/account-list-item.js index bd9745552..092bf2d61 100644 --- a/ui/components/app/account-list-item/account-list-item.js +++ b/ui/components/app/account-list-item/account-list-item.js @@ -39,9 +39,28 @@ export default function AccountListItem({ } AccountListItem.propTypes = { - account: PropTypes.object, + /** + * An account object that has name, address, and balance data + */ + account: PropTypes.shape({ + address: PropTypes.string.isRequired, + balance: PropTypes.string, + name: PropTypes.string, + }), + /** + * Additional className to add to the root div element of AccountListItem + */ className: PropTypes.string, + /** + * Display the address of the account object + */ displayAddress: PropTypes.bool, + /** + * The onClick handler of the AccountListItem + */ handleClick: PropTypes.func, + /** + * Pass icon component to be displayed. Currently not used + */ icon: PropTypes.node, }; diff --git a/ui/components/app/account-list-item/account-list-item.stories.js b/ui/components/app/account-list-item/account-list-item.stories.js index a6746113d..bad3ec20f 100644 --- a/ui/components/app/account-list-item/account-list-item.stories.js +++ b/ui/components/app/account-list-item/account-list-item.stories.js @@ -1,11 +1,40 @@ import React from 'react'; +import README from './README.mdx'; import AccountListItem from './account-list-item'; export default { - title: 'AccountListItem', + title: 'Components/App/AccountListItem', id: __filename, + component: AccountListItem, + parameters: { + docs: { + page: README, + }, + }, + argTypes: { + account: { + control: 'object', + }, + className: { control: 'text' }, + displayAddress: { control: 'boolean' }, + handleClick: { action: 'handleClick' }, + }, }; -export const AccountListItemComponent = () => { - return ; +const account = { + name: 'Account 2', + address: '0xb19ac54efa18cc3a14a5b821bfec73d284bf0c5e', + balance: '0x2d3142f5000', }; + +export const DefaultStory = (args) => { + return ; +}; + +DefaultStory.storyName = 'Default'; +DefaultStory.args = { + account, + displayAddress: false, +}; + +DefaultStory.storyName = 'Default'; diff --git a/ui/components/app/account-menu/account-menu.component.js b/ui/components/app/account-menu/account-menu.component.js index 131d63336..7f92787fe 100644 --- a/ui/components/app/account-menu/account-menu.component.js +++ b/ui/components/app/account-menu/account-menu.component.js @@ -70,7 +70,7 @@ export default class AccountMenu extends Component { selectedAddress: PropTypes.string, showAccountDetail: PropTypes.func, toggleAccountMenu: PropTypes.func, - addressConnectedDomainMap: PropTypes.object, + addressConnectedSubjectMap: PropTypes.object, originOfCurrentTab: PropTypes.string, }; @@ -147,7 +147,7 @@ export default class AccountMenu extends Component { selectedAddress, keyrings, showAccountDetail, - addressConnectedDomainMap, + addressConnectedSubjectMap, originOfCurrentTab, } = this.props; const { searchQuery } = this.state; @@ -177,8 +177,9 @@ export default class AccountMenu extends Component { kr.accounts.includes(identity.address) ); }); - const addressDomains = addressConnectedDomainMap[identity.address] || {}; - const iconAndNameForOpenDomain = addressDomains[originOfCurrentTab]; + const addressSubjects = + addressConnectedSubjectMap[identity.address] || {}; + const iconAndNameForOpenSubject = addressSubjects[originOfCurrentTab]; return (
{this.renderKeyringType(keyring)} - {iconAndNameForOpenDomain ? ( + {iconAndNameForOpenSubject ? (
diff --git a/ui/components/app/account-menu/account-menu.container.js b/ui/components/app/account-menu/account-menu.container.js index 36a420656..936c05eb6 100644 --- a/ui/components/app/account-menu/account-menu.container.js +++ b/ui/components/app/account-menu/account-menu.container.js @@ -8,7 +8,7 @@ import { hideWarning, } from '../../../store/actions'; import { - getAddressConnectedDomainMap, + getAddressConnectedSubjectMap, getMetaMaskAccountsOrdered, getMetaMaskKeyrings, getOriginOfCurrentTab, @@ -31,7 +31,7 @@ function mapStateToProps(state) { return { isAccountMenuOpen, - addressConnectedDomainMap: getAddressConnectedDomainMap(state), + addressConnectedSubjectMap: getAddressConnectedSubjectMap(state), originOfCurrentTab: origin, selectedAddress, keyrings: getMetaMaskKeyrings(state), diff --git a/ui/components/app/account-menu/account-menu.test.js b/ui/components/app/account-menu/account-menu.test.js index 501c187dd..749cb2591 100644 --- a/ui/components/app/account-menu/account-menu.test.js +++ b/ui/components/app/account-menu/account-menu.test.js @@ -24,7 +24,7 @@ describe('Account Menu', () => { const props = { isAccountMenuOpen: true, - addressConnectedDomainMap: {}, + addressConnectedSubjectMap: {}, accounts: [ { address: '0x00', diff --git a/ui/components/app/advanced-gas-controls/advanced-gas-controls.stories.js b/ui/components/app/advanced-gas-controls/advanced-gas-controls.stories.js index 7c14aefb0..08543ee21 100644 --- a/ui/components/app/advanced-gas-controls/advanced-gas-controls.stories.js +++ b/ui/components/app/advanced-gas-controls/advanced-gas-controls.stories.js @@ -3,14 +3,16 @@ import React from 'react'; import AdvancedGasControls from '.'; export default { - title: 'Advanced Gas Controls', + title: 'Components/App/AdvancedGasControls', id: __filename, }; -export const simple = () => { +export const DefaultStory = () => { return (
); }; + +DefaultStory.storyName = 'Default'; diff --git a/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-gas-limit/advanced-gas-fee-gas-limit.js b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-gas-limit/advanced-gas-fee-gas-limit.js new file mode 100644 index 000000000..e1efdfc00 --- /dev/null +++ b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-gas-limit/advanced-gas-fee-gas-limit.js @@ -0,0 +1,82 @@ +import React, { useEffect, useState } from 'react'; + +import { useGasFeeContext } from '../../../../contexts/gasFee'; +import { bnGreaterThan, bnLessThan } from '../../../../helpers/utils/util'; +import { TYPOGRAPHY } from '../../../../helpers/constants/design-system'; +import { useI18nContext } from '../../../../hooks/useI18nContext'; +import { MAX_GAS_LIMIT_DEC } from '../../../../pages/send/send.constants'; +import Button from '../../../ui/button'; +import FormField from '../../../ui/form-field'; +import I18nValue from '../../../ui/i18n-value'; +import Typography from '../../../ui/typography'; + +import { useAdvancedGasFeePopoverContext } from '../context'; + +const validateGasLimit = (gasLimit, minimumGasLimitDec) => { + return bnLessThan(gasLimit, minimumGasLimitDec) || + bnGreaterThan(gasLimit, MAX_GAS_LIMIT_DEC) + ? 'editGasLimitOutOfBoundsV2' + : null; +}; + +const AdvancedGasFeeGasLimit = () => { + const t = useI18nContext(); + const { + setGasLimit: setGasLimitInContext, + } = useAdvancedGasFeePopoverContext(); + const { + gasLimit: gasLimitInTransaction, + minimumGasLimitDec, + } = useGasFeeContext(); + const [isEditing, setEditing] = useState(false); + const [gasLimit, setGasLimit] = useState(gasLimitInTransaction); + const [gasLimitError, setGasLimitError] = useState(); + + const updateGasLimit = (value) => { + setGasLimit(value); + }; + + useEffect(() => { + setGasLimitInContext(gasLimit); + const error = validateGasLimit(gasLimit, minimumGasLimitDec); + setGasLimitError(error); + }, [gasLimit, minimumGasLimitDec, setGasLimitInContext]); + + if (isEditing) { + return ( + + ); + } + + return ( + + + + + {gasLimit} + + + ); +}; + +export default AdvancedGasFeeGasLimit; diff --git a/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-gas-limit/advanced-gas-fee-gas-limit.test.js b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-gas-limit/advanced-gas-fee-gas-limit.test.js new file mode 100644 index 000000000..de913b08c --- /dev/null +++ b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-gas-limit/advanced-gas-fee-gas-limit.test.js @@ -0,0 +1,110 @@ +import React from 'react'; +import { fireEvent, screen } from '@testing-library/react'; + +import { GAS_ESTIMATE_TYPES } from '../../../../../shared/constants/gas'; +import { renderWithProvider } from '../../../../../test/lib/render-helpers'; +import mockEstimates from '../../../../../test/data/mock-estimates.json'; +import mockState from '../../../../../test/data/mock-state.json'; +import { GasFeeContextProvider } from '../../../../contexts/gasFee'; +import configureStore from '../../../../store/store'; + +import { AdvancedGasFeePopoverContextProvider } from '../context'; +import AdvancedGasFeeGasLimit from './advanced-gas-fee-gas-limit'; + +jest.mock('../../../../store/actions', () => ({ + disconnectGasFeeEstimatePoller: jest.fn(), + getGasFeeEstimatesAndStartPolling: jest + .fn() + .mockImplementation(() => Promise.resolve()), + addPollingTokenToAppState: jest.fn(), + removePollingTokenFromAppState: jest.fn(), +})); + +const render = (contextProps) => { + const store = configureStore({ + metamask: { + ...mockState.metamask, + accounts: { + [mockState.metamask.selectedAddress]: { + address: mockState.metamask.selectedAddress, + balance: '0x1F4', + }, + }, + advancedGasFee: { priorityFee: 100 }, + featureFlags: { advancedInlineGas: true }, + gasFeeEstimates: + mockEstimates[GAS_ESTIMATE_TYPES.FEE_MARKET].gasFeeEstimates, + }, + }); + + return renderWithProvider( + + + + + , + store, + ); +}; + +describe('AdvancedGasFeeGasLimit', () => { + it('should show GasLimit from transaction', () => { + render(); + expect(screen.getByText('21000')).toBeInTheDocument(); + }); + + it('should show input when edit link is clicked', () => { + render(); + expect(document.getElementsByTagName('input')).toHaveLength(0); + fireEvent.click(screen.queryByText('Edit')); + expect(document.getElementsByTagName('input')[0]).toHaveValue(21000); + }); + + it('should show error if gas limit is not in range', () => { + render(); + fireEvent.click(screen.queryByText('Edit')); + fireEvent.change(document.getElementsByTagName('input')[0], { + target: { value: 20000 }, + }); + expect( + screen.queryByText( + 'Gas limit must be greater than 20999 and less than 7920027', + ), + ).toBeInTheDocument(); + fireEvent.change(document.getElementsByTagName('input')[0], { + target: { value: 8000000 }, + }); + expect( + screen.queryByText( + 'Gas limit must be greater than 20999 and less than 7920027', + ), + ).toBeInTheDocument(); + fireEvent.change(document.getElementsByTagName('input')[0], { + target: { value: 7000000 }, + }); + expect( + screen.queryByText( + 'Gas limit must be greater than 20999 and less than 7920027', + ), + ).not.toBeInTheDocument(); + }); + + it('should validate gas limit against minimumGasLimit it is passed to context', () => { + render({ minimumGasLimit: '0x7530' }); + fireEvent.click(screen.queryByText('Edit')); + fireEvent.change(document.getElementsByTagName('input')[0], { + target: { value: 25000 }, + }); + expect( + screen.queryByText( + 'Gas limit must be greater than 29999 and less than 7920027', + ), + ).toBeInTheDocument(); + }); +}); diff --git a/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-gas-limit/index.js b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-gas-limit/index.js new file mode 100644 index 000000000..8ab2a4041 --- /dev/null +++ b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-gas-limit/index.js @@ -0,0 +1 @@ +export { default } from './advanced-gas-fee-gas-limit'; diff --git a/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-gas-limit/index.scss b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-gas-limit/index.scss new file mode 100644 index 000000000..562a00db3 --- /dev/null +++ b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-gas-limit/index.scss @@ -0,0 +1,16 @@ +.advanced-gas-fee-gas-limit { + display: flex; + align-items: center; + white-space: nowrap; + + > * { + margin-right: 4px; + } + + a.advanced-gas-fee-gas-limit__edit-link { + @include H7; + + padding: 0; + width: auto; + } +} diff --git a/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-input-subtext/advanced-gas-fee-input-subtext.js b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-input-subtext/advanced-gas-fee-input-subtext.js index 40c003237..de7291b07 100644 --- a/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-input-subtext/advanced-gas-fee-input-subtext.js +++ b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-input-subtext/advanced-gas-fee-input-subtext.js @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import Box from '../../../ui/box'; import I18nValue from '../../../ui/i18n-value'; +import LoadingHeartBeat from '../../../ui/loading-heartbeat'; const AdvancedGasFeeInputSubtext = ({ latest, historical }) => { return ( @@ -11,14 +12,20 @@ const AdvancedGasFeeInputSubtext = ({ latest, historical }) => { - {latest} + + + {latest} + - {historical} + + + {historical} + ); diff --git a/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-input-subtext/advanced-gas-fee-input-subtext.test.js b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-input-subtext/advanced-gas-fee-input-subtext.test.js index f466a08f0..c2a9c51a3 100644 --- a/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-input-subtext/advanced-gas-fee-input-subtext.test.js +++ b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-input-subtext/advanced-gas-fee-input-subtext.test.js @@ -1,8 +1,48 @@ import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { screen } from '@testing-library/react'; +import { GAS_ESTIMATE_TYPES } from '../../../../../shared/constants/gas'; +import mockEstimates from '../../../../../test/data/mock-estimates.json'; +import mockState from '../../../../../test/data/mock-state.json'; +import { renderWithProvider } from '../../../../../test/lib/render-helpers'; +import configureStore from '../../../../store/store'; import AdvancedGasFeeInputSubtext from './advanced-gas-fee-input-subtext'; +jest.mock('../../../../store/actions', () => ({ + disconnectGasFeeEstimatePoller: jest.fn(), + getGasFeeEstimatesAndStartPolling: jest + .fn() + .mockImplementation(() => Promise.resolve()), + addPollingTokenToAppState: jest.fn(), + removePollingTokenFromAppState: jest.fn(), +})); + +const render = () => { + const store = configureStore({ + metamask: { + ...mockState.metamask, + accounts: { + [mockState.metamask.selectedAddress]: { + address: mockState.metamask.selectedAddress, + balance: '0x1F4', + }, + }, + advancedGasFee: { priorityFee: 100 }, + featureFlags: { advancedInlineGas: true }, + gasFeeEstimates: + mockEstimates[GAS_ESTIMATE_TYPES.FEE_MARKET].gasFeeEstimates, + }, + }); + + return renderWithProvider( + , + store, + ); +}; + describe('AdvancedGasFeeInputSubtext', () => { it('should renders latest and historical values passed', () => { render( diff --git a/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-input-subtext/index.scss b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-input-subtext/index.scss index 45763668c..7a1d71717 100644 --- a/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-input-subtext/index.scss +++ b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-input-subtext/index.scss @@ -10,6 +10,10 @@ margin-right: 4px; } + &__value { + position: relative; + } + img { height: 16px; margin-right: 8px; diff --git a/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-inputs/advanced-gas-fee-inputs.js b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-inputs/advanced-gas-fee-inputs.js index c191d36f6..070e2f7b0 100644 --- a/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-inputs/advanced-gas-fee-inputs.js +++ b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-inputs/advanced-gas-fee-inputs.js @@ -6,7 +6,7 @@ import PriorityFeeInput from './priority-fee-input'; const AdvancedGasFeeInputs = () => { return ( - +
diff --git a/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-inputs/base-fee-input/base-fee-input.js b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-inputs/base-fee-input/base-fee-input.js index 1e7739bf3..5a90c1739 100644 --- a/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-inputs/base-fee-input/base-fee-input.js +++ b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-inputs/base-fee-input/base-fee-input.js @@ -1,12 +1,14 @@ import React, { useCallback, useEffect, useState } from 'react'; import { useSelector } from 'react-redux'; +import { HIGH_FEE_WARNING_MULTIPLIER } from '../../../../../pages/send/send.constants'; import { PRIORITY_LEVELS } from '../../../../../../shared/constants/gas'; import { divideCurrencies, multiplyCurrencies, } from '../../../../../../shared/modules/conversion.utils'; import { PRIMARY, SECONDARY } from '../../../../../helpers/constants/common'; +import { bnGreaterThan, bnLessThan } from '../../../../../helpers/utils/util'; import { decGWEIToHexWEI } from '../../../../../helpers/utils/conversions.util'; import { getAdvancedGasFeeValues } from '../../../../../selectors'; import { useGasFeeContext } from '../../../../../contexts/gasFee'; @@ -18,7 +20,7 @@ import Box from '../../../../ui/box'; import FormField from '../../../../ui/form-field'; import I18nValue from '../../../../ui/i18n-value'; -import { useAdvanceGasFeePopoverContext } from '../../context'; +import { useAdvancedGasFeePopoverContext } from '../../context'; import AdvancedGasFeeInputSubtext from '../../advanced-gas-fee-input-subtext'; const divideCurrencyValues = (value, baseFee) => { @@ -39,11 +41,46 @@ const multiplyCurrencyValues = (baseFee, value, numberOfDecimals) => multiplierBase: 10, }).toNumber(); +const validateBaseFee = ( + editingInGwei, + value, + gasFeeEstimates, + maxPriorityFeePerGas, +) => { + if (bnGreaterThan(maxPriorityFeePerGas, value)) { + return editingInGwei + ? 'editGasMaxBaseFeeGWEIImbalance' + : 'editGasMaxBaseFeeMultiplierImbalance'; + } + if ( + gasFeeEstimates?.low && + bnLessThan(value, gasFeeEstimates.low.suggestedMaxFeePerGas) + ) { + return 'editGasMaxBaseFeeLow'; + } + if ( + gasFeeEstimates?.high && + bnGreaterThan( + value, + gasFeeEstimates.high.suggestedMaxFeePerGas * HIGH_FEE_WARNING_MULTIPLIER, + ) + ) { + return 'editGasMaxBaseFeeHigh'; + } + return null; +}; + const BaseFeeInput = () => { const t = useI18nContext(); const { gasFeeEstimates, estimateUsed, maxFeePerGas } = useGasFeeContext(); - const { setDirty, setMaxFeePerGas } = useAdvanceGasFeePopoverContext(); + const { + maxPriorityFeePerGas, + setErrorValue, + setMaxFeePerGas, + } = useAdvancedGasFeePopoverContext(); + const { estimatedBaseFee } = gasFeeEstimates; + const [baseFeeError, setBaseFeeError] = useState(); const { numberOfDecimals: numberOfDecimalsPrimary, } = useUserPreferencedCurrency(PRIMARY); @@ -102,7 +139,6 @@ const BaseFeeInput = () => { } setMaxBaseFeeGWEI(baseFeeInGWEI); setMaxBaseFeeMultiplier(baseFeeMultiplierValue); - setDirty(true); }, [ editingInGwei, @@ -110,17 +146,38 @@ const BaseFeeInput = () => { numberOfDecimalsPrimary, setMaxBaseFeeGWEI, setMaxBaseFeeMultiplier, - setDirty, ], ); useEffect(() => { setMaxFeePerGas(maxBaseFeeGWEI); - }, [maxBaseFeeGWEI, setMaxFeePerGas]); + const error = validateBaseFee( + editingInGwei, + maxBaseFeeGWEI, + gasFeeEstimates, + maxPriorityFeePerGas, + ); + + setBaseFeeError(error); + setErrorValue( + 'maxFeePerGas', + error === 'editGasMaxBaseFeeGWEIImbalance' || + error === 'editGasMaxBaseFeeMultiplierImbalance', + ); + }, [ + editingInGwei, + gasFeeEstimates, + maxBaseFeeGWEI, + maxPriorityFeePerGas, + setBaseFeeError, + setErrorValue, + setMaxFeePerGas, + ]); return ( { } value={editingInGwei ? maxBaseFeeGWEI : maxBaseFeeMultiplier} - detailText={ - editingInGwei - ? `${maxBaseFeeMultiplier}x ${`≈ ${baseFeeInFiat}`}` - : `${maxBaseFeeGWEI} GWEI ${`≈ ${baseFeeInFiat}`}` - } + detailText={`≈ ${baseFeeInFiat}`} numeric /> ({ @@ -44,9 +44,9 @@ const render = (txProps) => { ...txProps, }} > - + - + , store, ); @@ -115,4 +115,58 @@ describe('BaseFeeInput', () => { }); expect(screen.queryByText('50')).toBeInTheDocument(); }); + + it('should show error if base fee is less than suggested low value', () => { + render({ + txParams: { + maxFeePerGas: '0x174876E800', + }, + }); + fireEvent.change(document.getElementsByTagName('input')[0], { + target: { value: 3 }, + }); + expect( + screen.queryByText('Max base fee is low for current network conditions'), + ).not.toBeInTheDocument(); + fireEvent.change(document.getElementsByTagName('input')[0], { + target: { value: 0.01 }, + }); + expect( + screen.queryByText('Max base fee is low for current network conditions'), + ).toBeInTheDocument(); + fireEvent.click(screen.queryByText('Edit in GWEI')); + fireEvent.change(document.getElementsByTagName('input')[0], { + target: { value: 10 }, + }); + expect( + screen.queryByText('Max base fee is low for current network conditions'), + ).toBeInTheDocument(); + }); + + it('should show error if base if is more than suggested high value', () => { + render({ + txParams: { + maxFeePerGas: '0x174876E800', + }, + }); + fireEvent.change(document.getElementsByTagName('input')[0], { + target: { value: 3 }, + }); + expect( + screen.queryByText('Max base fee is higher than necessary'), + ).not.toBeInTheDocument(); + fireEvent.change(document.getElementsByTagName('input')[0], { + target: { value: 10 }, + }); + fireEvent.click(screen.queryByText('Edit in GWEI')); + expect( + screen.queryByText('Max base fee is higher than necessary'), + ).toBeInTheDocument(); + fireEvent.change(document.getElementsByTagName('input')[0], { + target: { value: 500 }, + }); + expect( + screen.queryByText('Max base fee is higher than necessary'), + ).toBeInTheDocument(); + }); }); diff --git a/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-inputs/priority-fee-input/priority-fee-input.js b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-inputs/priority-fee-input/priority-fee-input.js index e1d717fd8..fbf1da2b5 100644 --- a/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-inputs/priority-fee-input/priority-fee-input.js +++ b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-inputs/priority-fee-input/priority-fee-input.js @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react'; import { useSelector } from 'react-redux'; +import { HIGH_FEE_WARNING_MULTIPLIER } from '../../../../../pages/send/send.constants'; import { PRIORITY_LEVELS } from '../../../../../../shared/constants/gas'; import { SECONDARY } from '../../../../../helpers/constants/common'; import { decGWEIToHexWEI } from '../../../../../helpers/utils/conversions.util'; @@ -10,18 +11,47 @@ import { useGasFeeContext } from '../../../../../contexts/gasFee'; import { useI18nContext } from '../../../../../hooks/useI18nContext'; import { useUserPreferencedCurrency } from '../../../../../hooks/useUserPreferencedCurrency'; import FormField from '../../../../ui/form-field'; +import { bnGreaterThan, bnLessThan } from '../../../../../helpers/utils/util'; -import { useAdvanceGasFeePopoverContext } from '../../context'; +import { useAdvancedGasFeePopoverContext } from '../../context'; import AdvancedGasFeeInputSubtext from '../../advanced-gas-fee-input-subtext'; +const validatePriorityFee = (value, gasFeeEstimates) => { + if (value <= 0) { + return 'editGasMaxPriorityFeeBelowMinimumV2'; + } + if ( + gasFeeEstimates?.low && + bnLessThan(value, gasFeeEstimates.low.suggestedMaxPriorityFeePerGas) + ) { + return 'editGasMaxPriorityFeeLowV2'; + } + if ( + gasFeeEstimates?.high && + bnGreaterThan( + value, + gasFeeEstimates.high.suggestedMaxPriorityFeePerGas * + HIGH_FEE_WARNING_MULTIPLIER, + ) + ) { + return 'editGasMaxPriorityFeeHighV2'; + } + return null; +}; + const PriorityFeeInput = () => { const t = useI18nContext(); const advancedGasFeeValues = useSelector(getAdvancedGasFeeValues); const { - setDirty, + setErrorValue, setMaxPriorityFeePerGas, - } = useAdvanceGasFeePopoverContext(); - const { estimateUsed, maxPriorityFeePerGas } = useGasFeeContext(); + } = useAdvancedGasFeePopoverContext(); + const { + estimateUsed, + gasFeeEstimates, + maxPriorityFeePerGas, + } = useGasFeeContext(); + const [priorityFeeError, setPriorityFeeError] = useState(); const [priorityFee, setPriorityFee] = useState(() => { if ( @@ -41,18 +71,30 @@ const PriorityFeeInput = () => { const updatePriorityFee = (value) => { setPriorityFee(value); - setDirty(true); }; useEffect(() => { setMaxPriorityFeePerGas(priorityFee); - }, [priorityFee, setMaxPriorityFeePerGas]); + const error = validatePriorityFee(priorityFee, gasFeeEstimates); + setErrorValue( + 'maxPriorityFeePerGas', + error === 'editGasMaxPriorityFeeBelowMinimumV2', + ); + setPriorityFeeError(error); + }, [ + gasFeeEstimates, + priorityFee, + setErrorValue, + setMaxPriorityFeePerGas, + setPriorityFeeError, + ]); return ( <> ({ @@ -43,9 +44,9 @@ const render = (txProps) => { ...txProps, }} > - + - + , store, ); @@ -67,4 +68,21 @@ describe('PriorityfeeInput', () => { }); expect(document.getElementsByTagName('input')[0]).toHaveValue(2); }); + + it('should show error if value entered is 0', () => { + render({ + txParams: { + maxPriorityFeePerGas: '0x174876E800', + }, + }); + expect( + screen.queryByText('Priority fee must be greater than 0.'), + ).not.toBeInTheDocument(); + fireEvent.change(document.getElementsByTagName('input')[0], { + target: { value: 0 }, + }); + expect( + screen.queryByText('Priority fee must be greater than 0.'), + ).toBeInTheDocument(); + }); }); diff --git a/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-popover.js b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-popover.js index cda544c81..e631fa2b1 100644 --- a/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-popover.js +++ b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-popover.js @@ -5,8 +5,9 @@ import { useTransactionModalContext } from '../../../contexts/transaction-modal' import Box from '../../ui/box'; import Popover from '../../ui/popover'; -import { AdvanceGasFeePopoverContextProvider } from './context'; +import { AdvancedGasFeePopoverContextProvider } from './context'; import AdvancedGasFeeInputs from './advanced-gas-fee-inputs'; +import AdvancedGasFeeGasLimit from './advanced-gas-fee-gas-limit'; import AdvancedGasFeeSaveButton from './advanced-gas-fee-save'; const AdvancedGasFeePopover = () => { @@ -20,7 +21,7 @@ const AdvancedGasFeePopover = () => { if (currentModal !== 'advancedGasFee') return null; return ( - + { onClose={closeAllModals} footer={} > - + +
+ - + ); }; diff --git a/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-popover.test.js b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-popover.test.js index dd7f0f7a7..1767143b9 100644 --- a/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-popover.test.js +++ b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-popover.test.js @@ -36,7 +36,6 @@ const render = () => { balance: '0x1F4', }, }, - advancedGasFee: { priorityFee: 100 }, featureFlags: { advancedInlineGas: true }, gasFeeEstimates: mockEstimates[GAS_ESTIMATE_TYPES.FEE_MARKET].gasFeeEstimates, @@ -46,7 +45,7 @@ const render = () => { return renderWithProvider( @@ -56,16 +55,24 @@ const render = () => { }; describe('AdvancedGasFeePopover', () => { - it('should renders save button disabled by default', () => { + it('should renders save button enabled by default', () => { render(); + expect(screen.queryByRole('button', { name: 'Save' })).not.toBeDisabled(); + }); + + it('should disable save button if priority fee 0 is entered', () => { + render(); + fireEvent.change(document.getElementsByTagName('input')[1], { + target: { value: 0 }, + }); expect(screen.queryByRole('button', { name: 'Save' })).toBeDisabled(); }); - it('should enable save button as input value is changed', () => { + it('should disable save button if priority fee entered is greater than base fee', () => { render(); - fireEvent.change(document.getElementsByTagName('input')[0], { - target: { value: 4 }, + fireEvent.change(document.getElementsByTagName('input')[1], { + target: { value: 100000 }, }); - expect(screen.queryByRole('button', { name: 'Save' })).not.toBeDisabled(); + expect(screen.queryByRole('button', { name: 'Save' })).toBeDisabled(); }); }); diff --git a/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-save/advanced-gas-fee-save.js b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-save/advanced-gas-fee-save.js index d8efd7c7c..23dd4e5c6 100644 --- a/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-save/advanced-gas-fee-save.js +++ b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-save/advanced-gas-fee-save.js @@ -6,29 +6,31 @@ import { useGasFeeContext } from '../../../../contexts/gasFee'; import Button from '../../../ui/button'; import I18nValue from '../../../ui/i18n-value'; -import { useAdvanceGasFeePopoverContext } from '../context'; +import { useAdvancedGasFeePopoverContext } from '../context'; import { decGWEIToHexWEI } from '../../../../../shared/modules/conversion.utils'; const AdvancedGasFeeSaveButton = () => { const { closeModal } = useTransactionModalContext(); const { updateTransaction } = useGasFeeContext(); const { - isDirty, + gasLimit, + hasErrors, maxFeePerGas, maxPriorityFeePerGas, - } = useAdvanceGasFeePopoverContext(); + } = useAdvancedGasFeePopoverContext(); const onSave = () => { - updateTransaction( - PRIORITY_LEVELS.CUSTOM, - decGWEIToHexWEI(maxFeePerGas), - decGWEIToHexWEI(maxPriorityFeePerGas), - ); + updateTransaction({ + estimateUsed: PRIORITY_LEVELS.CUSTOM, + maxFeePerGas: decGWEIToHexWEI(maxFeePerGas), + maxPriorityFeePerGas: decGWEIToHexWEI(maxPriorityFeePerGas), + gasLimit, + }); closeModal('advancedGasFee'); }; return ( - ); diff --git a/ui/components/app/advanced-gas-fee-popover/context/advanceGasFeePopover.js b/ui/components/app/advanced-gas-fee-popover/context/advanceGasFeePopover.js deleted file mode 100644 index a6bca0a33..000000000 --- a/ui/components/app/advanced-gas-fee-popover/context/advanceGasFeePopover.js +++ /dev/null @@ -1,33 +0,0 @@ -import React, { createContext, useContext, useState } from 'react'; -import PropTypes from 'prop-types'; - -export const AdvanceGasFeePopoverContext = createContext({}); - -export const AdvanceGasFeePopoverContextProvider = ({ children }) => { - const [maxFeePerGas, setMaxFeePerGas] = useState(); - const [maxPriorityFeePerGas, setMaxPriorityFeePerGas] = useState(); - const [isDirty, setDirty] = useState(); - - return ( - - {children} - - ); -}; - -export function useAdvanceGasFeePopoverContext() { - return useContext(AdvanceGasFeePopoverContext); -} - -AdvanceGasFeePopoverContextProvider.propTypes = { - children: PropTypes.node.isRequired, -}; diff --git a/ui/components/app/advanced-gas-fee-popover/context/advancedGasFeePopover.js b/ui/components/app/advanced-gas-fee-popover/context/advancedGasFeePopover.js new file mode 100644 index 000000000..5297f3902 --- /dev/null +++ b/ui/components/app/advanced-gas-fee-popover/context/advancedGasFeePopover.js @@ -0,0 +1,48 @@ +import React, { createContext, useCallback, useContext, useState } from 'react'; +import PropTypes from 'prop-types'; + +export const AdvancedGasFeePopoverContext = createContext({}); + +export const AdvancedGasFeePopoverContextProvider = ({ children }) => { + const [gasLimit, setGasLimit] = useState(); + const [maxFeePerGas, setMaxFeePerGas] = useState(); + const [maxPriorityFeePerGas, setMaxPriorityFeePerGas] = useState(); + const [errors, setErrors] = useState({ + maxFeePerGas: false, + maxPriorityFeePerGas: false, + }); + + const setErrorValue = useCallback( + (field, value) => { + if (errors[field] !== value) { + setErrors({ ...errors, [field]: value }); + } + }, + [errors, setErrors], + ); + + return ( + + {children} + + ); +}; + +export function useAdvancedGasFeePopoverContext() { + return useContext(AdvancedGasFeePopoverContext); +} + +AdvancedGasFeePopoverContextProvider.propTypes = { + children: PropTypes.node.isRequired, +}; diff --git a/ui/components/app/advanced-gas-fee-popover/context/index.js b/ui/components/app/advanced-gas-fee-popover/context/index.js index f9181147b..f959b46af 100644 --- a/ui/components/app/advanced-gas-fee-popover/context/index.js +++ b/ui/components/app/advanced-gas-fee-popover/context/index.js @@ -1 +1 @@ -export * from './advanceGasFeePopover'; +export * from './advancedGasFeePopover'; diff --git a/ui/components/app/advanced-gas-fee-popover/index.scss b/ui/components/app/advanced-gas-fee-popover/index.scss index 604a983e5..a9ac727ed 100644 --- a/ui/components/app/advanced-gas-fee-popover/index.scss +++ b/ui/components/app/advanced-gas-fee-popover/index.scss @@ -7,4 +7,8 @@ border-top: 1px solid $ui-grey; margin: 24px 0 16px 0; } + + .form-field__heading-title > h6 { + font-size: $font-size-h7; + } } diff --git a/ui/components/app/alerts/unconnected-account-alert/unconnected-account-alert.test.js b/ui/components/app/alerts/unconnected-account-alert/unconnected-account-alert.test.js index 3aad2342b..cc5f64a9f 100644 --- a/ui/components/app/alerts/unconnected-account-alert/unconnected-account-alert.test.js +++ b/ui/components/app/alerts/unconnected-account-alert/unconnected-account-alert.test.js @@ -65,7 +65,7 @@ describe('Unconnected Account Alert', () => { provider: { chainId: KOVAN_CHAIN_ID, }, - permissionsHistory: { + permissionHistory: { 'https://test.dapp': { eth_accounts: { accounts: { @@ -74,26 +74,20 @@ describe('Unconnected Account Alert', () => { }, }, }, - domains: { + subjects: { 'https://test.dapp': { - permissions: [ - { + permissions: { + eth_accounts: { caveats: [ { - name: 'primaryAccountOnly', - type: 'limitResponseLength', - value: 1, - }, - { - name: 'exposedAccounts', - type: 'filterResponse', + type: 'restrictReturnedAccounts', value: ['0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'], }, ], invoker: 'https://test.dapp', parentCapability: 'eth_accounts', }, - ], + }, }, }, }, diff --git a/ui/components/app/app-components.scss b/ui/components/app/app-components.scss index 26d0d4fa1..756d1bc18 100644 --- a/ui/components/app/app-components.scss +++ b/ui/components/app/app-components.scss @@ -7,16 +7,24 @@ @import 'app-header/index'; @import 'asset-list-item/asset-list-item'; @import 'confirm-page-container/index'; +@import 'collectibles-items/index'; +@import 'collectibles-tab/index'; +@import 'collectible-details/index'; +@import 'collectible-options/index'; +@import 'collectibles-detection-notice/index'; @import 'connected-accounts-list/index'; @import 'connected-accounts-permissions/index'; @import 'connected-sites-list/index'; @import 'connected-status-indicator/index'; @import 'edit-gas-display/index'; @import 'edit-gas-display-education/index'; +@import 'edit-gas-fee-button/index'; @import 'edit-gas-fee-popover/index'; @import 'edit-gas-fee-popover/edit-gas-item/index'; -@import 'edit-gas-fee-popover/network-status/index'; -@import 'edit-gas-fee-popover/network-status/status-slider/index'; +@import 'edit-gas-fee-popover/network-statistics/index'; +@import 'edit-gas-fee-popover/network-statistics/status-slider/index'; +@import 'flask/snaps-authorship-pill/index'; +@import 'edit-gas-fee-popover/edit-gas-tooltip/index'; @import 'gas-customization/gas-modal-page-container/index'; @import 'gas-customization/gas-price-button-group/index'; @import 'gas-customization/index'; @@ -31,11 +39,13 @@ @import 'permission-page-container/index'; @import 'permissions-connect-footer/index'; @import 'permissions-connect-header/index'; +@import 'permissions-connect-permission-list/index'; @import 'recovery-phrase-reminder/index'; @import 'step-progress-bar/index.scss'; @import 'selected-account/index'; @import 'signature-request/index'; @import 'signature-request-original/index'; +@import 'flask/snap-settings-card/index'; @import 'tab-bar/index'; @import 'token-cell/token-cell'; @import 'transaction-activity-log/index'; @@ -53,6 +63,7 @@ @import 'loading-network-screen/index'; @import 'flask/experimental-area/index'; @import 'advanced-gas-fee-popover/index'; +@import 'advanced-gas-fee-popover/advanced-gas-fee-gas-limit/index'; @import 'advanced-gas-fee-popover/advanced-gas-fee-inputs/index'; @import 'advanced-gas-fee-popover/advanced-gas-fee-inputs/base-fee-input/index'; @import 'advanced-gas-fee-popover/advanced-gas-fee-input-subtext/index'; diff --git a/ui/components/app/asset-list-item/asset-list-item.js b/ui/components/app/asset-list-item/asset-list-item.js index 801a8735b..56667db55 100644 --- a/ui/components/app/asset-list-item/asset-list-item.js +++ b/ui/components/app/asset-list-item/asset-list-item.js @@ -138,28 +138,28 @@ const AssetListItem = ({ }; AssetListItem.propTypes = { - 'className': PropTypes.string, + className: PropTypes.string, 'data-testid': PropTypes.string, - 'iconClassName': PropTypes.string, - 'onClick': PropTypes.func.isRequired, - 'tokenAddress': PropTypes.string, - 'tokenSymbol': PropTypes.string, - 'tokenDecimals': PropTypes.number, - 'tokenImage': PropTypes.string, - 'warning': PropTypes.node, - 'primary': PropTypes.string, - 'secondary': PropTypes.string, - 'identiconBorder': PropTypes.bool, - 'isERC721': PropTypes.bool, + iconClassName: PropTypes.string, + onClick: PropTypes.func.isRequired, + tokenAddress: PropTypes.string, + tokenSymbol: PropTypes.string, + tokenDecimals: PropTypes.number, + tokenImage: PropTypes.string, + warning: PropTypes.node, + primary: PropTypes.string, + secondary: PropTypes.string, + identiconBorder: PropTypes.bool, + isERC721: PropTypes.bool, }; AssetListItem.defaultProps = { - 'className': undefined, + className: undefined, 'data-testid': undefined, - 'iconClassName': undefined, - 'tokenAddress': undefined, - 'tokenImage': undefined, - 'warning': undefined, + iconClassName: undefined, + tokenAddress: undefined, + tokenImage: undefined, + warning: undefined, }; export default AssetListItem; diff --git a/ui/components/app/collectible-details/collectible-details.js b/ui/components/app/collectible-details/collectible-details.js new file mode 100644 index 000000000..213b42f72 --- /dev/null +++ b/ui/components/app/collectible-details/collectible-details.js @@ -0,0 +1,251 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; +import { useHistory } from 'react-router-dom'; +import { getTokenTrackerLink } from '@metamask/etherscan-link'; +import Box from '../../ui/box'; +import Card from '../../ui/card'; +import Typography from '../../ui/typography/typography'; +import { + COLORS, + TYPOGRAPHY, + FONT_WEIGHT, + JUSTIFY_CONTENT, + FLEX_DIRECTION, + OVERFLOW_WRAP, + DISPLAY, +} from '../../../helpers/constants/design-system'; +import { useI18nContext } from '../../../hooks/useI18nContext'; +import { + getAssetImageURL, + isEqualCaseInsensitive, + shortenAddress, +} from '../../../helpers/utils/util'; +import { + getCurrentChainId, + getIpfsGateway, + getRpcPrefsForCurrentProvider, + getSelectedIdentity, +} from '../../../selectors'; +import AssetNavigation from '../../../pages/asset/components/asset-navigation'; +import { getCollectibleContracts } from '../../../ducks/metamask/metamask'; +import { DEFAULT_ROUTE } from '../../../helpers/constants/routes'; +import { removeAndIgnoreCollectible } from '../../../store/actions'; +import { + GOERLI_CHAIN_ID, + KOVAN_CHAIN_ID, + MAINNET_CHAIN_ID, + POLYGON_CHAIN_ID, + RINKEBY_CHAIN_ID, + ROPSTEN_CHAIN_ID, +} from '../../../../shared/constants/network'; +import { getEnvironmentType } from '../../../../app/scripts/lib/util'; +import { ENVIRONMENT_TYPE_POPUP } from '../../../../shared/constants/app'; +import CollectibleOptions from '../collectible-options/collectible-options'; + +export default function CollectibleDetails({ collectible }) { + const { image, name, description, address, tokenId } = collectible; + const t = useI18nContext(); + const history = useHistory(); + const rpcPrefs = useSelector(getRpcPrefsForCurrentProvider); + const ipfsGateway = useSelector(getIpfsGateway); + const collectibleContracts = useSelector(getCollectibleContracts); + const currentNetwork = useSelector(getCurrentChainId); + + const collectibleContractName = collectibleContracts.find( + ({ address: contractAddress }) => + isEqualCaseInsensitive(contractAddress, address), + )?.name; + const selectedAccountName = useSelector( + (state) => getSelectedIdentity(state).name, + ); + const collectibleImageURL = getAssetImageURL(image, ipfsGateway); + const dispatch = useDispatch(); + + const onRemove = () => { + dispatch(removeAndIgnoreCollectible(address, tokenId)); + history.push(DEFAULT_ROUTE); + }; + + const getOpenSeaLink = () => { + switch (currentNetwork) { + case MAINNET_CHAIN_ID: + return `https://opensea.io/assets/${address}/${tokenId}`; + case POLYGON_CHAIN_ID: + return `https://opensea.io/assets/matic/${address}/${tokenId}`; + case GOERLI_CHAIN_ID: + case KOVAN_CHAIN_ID: + case ROPSTEN_CHAIN_ID: + case RINKEBY_CHAIN_ID: + return `https://testnets.opensea.io/assets/${address}/${tokenId}`; + default: + return null; + } + }; + + const openSeaLink = getOpenSeaLink(); + return ( + <> + history.push(DEFAULT_ROUTE)} + optionsButton={ + global.platform.openTab({ url: openSeaLink }) + : null + } + onRemove={onRemove} + /> + } + /> + +
+ + + + + + {name} + + + {`#${tokenId}`} + + + {t('description')} + + + {description} + + +
+ + + + {t('source')} + + + + {image} + + + + + + {t('contractAddress')} + + + + {getEnvironmentType() === ENVIRONMENT_TYPE_POPUP + ? shortenAddress(address) + : address} + + + + +
+ + ); +} + +CollectibleDetails.propTypes = { + collectible: PropTypes.shape({ + address: PropTypes.string.isRequired, + tokenId: PropTypes.string.isRequired, + name: PropTypes.string, + description: PropTypes.string, + image: PropTypes.string, + standard: PropTypes.string, + imageThumbnail: PropTypes.string, + imagePreview: PropTypes.string, + creator: PropTypes.shape({ + address: PropTypes.string, + config: PropTypes.string, + profile_img_url: PropTypes.string, + }), + }), +}; diff --git a/ui/components/app/collectible-details/collectible-details.stories.js b/ui/components/app/collectible-details/collectible-details.stories.js new file mode 100644 index 000000000..bbd18bac0 --- /dev/null +++ b/ui/components/app/collectible-details/collectible-details.stories.js @@ -0,0 +1,31 @@ +import React from 'react'; +import CollectibleDetails from './collectible-details'; + +export default { + title: 'Components/App/CollectiblesDetail', + id: __filename, + argTypes: { + collectible: { + control: 'object', + }, + }, +}; + +const collectible = { + name: 'Catnip Spicywright', + tokenId: '1124157', + address: '0x06012c8cf97bead5deae237070f9587f8e7a266d', + image: './catnip-spicywright.png', + description: + "Good day. My name is Catnip Spicywight, which got me teased a lot in high school. If I want to put low fat mayo all over my hamburgers, I shouldn't have to answer to anyone about it, am I right? One time I beat Arlene in an arm wrestle.", +}; + +export const DefaultStory = () => { + return ; +}; + +DefaultStory.storyName = 'Default'; + +DefaultStory.args = { + collectible, +}; diff --git a/ui/components/app/collectible-details/index.scss b/ui/components/app/collectible-details/index.scss new file mode 100644 index 000000000..424b603f4 --- /dev/null +++ b/ui/components/app/collectible-details/index.scss @@ -0,0 +1,72 @@ +$card-width-break-large: 224px; +$link-title-width: 160px; +$spacer-break-large: 24px; +$spacer-break-small: 16px; + +.collectible-details { + padding: 0 $spacer-break-small; + + @media screen and (min-width: $break-large) { + padding: 0 $spacer-break-large; + } + + &__top-section { + display: flex; + flex-direction: column; + margin-bottom: $spacer-break-small; + + @media screen and (min-width: $break-large) { + margin-bottom: $spacer-break-large; + flex-direction: row; + } + + &__info { + @media screen and (min-width: $break-large) { + max-width: calc(100% - #{$card-width-break-large} - #{$spacer-break-large}); + flex: 0 0 calc(100% - #{$card-width-break-large} - #{$spacer-break-large}); + } + } + } + + &__card { + overflow: hidden; + margin-bottom: $spacer-break-small; + + @media screen and (min-width: $break-large) { + margin-right: $spacer-break-large; + margin-bottom: 0; + max-width: $card-width-break-large; + flex: 0 0 $card-width-break-large; + height: $card-width-break-large; + } + } + + &__image { + width: 100%; + + @media screen and (min-width: $break-large) { + width: $card-width-break-large; + } + } + + &__address { + overflow-wrap: break-word; + } + + &__contract-link, + &__image-link { + color: $primary-1; + overflow: hidden; + text-overflow: ellipsis; + word-break: break-all; + + &:hover { + color: $primary-3; + } + } + + &__link-title { + flex: 0 0 $link-title-width; + max-width: 0 0 $link-title-width; + } +} diff --git a/ui/components/app/collectible-options/collectible-options.js b/ui/components/app/collectible-options/collectible-options.js new file mode 100644 index 000000000..5df1b2019 --- /dev/null +++ b/ui/components/app/collectible-options/collectible-options.js @@ -0,0 +1,61 @@ +import React, { useContext, useState } from 'react'; +import PropTypes from 'prop-types'; + +import { I18nContext } from '../../../contexts/i18n'; +import { Menu, MenuItem } from '../../ui/menu'; + +const CollectibleOptions = ({ onRemove, onViewOnOpensea }) => { + const t = useContext(I18nContext); + const [ + collectibleOptionsButtonElement, + setCollectibleOptionsButtonElement, + ] = useState(null); + const [collectibleOptionsOpen, setCollectibleOptionsOpen] = useState(false); + + return ( + <> + diff --git a/ui/components/app/collectibles-detection-notice/index.js b/ui/components/app/collectibles-detection-notice/index.js new file mode 100644 index 000000000..f0ae0eaa2 --- /dev/null +++ b/ui/components/app/collectibles-detection-notice/index.js @@ -0,0 +1 @@ +export { default } from './collectibles-detection-notice'; diff --git a/ui/components/app/collectibles-detection-notice/index.scss b/ui/components/app/collectibles-detection-notice/index.scss new file mode 100644 index 000000000..27a28c46f --- /dev/null +++ b/ui/components/app/collectibles-detection-notice/index.scss @@ -0,0 +1,31 @@ +.collectibles-detection-notice { + &__message { + position: relative; + padding: 0 1rem 1rem 1rem !important; + + &__close-button { + background-color: transparent; + + &::after { + position: absolute; + content: '\00D7'; + font-size: 29px; + font-weight: 200; + color: $black; + background-color: transparent; + top: 0; + right: 12px; + cursor: pointer; + } + } + + a.collectibles-detection-notice__message__link { + @include H6; + + width: 60%; + padding: 0; + justify-content: flex-start; + font-weight: bold; + } + } +} diff --git a/ui/components/app/collectibles-items/collectibles-items.component.js b/ui/components/app/collectibles-items/collectibles-items.component.js deleted file mode 100644 index 887653658..000000000 --- a/ui/components/app/collectibles-items/collectibles-items.component.js +++ /dev/null @@ -1,148 +0,0 @@ -import React, { useState } from 'react'; -import PropTypes from 'prop-types'; -import Box from '../../ui/box'; -import Button from '../../ui/button'; -import Typography from '../../ui/typography/typography'; -import { - COLORS, - TYPOGRAPHY, - TEXT_ALIGN, - JUSTIFY_CONTENT, - FLEX_DIRECTION, - ALIGN_ITEMS, - DISPLAY, - BLOCK_SIZES, - SIZES, - FLEX_WRAP, -} from '../../../helpers/constants/design-system'; -import { ENVIRONMENT_TYPE_POPUP } from '../../../../shared/constants/app'; -import { useI18nContext } from '../../../hooks/useI18nContext'; -import { getEnvironmentType } from '../../../../app/scripts/lib/util'; - -export default function CollectiblesItems({ onAddNFT, onRefreshList }) { - const t = useI18nContext(); - const collections = {}; - const defaultDropdownState = {}; - - Object.keys(collections).forEach((key) => { - defaultDropdownState[key] = true; - }); - - const [dropdownState, setDropdownState] = useState(defaultDropdownState); - const width = - getEnvironmentType() === ENVIRONMENT_TYPE_POPUP - ? BLOCK_SIZES.ONE_THIRD - : BLOCK_SIZES.ONE_SIXTH; - return ( -
- - <> - {Object.keys(collections).map((key, index) => { - const { icon, collectibles } = collections[key]; - const isExpanded = dropdownState[key]; - - return ( -
- - - - - {`${key} (${collectibles.length})`} - - - - { - setDropdownState((_dropdownState) => ({ - ..._dropdownState, - [key]: !isExpanded, - })); - }} - /> - - - {isExpanded ? ( - - {collectibles.map((collectible, i) => { - return ( - - - - - - ); - })} - - ) : null} -
- ); - })} - - - {t('missingNFT')} - - - - - - - {t('or')} - - - - - - - -
-
- ); -} - -CollectiblesItems.propTypes = { - onAddNFT: PropTypes.func.isRequired, - onRefreshList: PropTypes.func.isRequired, -}; diff --git a/ui/components/app/collectibles-items/collectibles-items.js b/ui/components/app/collectibles-items/collectibles-items.js new file mode 100644 index 000000000..eff55a954 --- /dev/null +++ b/ui/components/app/collectibles-items/collectibles-items.js @@ -0,0 +1,164 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { useSelector } from 'react-redux'; +import { useHistory } from 'react-router-dom'; +import Box from '../../ui/box'; +import Typography from '../../ui/typography/typography'; +import { + COLORS, + TYPOGRAPHY, + JUSTIFY_CONTENT, + FLEX_DIRECTION, + ALIGN_ITEMS, + DISPLAY, + BLOCK_SIZES, + FLEX_WRAP, +} from '../../../helpers/constants/design-system'; +import { ENVIRONMENT_TYPE_POPUP } from '../../../../shared/constants/app'; +import { getEnvironmentType } from '../../../../app/scripts/lib/util'; +import { getIpfsGateway } from '../../../selectors'; +import { ASSET_ROUTE } from '../../../helpers/constants/routes'; +import { getAssetImageURL } from '../../../helpers/utils/util'; + +const width = + getEnvironmentType() === ENVIRONMENT_TYPE_POPUP + ? BLOCK_SIZES.ONE_THIRD + : BLOCK_SIZES.ONE_SIXTH; +export default function CollectiblesItems({ collections = {} }) { + const defaultDropdownState = {}; + const ipfsGateway = useSelector(getIpfsGateway); + + Object.keys(collections).forEach((key) => { + defaultDropdownState[key] = true; + }); + const history = useHistory(); + + const [dropdownState, setDropdownState] = useState(defaultDropdownState); + return ( +
+ + <> + {Object.keys(collections).map((key, index) => { + const { + collectibles, + collectionName, + collectionImage, + } = collections[key]; + + const isExpanded = dropdownState[key]; + return ( +
{ + setDropdownState((_dropdownState) => ({ + ..._dropdownState, + [key]: !isExpanded, + })); + }} + > + + + {collectionImage ? ( + + ) : ( +
+ {collectionName[0]} +
+ )} + + {`${collectionName} (${collectibles.length})`} + +
+ + + +
+ {isExpanded ? ( + + {collectibles.map((collectible, i) => { + const { + image, + address, + tokenId, + backgroundColor, + } = collectible; + const collectibleImage = getAssetImageURL( + image, + ipfsGateway, + ); + return ( + +
+ + history.push( + `${ASSET_ROUTE}/${address}/${tokenId}`, + ) + } + className="collectibles-items__image" + src={collectibleImage} + /> +
+
+ ); + })} +
+ ) : null} +
+ ); + })} + +
+
+ ); +} + +CollectiblesItems.propTypes = { + collections: PropTypes.shape({ + collectibles: PropTypes.arrayOf( + PropTypes.shape({ + address: PropTypes.string.isRequired, + tokenId: PropTypes.string.isRequired, + name: PropTypes.string, + description: PropTypes.string, + image: PropTypes.string, + standard: PropTypes.string, + imageThumbnail: PropTypes.string, + imagePreview: PropTypes.string, + creator: PropTypes.shape({ + address: PropTypes.string, + config: PropTypes.string, + profile_img_url: PropTypes.string, + }), + }), + ), + collectionImage: PropTypes.string, + collectionName: PropTypes.string, + }), +}; diff --git a/ui/components/app/collectibles-items/index.js b/ui/components/app/collectibles-items/index.js index b8b286c20..79b4e7302 100644 --- a/ui/components/app/collectibles-items/index.js +++ b/ui/components/app/collectibles-items/index.js @@ -1 +1 @@ -export { default } from './collectibles-items.component'; +export { default } from './collectibles-items'; diff --git a/ui/components/app/collectibles-items/index.scss b/ui/components/app/collectibles-items/index.scss new file mode 100644 index 000000000..cb786bc99 --- /dev/null +++ b/ui/components/app/collectibles-items/index.scss @@ -0,0 +1,41 @@ +.collectibles-items { + &__image__wrapper { + border-radius: 4px; + width: 100%; + display: flex; + justify-content: center; + cursor: pointer; + } + + &__image { + border-radius: 4px; + width: 100%; + height: 100%; + cursor: pointer; + } + + &__item { + margin-bottom: 24px; + + &__accordion-title { + cursor: pointer; + } + + &__collection-image { + width: 32px; + height: 32px; + border-radius: 50%; + } + + &__collection-image-alt { + border-radius: 50%; + width: 32px; + height: 32px; + padding: 8px; + background: $ui-4; + color: $ui-white; + text-align: center; + line-height: 1; + } + } +} diff --git a/ui/components/app/collectibles-tab/collectibles-tab.component.js b/ui/components/app/collectibles-tab/collectibles-tab.component.js deleted file mode 100644 index 42c85d17a..000000000 --- a/ui/components/app/collectibles-tab/collectibles-tab.component.js +++ /dev/null @@ -1,90 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import Box from '../../ui/box'; -import Button from '../../ui/button'; -import Typography from '../../ui/typography/typography'; -import NewCollectiblesNotice from '../new-collectibles-notice'; -import CollectiblesItems from '../collectibles-items'; -import { - COLORS, - TYPOGRAPHY, - TEXT_ALIGN, - JUSTIFY_CONTENT, - FLEX_DIRECTION, - FONT_WEIGHT, -} from '../../../helpers/constants/design-system'; -import { useI18nContext } from '../../../hooks/useI18nContext'; - -export default function CollectiblesTab({ onAddNFT }) { - const collectibles = []; - const newNFTsDetected = false; - const t = useI18nContext(); - - return ( -
- {collectibles.length > 0 ? ( - { - console.log('refreshing collectibles'); - }} - /> - ) : ( - - {newNFTsDetected ? : null} - - - - - - {t('noNFTs')} - - - - - - {t('missingNFT')} - - - - - )} -
- ); -} - -CollectiblesTab.propTypes = { - onAddNFT: PropTypes.func.isRequired, -}; diff --git a/ui/components/app/collectibles-tab/collectibles-tab.js b/ui/components/app/collectibles-tab/collectibles-tab.js new file mode 100644 index 000000000..9264144b7 --- /dev/null +++ b/ui/components/app/collectibles-tab/collectibles-tab.js @@ -0,0 +1,162 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; +import { useHistory } from 'react-router-dom'; +import Box from '../../ui/box'; +import Button from '../../ui/button'; +import Typography from '../../ui/typography/typography'; +import CollectiblesDetectionNotice from '../collectibles-detection-notice'; +import CollectiblesItems from '../collectibles-items'; +import { + COLORS, + TYPOGRAPHY, + TEXT_ALIGN, + JUSTIFY_CONTENT, + FLEX_DIRECTION, + FONT_WEIGHT, + ALIGN_ITEMS, +} from '../../../helpers/constants/design-system'; +import { useI18nContext } from '../../../hooks/useI18nContext'; +import { + getCollectibles, + getCollectibleContracts, + getCollectiblesDetectionNoticeDismissed, +} from '../../../ducks/metamask/metamask'; +import { getIsMainnet, getUseCollectibleDetection } from '../../../selectors'; +import { EXPERIMENTAL_ROUTE } from '../../../helpers/constants/routes'; +import { detectCollectibles } from '../../../store/actions'; + +export default function CollectiblesTab({ onAddNFT }) { + const collectibles = useSelector(getCollectibles); + const collectibleContracts = useSelector(getCollectibleContracts); + const useCollectibleDetection = useSelector(getUseCollectibleDetection); + const isMainnet = useSelector(getIsMainnet); + const collectibleDetectionNoticeDismissed = useSelector( + getCollectiblesDetectionNoticeDismissed, + ); + const history = useHistory(); + const t = useI18nContext(); + const dispatch = useDispatch(); + + const collections = {}; + collectibles.forEach((collectible) => { + if (collections[collectible.address]) { + collections[collectible.address].collectibles.push(collectible); + } else { + const collectionContract = collectibleContracts.find( + ({ address }) => address === collectible.address, + ); + collections[collectible.address] = { + collectionName: collectionContract?.name || collectible.name, + collectionImage: + collectionContract?.logo || collectible.collectionImage, + collectibles: [collectible], + }; + } + }); + + const onEnableAutoDetect = () => { + history.push(EXPERIMENTAL_ROUTE); + }; + + return ( +
+ {collectibles.length > 0 ? ( + + ) : ( + + {isMainnet && + !useCollectibleDetection && + !collectibleDetectionNoticeDismissed ? ( + + ) : null} + + + + + + {t('noNFTs')} + + + + + )} + + + {t('missingNFT')} + + + {isMainnet ? ( + <> + + {useCollectibleDetection ? ( + + ) : ( + + )} + + + {t('or')} + + + ) : null} + + + + + +
+ ); +} + +CollectiblesTab.propTypes = { + onAddNFT: PropTypes.func.isRequired, +}; diff --git a/ui/components/app/collectibles-tab/collectibles-tab.test.js b/ui/components/app/collectibles-tab/collectibles-tab.test.js new file mode 100644 index 000000000..827a489aa --- /dev/null +++ b/ui/components/app/collectibles-tab/collectibles-tab.test.js @@ -0,0 +1,300 @@ +import React from 'react'; +import { fireEvent, screen } from '@testing-library/react'; +import reactRouterDom from 'react-router-dom'; +import configureStore from '../../../store/store'; +import { renderWithProvider } from '../../../../test/jest/rendering'; +import { EXPERIMENTAL_ROUTE } from '../../../helpers/constants/routes'; +import { setBackgroundConnection } from '../../../../test/jest'; +import CollectiblesTab from '.'; + +const COLLECTIBLES = [ + { + address: '0x495f947276749Ce646f68AC8c248420045cb7b5e', + tokenId: + '58076532811975507823669075598676816378162417803895263482849101575514658701313', + name: 'Punk #4', + creator: { + user: { + username: null, + }, + profile_img_url: null, + address: '0x806627172af48bd5b0765d3449a7def80d6576ff', + config: '', + }, + description: 'Red Mohawk bam!', + image: + 'https://lh3.googleusercontent.com/BdxvLseXcfl57BiuQcQYdJ64v-aI8din7WPk0Pgo3qQFhAUH-B6i-dCqqc_mCkRIzULmwzwecnohLhrcH8A9mpWIZqA7ygc52Sr81hE', + standard: 'ERC1155', + }, + { + address: '0x495f947276749Ce646f68AC8c248420045cb7b5e', + tokenId: + '58076532811975507823669075598676816378162417803895263482849101574415147073537', + name: 'Punk #3', + creator: { + user: { + username: null, + }, + profile_img_url: null, + address: '0x806627172af48bd5b0765d3449a7def80d6576ff', + config: '', + }, + description: 'Clown PUNK!!!', + image: + 'https://lh3.googleusercontent.com/H7VrxaalZv4PF1B8U7ADuc8AfuqTVyzmMEDQ5OXKlx0Tqu5XiwsKYj4j_pAF6wUJjLMQbSN_0n3fuj84lNyRhFW9hyrxqDfY1IiQEQ', + standard: 'ERC1155', + }, + { + address: '0x495f947276749Ce646f68AC8c248420045cb7b5e', + tokenId: + '58076532811975507823669075598676816378162417803895263482849101573315635445761', + name: 'Punk #2', + creator: { + user: { + username: null, + }, + profile_img_url: null, + address: '0x806627172af48bd5b0765d3449a7def80d6576ff', + config: '', + }, + description: 'Got glasses and black hair!', + image: + 'https://lh3.googleusercontent.com/CHNTSlKB_Gob-iwTq8jcag6XwBkTqBMLt_vEKeBv18Q4AoPFAEPceqK6mRzkad2s5djx6CT5zbGQwDy81WwtNzViK5dQbG60uAWv', + standard: 'ERC1155', + }, + { + address: '0x495f947276749Ce646f68AC8c248420045cb7b5e', + tokenId: + '58076532811975507823669075598676816378162417803895263482849101572216123817985', + name: 'Punk #1', + creator: { + user: { + username: null, + }, + profile_img_url: null, + address: '0x806627172af48bd5b0765d3449a7def80d6576ff', + config: '', + }, + image: + 'https://lh3.googleusercontent.com/4jfPi-nQNWCUXD5qVNVWX7LX2UufU_elEJcvICFlsTdcBXv70asnDEOlI8oKECZxlXq1wseeIXMwmP5tLyOUxMKk', + standard: 'ERC1155', + }, + { + address: '0x495f947276749Ce646f68AC8c248420045cb7b5e', + tokenId: + '58076532811975507823669075598676816378162417803895263482849101571116612190209', + name: 'Punk #4651', + creator: { + user: { + username: null, + }, + profile_img_url: null, + address: '0x806627172af48bd5b0765d3449a7def80d6576ff', + config: '', + }, + image: + 'https://lh3.googleusercontent.com/BdxvLseXcfl57BiuQcQYdJ64v-aI8din7WPk0Pgo3qQFhAUH-B6i-dCqqc_mCkRIzULmwzwecnohLhrcH8A9mpWIZqA7ygc52Sr81hE', + standard: 'ERC1155', + }, + { + address: '0xDc7382Eb0Bc9C352A4CbA23c909bDA01e0206414', + tokenId: '1', + name: 'MUNK #1', + description: null, + image: 'ipfs://QmTSZUNt8AKyDabkyXXXP4oHWDnaVXgNdXoJGEyaYzLbeL', + standard: 'ERC721', + }, + { + address: '0xDc7382Eb0Bc9C352A4CbA23c909bDA01e0206414', + tokenId: '2', + name: 'MUNK #2', + description: null, + image: 'ipfs://QmTSZUNt8AKyDabkyXXXP4oHWDnaVXgNdXoJGEyaYzLbeL', + standard: 'ERC721', + }, + { + address: '0xDc7382Eb0Bc9C352A4CbA23c909bDA01e0206414', + tokenId: '3', + name: 'MUNK #3', + description: null, + image: 'ipfs://QmTSZUNt8AKyDabkyXXXP4oHWDnaVXgNdXoJGEyaYzLbeL', + standard: 'ERC721', + }, +]; + +const COLLECTIBLES_CONTRACTS = [ + { + address: '0x495f947276749Ce646f68AC8c248420045cb7b5e', + name: 'PUNKS', + symbol: 'PNKS', + schemaName: 'ERC1155', + }, + { + address: '0xDc7382Eb0Bc9C352A4CbA23c909bDA01e0206414', + name: 'Munks', + symbol: 'MNKS', + }, +]; + +const ACCOUNT_1 = '0x123'; +const ACCOUNT_2 = '0x456'; + +const render = ({ + collectibleContracts = [], + collectibles = [], + selectedAddress, + chainId = '0x1', + collectiblesDetectionNoticeDismissed = false, + useCollectibleDetection, + onAddNFT = jest.fn(), +}) => { + const store = configureStore({ + metamask: { + allCollectibles: { + [ACCOUNT_1]: { + [chainId]: collectibles, + }, + }, + allCollectibleContracts: { + [ACCOUNT_1]: { + [chainId]: collectibleContracts, + }, + }, + provider: { chainId }, + selectedAddress, + collectiblesDetectionNoticeDismissed, + useCollectibleDetection, + }, + }); + return renderWithProvider(, store); +}; + +describe('Collectible Items', () => { + const detectCollectiblesStub = jest.fn(); + const setCollectiblesDetectionNoticeDismissedStub = jest.fn(); + setBackgroundConnection({ + setCollectiblesDetectionNoticeDismissed: setCollectiblesDetectionNoticeDismissedStub, + detectCollectibles: detectCollectiblesStub, + }); + const historyPushMock = jest.fn(); + + jest + .spyOn(reactRouterDom, 'useHistory') + .mockImplementation() + .mockReturnValue({ push: historyPushMock }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('Collectibles Detection Notice', () => { + it('should render the Collectibles Detection Notice when currently selected network is Mainnet and currently selected account has no collectibles', () => { + render({ + selectedAddress: ACCOUNT_2, + collectibles: COLLECTIBLES, + }); + expect(screen.queryByText('New! NFT detection')).toBeInTheDocument(); + }); + it('should not render the Collectibles Detection Notice when currently selected network is Mainnet and currently selected account has collectibles', () => { + render({ + selectedAddress: ACCOUNT_1, + collectibles: COLLECTIBLES, + }); + expect(screen.queryByText('New! NFT detection')).not.toBeInTheDocument(); + }); + it('should take user to the experimental settings tab in setings when user clicks "Turn on NFT detection in Settings"', () => { + render({ + selectedAddress: ACCOUNT_2, + collectibles: COLLECTIBLES, + }); + fireEvent.click(screen.queryByText('Turn on NFT detection in Settings')); + expect(historyPushMock).toHaveBeenCalledTimes(1); + expect(historyPushMock).toHaveBeenCalledWith(EXPERIMENTAL_ROUTE); + }); + it('should not render the Collectibles Detection Notice when currently selected network is Mainnet and currently selected account has no collectibles but use collectible autodetection preference is set to true', () => { + render({ + selectedAddress: ACCOUNT_1, + collectibles: COLLECTIBLES, + useCollectibleDetection: true, + }); + expect(screen.queryByText('New! NFT detection')).not.toBeInTheDocument(); + }); + it('should not render the Collectibles Detection Notice when currently selected network is Mainnet and currently selected account has no collectibles but user has dismissed the notice before', () => { + render({ + selectedAddress: ACCOUNT_1, + collectibles: COLLECTIBLES, + collectiblesDetectionNoticeDismissed: true, + }); + expect(screen.queryByText('New! NFT detection')).not.toBeInTheDocument(); + }); + + it('should call setCollectibesDetectionNoticeDismissed when users clicks "X"', () => { + render({ + selectedAddress: ACCOUNT_2, + collectibles: COLLECTIBLES, + }); + expect( + setCollectiblesDetectionNoticeDismissedStub, + ).not.toHaveBeenCalled(); + fireEvent.click( + screen.queryByTestId('collectibles-detection-notice-close'), + ); + expect(setCollectiblesDetectionNoticeDismissedStub).toHaveBeenCalled(); + }); + }); + + describe('Collections', () => { + it('should render the name of the collections and number of collectibles in each collection if current account/chainId combination has collectibles', () => { + render({ + selectedAddress: ACCOUNT_1, + collectibles: COLLECTIBLES, + collectibleContracts: COLLECTIBLES_CONTRACTS, + }); + expect(screen.queryByText('PUNKS (5)')).toBeInTheDocument(); + expect(screen.queryByText('Munks (3)')).toBeInTheDocument(); + }); + it('should not render collections if current account/chainId combination has collectibles', () => { + render({ + selectedAddress: ACCOUNT_2, + collectibles: COLLECTIBLES, + collectibleContracts: COLLECTIBLES_CONTRACTS, + }); + expect(screen.queryByText('PUNKS (5)')).not.toBeInTheDocument(); + expect(screen.queryByText('Munks (3)')).not.toBeInTheDocument(); + }); + }); + describe('Collectibles options', () => { + it('should render a link "Refresh list" when some collectibles are present and collectible auto-detection preference is set to true, which, when clicked calls a method DetectCollectibles', () => { + render({ + selectedAddress: ACCOUNT_1, + collectibles: COLLECTIBLES, + useCollectibleDetection: true, + }); + expect(detectCollectiblesStub).not.toHaveBeenCalled(); + fireEvent.click(screen.queryByText('Refresh list')); + expect(detectCollectiblesStub).toHaveBeenCalled(); + }); + + it('should render a link "Enable Autodetect" when some collectibles are present and collectible auto-detection preference is set to false, which, when clicked sends user to the experimental tab of settings', () => { + render({ + selectedAddress: ACCOUNT_1, + collectibles: COLLECTIBLES, + }); + expect(historyPushMock).toHaveBeenCalledTimes(0); + fireEvent.click(screen.queryByText('Enable Autodetect')); + expect(historyPushMock).toHaveBeenCalledTimes(1); + expect(historyPushMock).toHaveBeenCalledWith(EXPERIMENTAL_ROUTE); + }); + it('should render a link "Import NFTs" when some collectibles are present, which, when clicked calls the passed in onAddNFT method', () => { + const onAddNFTStub = jest.fn(); + render({ + selectedAddress: ACCOUNT_1, + collectibles: COLLECTIBLES, + onAddNFT: onAddNFTStub, + }); + expect(onAddNFTStub).toHaveBeenCalledTimes(0); + fireEvent.click(screen.queryByText('Import NFTs')); + expect(onAddNFTStub).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/ui/components/app/collectibles-tab/index.js b/ui/components/app/collectibles-tab/index.js index a69b09579..fac98327d 100644 --- a/ui/components/app/collectibles-tab/index.js +++ b/ui/components/app/collectibles-tab/index.js @@ -1 +1 @@ -export { default } from './collectibles-tab.component'; +export { default } from './collectibles-tab'; diff --git a/ui/components/app/collectibles-tab/index.scss b/ui/components/app/collectibles-tab/index.scss new file mode 100644 index 000000000..2702e32e7 --- /dev/null +++ b/ui/components/app/collectibles-tab/index.scss @@ -0,0 +1,8 @@ +.collectibles-tab { + &__link { + a { + padding: 4px; + font-size: 1rem; + } + } +} diff --git a/ui/components/app/confirm-page-container/confirm-page-container-content/index.scss b/ui/components/app/confirm-page-container/confirm-page-container-content/index.scss index 2783e08d4..7a0777f98 100644 --- a/ui/components/app/confirm-page-container/confirm-page-container-content/index.scss +++ b/ui/components/app/confirm-page-container/confirm-page-container-content/index.scss @@ -91,7 +91,9 @@ margin-top: auto; } - &__currency-container { + &__currency-container, + &__total-amount, + &__total-value { position: relative; } } diff --git a/ui/components/app/confirm-page-container/confirm-page-container.component.js b/ui/components/app/confirm-page-container/confirm-page-container.component.js index 351a6e37d..dd5ef5ab2 100644 --- a/ui/components/app/confirm-page-container/confirm-page-container.component.js +++ b/ui/components/app/confirm-page-container/confirm-page-container.component.js @@ -258,8 +258,12 @@ export default class ConfirmPageContainer extends Component { transaction={currentTransaction} /> )} - - + {supportsEIP1559V2 && ( + <> + + + + )}
); diff --git a/ui/components/app/connected-sites-list/connected-sites-list.component.js b/ui/components/app/connected-sites-list/connected-sites-list.component.js index 1e9513a58..58d04ca0f 100644 --- a/ui/components/app/connected-sites-list/connected-sites-list.component.js +++ b/ui/components/app/connected-sites-list/connected-sites-list.component.js @@ -1,7 +1,7 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import SiteIcon from '../../ui/site-icon'; -import { stripHttpSchemes } from '../../../helpers/utils/util'; +import { stripHttpsSchemeWithoutPort } from '../../../helpers/utils/util'; export default class ConnectedSitesList extends Component { static contextTypes = { @@ -9,42 +9,40 @@ export default class ConnectedSitesList extends Component { }; static propTypes = { - connectedDomains: PropTypes.arrayOf( + connectedSubjects: PropTypes.arrayOf( PropTypes.shape({ name: PropTypes.string, - icon: PropTypes.string, + iconUrl: PropTypes.string, origin: PropTypes.string, - host: PropTypes.string, }), ).isRequired, onDisconnect: PropTypes.func.isRequired, - domainHostCount: PropTypes.objectOf(PropTypes.number).isRequired, }; render() { - const { connectedDomains, onDisconnect } = this.props; + const { connectedSubjects, onDisconnect } = this.props; const { t } = this.context; return (
- {connectedDomains.map((domain) => ( + {connectedSubjects.map((subject) => (
-
- +
+ - {this.getDomainDisplayName(domain)} + {this.getSubjectDisplayName(subject)}
onDisconnect(domain.origin)} + onClick={() => onDisconnect(subject.origin)} />
))} @@ -52,13 +50,12 @@ export default class ConnectedSitesList extends Component { ); } - getDomainDisplayName(domain) { - if (domain.extensionId) { + getSubjectDisplayName(subject) { + if (subject.extensionId) { return this.context.t('externalExtension'); } - return this.props.domainHostCount[domain.host] > 1 - ? domain.origin - : stripHttpSchemes(domain.origin); + // We strip https schemes only, and only if the URL has no port. + return stripHttpsSchemeWithoutPort(subject.origin); } } diff --git a/ui/components/app/connected-sites-list/index.scss b/ui/components/app/connected-sites-list/index.scss index 7e4a665c2..a46b2c13e 100644 --- a/ui/components/app/connected-sites-list/index.scss +++ b/ui/components/app/connected-sites-list/index.scss @@ -15,7 +15,7 @@ padding: 16px 24px; } - &__domain-info { + &__subject-info { @include H7; display: flex; @@ -24,7 +24,7 @@ min-width: 0; } - &__domain-name { + &__subject-name { max-width: 215px; white-space: nowrap; overflow: hidden; diff --git a/ui/components/app/connected-status-indicator/connected-status-indicator.js b/ui/components/app/connected-status-indicator/connected-status-indicator.js index b8596d58f..969cb401c 100644 --- a/ui/components/app/connected-status-indicator/connected-status-indicator.js +++ b/ui/components/app/connected-status-indicator/connected-status-indicator.js @@ -11,7 +11,7 @@ import ColorIndicator from '../../ui/color-indicator'; import { COLORS } from '../../../helpers/constants/design-system'; import { useI18nContext } from '../../../hooks/useI18nContext'; import { - getAddressConnectedDomainMap, + getAddressConnectedSubjectMap, getOriginOfCurrentTab, getSelectedAddress, } from '../../../selectors'; @@ -20,17 +20,17 @@ export default function ConnectedStatusIndicator({ onClick }) { const t = useI18nContext(); const selectedAddress = useSelector(getSelectedAddress); - const addressConnectedDomainMap = useSelector(getAddressConnectedDomainMap); + const addressConnectedSubjectMap = useSelector(getAddressConnectedSubjectMap); const originOfCurrentTab = useSelector(getOriginOfCurrentTab); - const selectedAddressDomainMap = addressConnectedDomainMap[selectedAddress]; + const selectedAddressSubjectMap = addressConnectedSubjectMap[selectedAddress]; const currentTabIsConnectedToSelectedAddress = Boolean( - selectedAddressDomainMap && selectedAddressDomainMap[originOfCurrentTab], + selectedAddressSubjectMap && selectedAddressSubjectMap[originOfCurrentTab], ); let status; if (currentTabIsConnectedToSelectedAddress) { status = STATUS_CONNECTED; - } else if (findKey(addressConnectedDomainMap, originOfCurrentTab)) { + } else if (findKey(addressConnectedSubjectMap, originOfCurrentTab)) { status = STATUS_CONNECTED_TO_ANOTHER_ACCOUNT; } else { status = STATUS_NOT_CONNECTED; diff --git a/ui/components/app/edit-gas-display-education/edit-gas-display-education.stories.js b/ui/components/app/edit-gas-display-education/edit-gas-display-education.stories.js index f53cfd344..04ee10fbb 100644 --- a/ui/components/app/edit-gas-display-education/edit-gas-display-education.stories.js +++ b/ui/components/app/edit-gas-display-education/edit-gas-display-education.stories.js @@ -2,14 +2,16 @@ import React from 'react'; import EditGasDisplayEducation from '.'; export default { - title: 'Edit Gas Display', + title: 'Components/App/EditGasDisplayEducation', id: __filename, }; -export const basic = () => { +export const DefaultStory = () => { return (
); }; + +DefaultStory.storyName = 'Default'; diff --git a/ui/components/app/edit-gas-display/edit-gas-display.stories.js b/ui/components/app/edit-gas-display/edit-gas-display.stories.js index 14724468f..9de3300f2 100644 --- a/ui/components/app/edit-gas-display/edit-gas-display.stories.js +++ b/ui/components/app/edit-gas-display/edit-gas-display.stories.js @@ -2,11 +2,11 @@ import React from 'react'; import EditGasDisplay from '.'; export default { - title: 'Edit Gas Display', + title: 'Components/App/EditGasDisplay', id: __filename, }; -export const basic = () => { +export const DefaultStory = () => { return (
@@ -14,7 +14,9 @@ export const basic = () => { ); }; -export const withEducation = () => { +DefaultStory.storyName = 'Default'; + +export const WithEducation = () => { return (
@@ -22,7 +24,7 @@ export const withEducation = () => { ); }; -export const withDappSuggestedGas = () => { +export const WithDappSuggestedGas = () => { return (
+ + {estimateUsed === 'custom' && ( + + )} + {estimateUsed === 'dappSuggested' && ( + + + {t('dappSuggestedTooltip', [transaction.origin])} + + + {t('maxBaseFee')} + {maxFeePerGas} + + + {t('maxPriorityFee')} + {maxPriorityFeePerGas} + + + {t('gasLimit')} + {gasLimit} + +
+ } + position="top" + /> + )} +
+ ); +} + +EditGasFeeButton.propTypes = { + userAcknowledgedGasMissing: PropTypes.bool, +}; diff --git a/ui/components/app/edit-gas-fee-button/edit-gas-fee-button.test.js b/ui/components/app/edit-gas-fee-button/edit-gas-fee-button.test.js new file mode 100644 index 000000000..3a17ea070 --- /dev/null +++ b/ui/components/app/edit-gas-fee-button/edit-gas-fee-button.test.js @@ -0,0 +1,152 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; + +import { + EDIT_GAS_MODES, + GAS_ESTIMATE_TYPES, + PRIORITY_LEVELS, +} from '../../../../shared/constants/gas'; +import { TRANSACTION_ENVELOPE_TYPES } from '../../../../shared/constants/transaction'; + +import { GasFeeContextProvider } from '../../../contexts/gasFee'; +import { renderWithProvider } from '../../../../test/jest'; +import mockEstimates from '../../../../test/data/mock-estimates.json'; +import mockState from '../../../../test/data/mock-state.json'; +import configureStore from '../../../store/store'; + +import EditGasFeeButton from './edit-gas-fee-button'; + +jest.mock('../../../store/actions', () => ({ + disconnectGasFeeEstimatePoller: jest.fn(), + getGasFeeEstimatesAndStartPolling: jest + .fn() + .mockImplementation(() => Promise.resolve()), + addPollingTokenToAppState: jest.fn(), +})); + +const render = ({ componentProps, contextProps } = {}) => { + const store = configureStore({ + metamask: { + ...mockState.metamask, + accounts: { + [mockState.metamask.selectedAddress]: { + address: mockState.metamask.selectedAddress, + balance: '0x1F4', + }, + }, + gasFeeEstimates: mockEstimates[GAS_ESTIMATE_TYPES.FEE_MARKET], + }, + }); + + return renderWithProvider( + + + , + store, + ); +}; + +describe('EditGasFeeButton', () => { + 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({ contextProps: { transaction: { userFeeLevel: 'low' } } }); + expect(screen.queryByText('🐢')).toBeInTheDocument(); + expect(screen.queryByText('Low')).toBeInTheDocument(); + }); + + it('should render edit link with text market if medium gas estimates are selected', () => { + render({ contextProps: { 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({ contextProps: { 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({ + contextProps: { + transaction: { + userFeeLevel: PRIORITY_LEVELS.DAPP_SUGGESTED, + 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 swap suggested if high gas estimates are selected for swaps', () => { + render({ + contextProps: { + transaction: { userFeeLevel: 'high' }, + editGasMode: EDIT_GAS_MODES.SWAPS, + }, + }); + expect(screen.queryByText('🔄')).toBeInTheDocument(); + expect(screen.queryByText('Swap suggested')).toBeInTheDocument(); + }); + + it('should render edit link with text advance if custom gas estimates are used', () => { + render({ + contextProps: { + defaultEstimateToUse: 'custom', + }, + }); + expect(screen.queryByText('⚙')).toBeInTheDocument(); + expect(screen.queryByText('Advanced')).toBeInTheDocument(); + expect(screen.queryByText('Edit')).toBeInTheDocument(); + }); + + it('should not render edit link if transaction has simulation error and prop userAcknowledgedGasMissing is false', () => { + render({ + contextProps: { + transaction: { + simulationFails: true, + userFeeLevel: 'low', + }, + }, + componentProps: { userAcknowledgedGasMissing: false }, + }); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(screen.queryByText('Low')).not.toBeInTheDocument(); + }); + + it('should render edit link if userAcknowledgedGasMissing is true even if transaction has simulation error', () => { + render({ + contextProps: { + transaction: { + simulationFails: true, + userFeeLevel: 'low', + }, + }, + componentProps: { userAcknowledgedGasMissing: true }, + }); + expect(screen.queryByRole('button')).toBeInTheDocument(); + expect(screen.queryByText('Low')).toBeInTheDocument(); + }); + + it('should render null for legacy transactions', () => { + const { container } = render({ + contextProps: { + transaction: { + userFeeLevel: 'low', + txParams: { type: TRANSACTION_ENVELOPE_TYPES.LEGACY }, + }, + }, + }); + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/ui/components/app/edit-gas-fee-button/index.js b/ui/components/app/edit-gas-fee-button/index.js new file mode 100644 index 000000000..0dbd11e85 --- /dev/null +++ b/ui/components/app/edit-gas-fee-button/index.js @@ -0,0 +1 @@ +export { default } from './edit-gas-fee-button'; diff --git a/ui/components/app/edit-gas-fee-button/index.scss b/ui/components/app/edit-gas-fee-button/index.scss new file mode 100644 index 000000000..fa5ccd97e --- /dev/null +++ b/ui/components/app/edit-gas-fee-button/index.scss @@ -0,0 +1,48 @@ +.edit-gas-fee-button { + display: flex; + align-items: baseline; + justify-content: flex-end; + + 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: 16px; + } + + &__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%; + } + } +} diff --git a/ui/components/app/edit-gas-fee-popover/edit-gas-fee-popover.js b/ui/components/app/edit-gas-fee-popover/edit-gas-fee-popover.js index 1f6037c26..fca821623 100644 --- a/ui/components/app/edit-gas-fee-popover/edit-gas-fee-popover.js +++ b/ui/components/app/edit-gas-fee-popover/edit-gas-fee-popover.js @@ -1,22 +1,24 @@ import React from 'react'; -import { PRIORITY_LEVELS } from '../../../../shared/constants/gas'; +import { + EDIT_GAS_MODES, + PRIORITY_LEVELS, +} from '../../../../shared/constants/gas'; import { useI18nContext } from '../../../hooks/useI18nContext'; import { useTransactionModalContext } from '../../../contexts/transaction-modal'; import ErrorMessage from '../../ui/error-message'; import I18nValue from '../../ui/i18n-value'; -import LoadingHeartBeat from '../../ui/loading-heartbeat'; import Popover from '../../ui/popover'; import Typography from '../../ui/typography/typography'; -import { COLORS } from '../../../helpers/constants/design-system'; +import { COLORS, TYPOGRAPHY } from '../../../helpers/constants/design-system'; import { INSUFFICIENT_FUNDS_ERROR_KEY } from '../../../helpers/constants/error-keys'; import { useGasFeeContext } from '../../../contexts/gasFee'; import EditGasItem from './edit-gas-item'; -import NetworkStatus from './network-status'; +import NetworkStatistics from './network-statistics'; const EditGasFeePopover = () => { - const { balanceError } = useGasFeeContext(); + const { balanceError, editGasMode } = useGasFeeContext(); const t = useI18nContext(); const { closeModal, currentModal } = useTransactionModalContext(); @@ -29,7 +31,6 @@ const EditGasFeePopover = () => { className="edit-gas-fee-popover" > <> - {process.env.IN_TEST === 'true' ? null : }
{balanceError && ( @@ -40,24 +41,31 @@ const EditGasFeePopover = () => { - + {editGasMode !== EDIT_GAS_MODES.SWAPS && ( + + )}
- + {editGasMode !== EDIT_GAS_MODES.SWAPS && ( + + )}
- + {editGasMode !== EDIT_GAS_MODES.SWAPS && ( + + )} - + { +const render = ({ txProps, contextProps } = {}) => { const store = configureStore({ metamask: { nativeCurrency: ETH, @@ -66,6 +67,7 @@ const render = (txProps) => { return renderWithProvider( , @@ -75,7 +77,7 @@ const render = (txProps) => { describe('EditGasFeePopover', () => { it('should renders low / medium / high options', () => { - render(); + render({ txProps: { dappSuggestedGasFees: {} } }); expect(screen.queryByText('🐢')).toBeInTheDocument(); expect(screen.queryByText('🦊')).toBeInTheDocument(); @@ -103,12 +105,40 @@ describe('EditGasFeePopover', () => { }); it('should not show insufficient balance message if transaction value is less than balance', () => { - render({ userFeeLevel: 'high', txParams: { value: '0x64' } }); + render({ txProps: { userFeeLevel: 'high', txParams: { value: '0x64' } } }); expect(screen.queryByText('Insufficient funds.')).not.toBeInTheDocument(); }); it('should show insufficient balance message if transaction value is more than balance', () => { - render({ userFeeLevel: 'high', txParams: { value: '0x5208' } }); + render({ + txProps: { userFeeLevel: 'high', txParams: { value: '0x5208' } }, + }); expect(screen.queryByText('Insufficient funds.')).toBeInTheDocument(); }); + + it('should not show low, aggressive and dapp-suggested options for swap', () => { + render({ + contextProps: { editGasMode: EDIT_GAS_MODES.SWAPS }, + }); + expect(screen.queryByText('🐢')).not.toBeInTheDocument(); + expect(screen.queryByText('🦊')).toBeInTheDocument(); + expect(screen.queryByText('🦍')).not.toBeInTheDocument(); + expect(screen.queryByText('🌐')).not.toBeInTheDocument(); + expect(screen.queryByText('🔄')).toBeInTheDocument(); + expect(screen.queryByText('⚙')).toBeInTheDocument(); + expect(screen.queryByText('Low')).not.toBeInTheDocument(); + expect(screen.queryByText('Market')).toBeInTheDocument(); + expect(screen.queryByText('Aggressive')).not.toBeInTheDocument(); + expect(screen.queryByText('Site')).not.toBeInTheDocument(); + expect(screen.queryByText('Swap suggested')).toBeInTheDocument(); + expect(screen.queryByText('Advanced')).toBeInTheDocument(); + }); + + it('should not show time estimates for swaps', () => { + render({ + contextProps: { editGasMode: EDIT_GAS_MODES.SWAPS }, + }); + expect(screen.queryByText('Time')).not.toBeInTheDocument(); + expect(screen.queryByText('Max fee')).toBeInTheDocument(); + }); }); diff --git a/ui/components/app/edit-gas-fee-popover/edit-gas-item/edit-gas-item.js b/ui/components/app/edit-gas-fee-popover/edit-gas-item/edit-gas-item.js index 7cd7aa281..64ee11b88 100644 --- a/ui/components/app/edit-gas-fee-popover/edit-gas-item/edit-gas-item.js +++ b/ui/components/app/edit-gas-fee-popover/edit-gas-item/edit-gas-item.js @@ -4,7 +4,10 @@ import classNames from 'classnames'; import { useSelector } from 'react-redux'; import { getMaximumGasTotalInHexWei } from '../../../../../shared/modules/gas.utils'; -import { PRIORITY_LEVELS } from '../../../../../shared/constants/gas'; +import { + EDIT_GAS_MODES, + PRIORITY_LEVELS, +} from '../../../../../shared/constants/gas'; import { PRIORITY_LEVEL_ICON_MAP } from '../../../../helpers/constants/gas'; import { PRIMARY } from '../../../../helpers/constants/common'; import { @@ -12,30 +15,34 @@ import { decimalToHex, hexWEIToDecGWEI, } from '../../../../helpers/utils/conversions.util'; +import LoadingHeartBeat from '../../../ui/loading-heartbeat'; import { getAdvancedGasFeeValues } from '../../../../selectors'; import { toHumanReadableTime } from '../../../../helpers/utils/util'; import { useGasFeeContext } from '../../../../contexts/gasFee'; import { useI18nContext } from '../../../../hooks/useI18nContext'; import { useTransactionModalContext } from '../../../../contexts/transaction-modal'; import I18nValue from '../../../ui/i18n-value'; -import InfoTooltip from '../../../ui/info-tooltip'; import UserPreferencedCurrencyDisplay from '../../user-preferenced-currency-display'; +import EditGasToolTip from '../edit-gas-tooltip/edit-gas-tooltip'; +import InfoTooltip from '../../../ui/info-tooltip'; import { useCustomTimeEstimate } from './useCustomTimeEstimate'; const EditGasItem = ({ priorityLevel }) => { const { + editGasMode, estimateUsed, gasFeeEstimates, gasLimit, maxFeePerGas: maxFeePerGasValue, maxPriorityFeePerGas: maxPriorityFeePerGasValue, updateTransactionUsingGasFeeEstimates, - transaction: { dappSuggestedGasFees }, + transaction, } = useGasFeeContext(); const t = useI18nContext(); const advancedGasFeeValues = useSelector(getAdvancedGasFeeValues); const { closeModal, openModal } = useTransactionModalContext(); + const { dappSuggestedGasFees } = transaction; let maxFeePerGas; let maxPriorityFeePerGas; @@ -43,13 +50,17 @@ const EditGasItem = ({ priorityLevel }) => { if (gasFeeEstimates?.[priorityLevel]) { maxFeePerGas = gasFeeEstimates[priorityLevel].suggestedMaxFeePerGas; + maxPriorityFeePerGas = + gasFeeEstimates[priorityLevel].suggestedMaxPriorityFeePerGas; } else if ( priorityLevel === PRIORITY_LEVELS.DAPP_SUGGESTED && dappSuggestedGasFees ) { - maxFeePerGas = hexWEIToDecGWEI(dappSuggestedGasFees.maxFeePerGas); + maxFeePerGas = hexWEIToDecGWEI( + dappSuggestedGasFees.maxFeePerGas || dappSuggestedGasFees.gasPrice, + ); maxPriorityFeePerGas = hexWEIToDecGWEI( - dappSuggestedGasFees.maxPriorityFeePerGas, + dappSuggestedGasFees.maxPriorityFeePerGas || maxFeePerGas, ); } else if (priorityLevel === PRIORITY_LEVELS.CUSTOM) { if (estimateUsed === PRIORITY_LEVELS.CUSTOM) { @@ -94,18 +105,30 @@ const EditGasItem = ({ priorityLevel }) => { } }; + if ( + priorityLevel === PRIORITY_LEVELS.DAPP_SUGGESTED && + !dappSuggestedGasFees + ) { + return null; + } + + let icon = priorityLevel; + let title = priorityLevel; + if (priorityLevel === PRIORITY_LEVELS.DAPP_SUGGESTED) { + title = 'dappSuggestedShortLabel'; + } else if ( + priorityLevel === PRIORITY_LEVELS.HIGH && + editGasMode === EDIT_GAS_MODES.SWAPS + ) { + icon = 'swapSuggested'; + title = 'swapSuggested'; + } + return ( ); diff --git a/ui/components/app/edit-gas-fee-popover/edit-gas-item/edit-gas-item.test.js b/ui/components/app/edit-gas-fee-popover/edit-gas-item/edit-gas-item.test.js index cd95b654f..2cf787a6c 100644 --- a/ui/components/app/edit-gas-fee-popover/edit-gas-item/edit-gas-item.test.js +++ b/ui/components/app/edit-gas-fee-popover/edit-gas-item/edit-gas-item.test.js @@ -1,6 +1,7 @@ import React from 'react'; import { screen } from '@testing-library/react'; +import { EDIT_GAS_MODES } from '../../../../../shared/constants/gas'; import { renderWithProvider } from '../../../../../test/lib/render-helpers'; import { ETH } from '../../../../helpers/constants/common'; import configureStore from '../../../../store/store'; @@ -46,7 +47,11 @@ const DAPP_SUGGESTED_ESTIMATE = { maxPriorityFeePerGas: '0x59682f00', }; -const renderComponent = (componentProps, transactionProps) => { +const renderComponent = ({ + componentProps, + transactionProps, + contextProps, +} = {}) => { const store = configureStore({ metamask: { nativeCurrency: ETH, @@ -71,6 +76,7 @@ const renderComponent = (componentProps, transactionProps) => { return renderWithProvider( , @@ -80,7 +86,7 @@ const renderComponent = (componentProps, transactionProps) => { describe('EditGasItem', () => { it('should renders low gas estimate option for priorityLevel low', () => { - renderComponent({ priorityLevel: 'low' }); + renderComponent({ componentProps: { priorityLevel: 'low' } }); expect(screen.queryByRole('button', { name: 'low' })).toBeInTheDocument(); expect(screen.queryByText('🐢')).toBeInTheDocument(); expect(screen.queryByText('Low')).toBeInTheDocument(); @@ -89,7 +95,7 @@ describe('EditGasItem', () => { }); it('should renders market gas estimate option for priorityLevel medium', () => { - renderComponent({ priorityLevel: 'medium' }); + renderComponent({ componentProps: { priorityLevel: 'medium' } }); expect( screen.queryByRole('button', { name: 'medium' }), ).toBeInTheDocument(); @@ -100,7 +106,7 @@ describe('EditGasItem', () => { }); it('should renders aggressive gas estimate option for priorityLevel high', () => { - renderComponent({ priorityLevel: 'high' }); + renderComponent({ componentProps: { priorityLevel: 'high' } }); expect(screen.queryByRole('button', { name: 'high' })).toBeInTheDocument(); expect(screen.queryByText('🦍')).toBeInTheDocument(); expect(screen.queryByText('Aggressive')).toBeInTheDocument(); @@ -108,18 +114,33 @@ describe('EditGasItem', () => { expect(screen.queryByTitle('0.0021 ETH')).toBeInTheDocument(); }); + it('should render priorityLevel high as "Swap suggested" for swaps', () => { + renderComponent({ + componentProps: { priorityLevel: 'high' }, + contextProps: { editGasMode: EDIT_GAS_MODES.SWAPS }, + }); + expect(screen.queryByRole('button', { name: 'high' })).toBeInTheDocument(); + expect(screen.queryByText('🔄')).toBeInTheDocument(); + expect(screen.queryByText('Swap suggested')).toBeInTheDocument(); + expect(screen.queryByText('15 sec')).not.toBeInTheDocument(); + expect(screen.queryByTitle('0.0021 ETH')).toBeInTheDocument(); + }); + it('should highlight option is priorityLevel is currently selected', () => { - renderComponent({ priorityLevel: 'high' }, { userFeeLevel: 'high' }); + renderComponent({ + componentProps: { priorityLevel: 'high' }, + transactionProps: { userFeeLevel: 'high' }, + }); expect( - document.getElementsByClassName('edit-gas-item-selected'), + 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 }, - ); + renderComponent({ + componentProps: { priorityLevel: 'dappSuggested' }, + transactionProps: { dappSuggestedGasFees: DAPP_SUGGESTED_ESTIMATE }, + }); expect( screen.queryByRole('button', { name: 'dappSuggested' }), ).toBeInTheDocument(); @@ -128,15 +149,11 @@ describe('EditGasItem', () => { 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' }, { userFeeLevel: 'high' }); + renderComponent({ + componentProps: { priorityLevel: 'custom' }, + transactionProps: { userFeeLevel: 'high' }, + }); expect( screen.queryByRole('button', { name: 'custom' }), ).toBeInTheDocument(); diff --git a/ui/components/app/edit-gas-fee-popover/edit-gas-item/index.scss b/ui/components/app/edit-gas-fee-popover/edit-gas-item/index.scss index f29b80fce..6b0a338fd 100644 --- a/ui/components/app/edit-gas-fee-popover/edit-gas-item/index.scss +++ b/ui/components/app/edit-gas-fee-popover/edit-gas-item/index.scss @@ -11,12 +11,12 @@ height: 32px; width: 100%; - &-selected { - background-color: $ui-1; + &:hover { + background-color: $primary-2; } - &-disabled { - cursor: default; + &--selected { + background-color: $ui-1; } &__name { @@ -25,21 +25,29 @@ color: $ui-black; font-size: 12px; font-weight: bold; - width: 40%; + white-space: nowrap; + width: 36%; } &__icon { + font-size: 20px; + line-height: 1; margin-right: 4px; &-custom { - font-size: 20px; - line-height: 0; + font-size: 32px; + line-height: 1; } } + &__maxfee { + position: relative; + } + &__time-estimate { display: inline-block; - width: 20%; + text-align: left; + width: 24%; } &__fee-estimate { diff --git a/ui/components/app/edit-gas-fee-popover/edit-gas-tooltip/edit-gas-tooltip.js b/ui/components/app/edit-gas-fee-popover/edit-gas-tooltip/edit-gas-tooltip.js new file mode 100644 index 000000000..b26ee0035 --- /dev/null +++ b/ui/components/app/edit-gas-fee-popover/edit-gas-tooltip/edit-gas-tooltip.js @@ -0,0 +1,152 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + EDIT_GAS_MODES, + PRIORITY_LEVELS, +} from '../../../../../shared/constants/gas'; +import { + COLORS, + FONT_WEIGHT, + TYPOGRAPHY, +} from '../../../../helpers/constants/design-system'; +import Typography from '../../../ui/typography'; + +const EditGasToolTip = ({ + gasLimit, + priorityLevel, + // maxFeePerGas & maxPriorityFeePerGas are derived from conditional logic + // related to the source of the estimates. We pass these values from the + // the parent component (edit-gas-item) rather than recalculate them + maxFeePerGas, + maxPriorityFeePerGas, + editGasMode, + transaction, + t, +}) => { + const toolTipMessage = () => { + switch (priorityLevel) { + case PRIORITY_LEVELS.LOW: + return t('lowGasSettingToolTipMessage', [ + + {t('low')} + , + ]); + case PRIORITY_LEVELS.MEDIUM: + return t('mediumGasSettingToolTipMessage', [ + + {t('medium')} + , + ]); + case PRIORITY_LEVELS.HIGH: + if (editGasMode === EDIT_GAS_MODES.SWAPS) { + return t('swapSuggestedGasSettingToolTipMessage'); + } + return t('highGasSettingToolTipMessage', [ + + {t('high')} + , + ]); + case PRIORITY_LEVELS.CUSTOM: + return t('customGasSettingToolTipMessage', [ + + {t('custom')} + , + ]); + case PRIORITY_LEVELS.DAPP_SUGGESTED: + return transaction?.origin + ? t('dappSuggestedGasSettingToolTipMessage', [ + {transaction?.origin}, + ]) + : null; + default: + return ''; + } + }; + return ( +
+ {priorityLevel !== PRIORITY_LEVELS.CUSTOM && + priorityLevel !== PRIORITY_LEVELS.DAPP_SUGGESTED && + !( + priorityLevel === PRIORITY_LEVELS.HIGH && + editGasMode === EDIT_GAS_MODES.SWAPS + ) ? ( + + ) : null} + {priorityLevel === PRIORITY_LEVELS.HIGH && + editGasMode !== EDIT_GAS_MODES.SWAPS ? ( +
+ + {t('highGasSettingToolTipDialog')} + +
+ ) : null} +
+ {toolTipMessage()} +
+ {priorityLevel === PRIORITY_LEVELS.CUSTOM ? null : ( +
+
+ + {t('maxBaseFee')} + + + {maxFeePerGas} + +
+
+ + {t('priorityFeeProperCase')} + + + {maxPriorityFeePerGas} + +
+
+ + {t('gasLimit')} + + + {gasLimit} + +
+
+ )} +
+ ); +}; + +EditGasToolTip.propTypes = { + priorityLevel: PropTypes.string, + maxFeePerGas: PropTypes.string, + maxPriorityFeePerGas: PropTypes.string, + t: PropTypes.func, + editGasMode: PropTypes.string, + gasLimit: PropTypes.number, + transaction: PropTypes.object, +}; + +export default EditGasToolTip; diff --git a/ui/components/app/edit-gas-fee-popover/edit-gas-tooltip/edit-gas-tooltip.test.js b/ui/components/app/edit-gas-fee-popover/edit-gas-tooltip/edit-gas-tooltip.test.js new file mode 100644 index 000000000..e505b7d62 --- /dev/null +++ b/ui/components/app/edit-gas-fee-popover/edit-gas-tooltip/edit-gas-tooltip.test.js @@ -0,0 +1,90 @@ +import React from 'react'; +import configureStore from '../../../../store/store'; +import { renderWithProvider } from '../../../../../test/jest'; +import { GasFeeContextProvider } from '../../../../contexts/gasFee'; +import EditGasToolTip from './edit-gas-tooltip'; + +jest.mock('../../../../store/actions', () => ({ + disconnectGasFeeEstimatePoller: jest.fn(), + getGasFeeEstimatesAndStartPolling: jest + .fn() + .mockImplementation(() => Promise.resolve()), + addPollingTokenToAppState: jest.fn(), + getGasFeeTimeEstimate: jest + .fn() + .mockImplementation(() => Promise.resolve('unknown')), +})); + +const LOW_GAS_OPTION = { + maxFeePerGas: '2.010203381', + maxPriorityFeePerGas: '1.20004164', +}; + +const MEDIUM_GAS_OPTION = { + maxFeePerGas: '2.383812808', + maxPriorityFeePerGas: '1.5', +}; + +const HIGH_GAS_OPTION = { + maxFeePerGas: '2.920638342', + maxPriorityFeePerGas: '2', +}; + +const renderComponent = (componentProps) => { + const mockStore = { + metamask: { + provider: {}, + cachedBalances: {}, + accounts: { + '0xAddress': { + address: '0xAddress', + balance: '0x176e5b6f173ebe66', + }, + }, + selectedAddress: '0xAddress', + featureFlags: { advancedInlineGas: true }, + }, + }; + + const store = configureStore(mockStore); + + return renderWithProvider( + + + , + store, + ); +}; + +describe('EditGasToolTip', () => { + it('should render correct values for priorityLevel low', () => { + const { queryByText } = renderComponent({ + priorityLevel: 'low', + ...LOW_GAS_OPTION, + }); + + expect(queryByText('2.010203381')).toBeInTheDocument(); + expect(queryByText('1.20004164')).toBeInTheDocument(); + expect(queryByText('21000')).toBeInTheDocument(); + }); + + it('should render correct values for priorityLevel medium', () => { + const { queryByText } = renderComponent({ + priorityLevel: 'medium', + ...MEDIUM_GAS_OPTION, + }); + expect(queryByText('2.383812808')).toBeInTheDocument(); + expect(queryByText('1.5')).toBeInTheDocument(); + expect(queryByText('21000')).toBeInTheDocument(); + }); + + it('should render correct values for priorityLevel high', () => { + const { queryByText } = renderComponent({ + priorityLevel: 'high', + ...HIGH_GAS_OPTION, + }); + expect(queryByText('2.920638342')).toBeInTheDocument(); + expect(queryByText('2')).toBeInTheDocument(); + expect(queryByText('21000')).toBeInTheDocument(); + }); +}); diff --git a/ui/components/app/edit-gas-fee-popover/edit-gas-tooltip/index.scss b/ui/components/app/edit-gas-fee-popover/edit-gas-tooltip/index.scss new file mode 100644 index 000000000..d3d4bc9f0 --- /dev/null +++ b/ui/components/app/edit-gas-fee-popover/edit-gas-tooltip/index.scss @@ -0,0 +1,53 @@ +.edit-gas-tooltip { + display: inline-block; + text-align: right; + width: 10%; + + &__container { + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + width: 100%; + height: 100%; + + img { + margin-bottom: 8px; + } + + &__message { + width: 100%; + } + + &__dialog { + background-color: $Orange-500; + border-radius: 30px; + margin: 4px 0; + text-align: center; + padding: 4px; + } + + &__label { + width: 50%; + } + + &__value { + width: 50%; + } + + p { + margin-bottom: 0 !important; + } + + &__values { + width: 100%; + margin-top: 8px; + + div { + display: flex; + flex-direction: row; + text-align: left; + } + } + } +} diff --git a/ui/components/app/edit-gas-fee-popover/index.scss b/ui/components/app/edit-gas-fee-popover/index.scss index 581cb5f6b..0b403b78b 100644 --- a/ui/components/app/edit-gas-fee-popover/index.scss +++ b/ui/components/app/edit-gas-fee-popover/index.scss @@ -21,12 +21,12 @@ &-option { display: inline-block; - width: 40%; + width: 36%; } &-time { display: inline-block; - width: 20%; + width: 24%; } &-max-fee { diff --git a/ui/components/app/edit-gas-fee-popover/network-statistics/index.js b/ui/components/app/edit-gas-fee-popover/network-statistics/index.js new file mode 100644 index 000000000..ee0d2039a --- /dev/null +++ b/ui/components/app/edit-gas-fee-popover/network-statistics/index.js @@ -0,0 +1 @@ +export { default } from './network-statistics'; diff --git a/ui/components/app/edit-gas-fee-popover/network-status/index.scss b/ui/components/app/edit-gas-fee-popover/network-statistics/index.scss similarity index 90% rename from ui/components/app/edit-gas-fee-popover/network-status/index.scss rename to ui/components/app/edit-gas-fee-popover/network-statistics/index.scss index db6d37299..2ff3c1dce 100644 --- a/ui/components/app/edit-gas-fee-popover/network-status/index.scss +++ b/ui/components/app/edit-gas-fee-popover/network-statistics/index.scss @@ -1,4 +1,4 @@ -.network-status { +.network-statistics { margin: 24px 0 12px; &__info { @@ -38,4 +38,8 @@ } } } + + &__tooltip-label { + font-weight: 700; + } } diff --git a/ui/components/app/edit-gas-fee-popover/network-statistics/network-statistics.js b/ui/components/app/edit-gas-fee-popover/network-statistics/network-statistics.js new file mode 100644 index 000000000..d6508f3e9 --- /dev/null +++ b/ui/components/app/edit-gas-fee-popover/network-statistics/network-statistics.js @@ -0,0 +1,58 @@ +import React from 'react'; + +import { + COLORS, + FONT_WEIGHT, + TYPOGRAPHY, +} from '../../../../helpers/constants/design-system'; +import { useGasFeeContext } from '../../../../contexts/gasFee'; +import I18nValue from '../../../ui/i18n-value'; +import Typography from '../../../ui/typography/typography'; + +import { BaseFeeTooltip, PriorityFeeTooltip } from './tooltips'; +import StatusSlider from './status-slider'; + +const NetworkStatistics = () => { + const { gasFeeEstimates } = useGasFeeContext(); + + return ( +
+ + + +
+
+ + + {gasFeeEstimates?.estimatedBaseFee && + `${gasFeeEstimates?.estimatedBaseFee} GWEI`} + + + + + +
+
+
+ + 0.5 - 22 GWEI + + + + +
+
+
+ +
+
+
+ ); +}; + +export default NetworkStatistics; diff --git a/ui/components/app/edit-gas-fee-popover/network-status/network-status.test.js b/ui/components/app/edit-gas-fee-popover/network-statistics/network-statistics.test.js similarity index 93% rename from ui/components/app/edit-gas-fee-popover/network-status/network-status.test.js rename to ui/components/app/edit-gas-fee-popover/network-statistics/network-statistics.test.js index 7981b5a72..6ad2d5574 100644 --- a/ui/components/app/edit-gas-fee-popover/network-status/network-status.test.js +++ b/ui/components/app/edit-gas-fee-popover/network-statistics/network-statistics.test.js @@ -6,7 +6,7 @@ import { ETH } from '../../../../helpers/constants/common'; import { GasFeeContextProvider } from '../../../../contexts/gasFee'; import configureStore from '../../../../store/store'; -import NetworkStatus from './network-status'; +import NetworkStatistics from './network-statistics'; jest.mock('../../../../store/actions', () => ({ disconnectGasFeeEstimatePoller: jest.fn(), @@ -44,13 +44,13 @@ const renderComponent = (props) => { return renderWithProvider( - + , store, ); }; -describe('NetworkStatus', () => { +describe('NetworkStatistics', () => { it('should renders labels', () => { renderComponent(); expect(screen.queryByText('Base fee')).toBeInTheDocument(); diff --git a/ui/components/app/edit-gas-fee-popover/network-status/status-slider/index.js b/ui/components/app/edit-gas-fee-popover/network-statistics/status-slider/index.js similarity index 100% rename from ui/components/app/edit-gas-fee-popover/network-status/status-slider/index.js rename to ui/components/app/edit-gas-fee-popover/network-statistics/status-slider/index.js diff --git a/ui/components/app/edit-gas-fee-popover/network-status/status-slider/index.scss b/ui/components/app/edit-gas-fee-popover/network-statistics/status-slider/index.scss similarity index 87% rename from ui/components/app/edit-gas-fee-popover/network-status/status-slider/index.scss rename to ui/components/app/edit-gas-fee-popover/network-statistics/status-slider/index.scss index 2ae4daf0c..47626d64c 100644 --- a/ui/components/app/edit-gas-fee-popover/network-status/status-slider/index.scss +++ b/ui/components/app/edit-gas-fee-popover/network-statistics/status-slider/index.scss @@ -1,9 +1,8 @@ .status-slider { display: flex; flex-direction: column; - align-items: center; justify-content: center; - width: 55%; + width: 56px; &__line { background-image: linear-gradient(to right, #037dd6, #d73a49); @@ -17,6 +16,12 @@ font-size: 10px; font-weight: bold; margin-top: 4px; + text-align: center; + } + + &__arrow-container { + margin-left: -10px; + width: 100%; } &__arrow-border { diff --git a/ui/components/app/edit-gas-fee-popover/network-statistics/status-slider/status-slider.js b/ui/components/app/edit-gas-fee-popover/network-statistics/status-slider/status-slider.js new file mode 100644 index 000000000..7f4f34e77 --- /dev/null +++ b/ui/components/app/edit-gas-fee-popover/network-statistics/status-slider/status-slider.js @@ -0,0 +1,90 @@ +import React from 'react'; + +import { useGasFeeContext } from '../../../../../contexts/gasFee'; +import I18nValue from '../../../../ui/i18n-value'; +import { NetworkStabilityTooltip } from '../tooltips'; + +const GRADIENT_COLORS = [ + '#037DD6', + '#1876C8', + '#2D70BA', + '#4369AB', + '#57629E', + '#6A5D92', + '#805683', + '#9A4D71', + '#B44561', + '#C54055', + '#D73A49', +]; + +const determineStatusInfo = (givenNetworkCongestion) => { + const networkCongestion = givenNetworkCongestion ?? 0.5; + const colorIndex = Math.round(networkCongestion * 10); + const color = GRADIENT_COLORS[colorIndex]; + const sliderTickValue = colorIndex * 10; + + if (networkCongestion <= 0.33) { + return { + statusLabel: 'notBusy', + tooltipLabel: 'lowLowercase', + color, + sliderTickValue, + }; + } else if (networkCongestion > 0.66) { + return { + statusLabel: 'busy', + tooltipLabel: 'highLowercase', + color, + sliderTickValue, + }; + } + return { + statusLabel: 'stable', + tooltipLabel: 'stableLowercase', + color, + sliderTickValue, + }; +}; + +const StatusSlider = () => { + const { gasFeeEstimates } = useGasFeeContext(); + const statusInfo = determineStatusInfo(gasFeeEstimates.networkCongestion); + + return ( + +
+
+
+
+
+
+
+
+ +
+
+ + ); +}; + +export default StatusSlider; diff --git a/ui/components/app/edit-gas-fee-popover/network-statistics/status-slider/status-slider.test.js b/ui/components/app/edit-gas-fee-popover/network-statistics/status-slider/status-slider.test.js new file mode 100644 index 000000000..004838aa0 --- /dev/null +++ b/ui/components/app/edit-gas-fee-popover/network-statistics/status-slider/status-slider.test.js @@ -0,0 +1,89 @@ +import React from 'react'; + +import { renderWithProvider } from '../../../../../../test/jest'; +import { GasFeeContext } from '../../../../../contexts/gasFee'; +import configureStore from '../../../../../store/store'; + +import StatusSlider from './status-slider'; + +const renderComponent = ({ networkCongestion }) => { + const component = ( + + + + ); + + const store = configureStore(); + + return renderWithProvider(component, store); +}; + +describe('StatusSlider', () => { + it('should show "Not busy" when networkCongestion is less than 0.33', () => { + const { queryByText } = renderComponent({ networkCongestion: 0.32 }); + expect(queryByText('Not busy')).toBeInTheDocument(); + }); + + it('should show "Not busy" when networkCongestion is 0.33', () => { + const { queryByText } = renderComponent({ networkCongestion: 0.33 }); + expect(queryByText('Not busy')).toBeInTheDocument(); + }); + + it('should show "Stable" when networkCongestion is between 0.33 and 0.66', () => { + const { queryByText } = renderComponent({ networkCongestion: 0.5 }); + expect(queryByText('Stable')).toBeInTheDocument(); + }); + + it('should show "Stable" when networkCongestion is 0.66', () => { + const { queryByText } = renderComponent({ networkCongestion: 0.66 }); + expect(queryByText('Stable')).toBeInTheDocument(); + }); + + it('should show "Busy" when networkCongestion is greater than 0.66', () => { + const { queryByText } = renderComponent({ networkCongestion: 0.67 }); + expect(queryByText('Busy')).toBeInTheDocument(); + }); + + it('should show "Stable" if networkCongestion has not been set yet', () => { + const { getByText } = renderComponent({}); + expect(getByText('Stable')).toBeInTheDocument(); + }); + + it('should position the arrow based on converting networkCongestion to a percentage rounded to the nearest 10', () => { + const { getByTestId } = renderComponent({ networkCongestion: 0.23 }); + expect(getByTestId('status-slider-arrow-border')).toHaveStyle( + 'margin-left: 20%', + ); + }); + + it('should position the arrow in the middle if networkCongestion has not been set yet', () => { + const { getByTestId } = renderComponent({}); + expect(getByTestId('status-slider-arrow-border')).toHaveStyle( + 'margin-left: 50%', + ); + }); + + it('should color the arrow and label based on converting networkCongestion to a percentage rounded to the nearest 10', () => { + const { getByTestId } = renderComponent({ networkCongestion: 0.23 }); + expect(getByTestId('status-slider-arrow')).toHaveStyle( + 'border-top-color: #2D70BA', + ); + expect(getByTestId('status-slider-label')).toHaveStyle('color: #2D70BA'); + }); + + it('should color the arrow and label for the end position if networkCongestion rounds to 100%', () => { + const { getByTestId } = renderComponent({ networkCongestion: 0.95 }); + expect(getByTestId('status-slider-arrow')).toHaveStyle( + 'border-top-color: #D73A49', + ); + expect(getByTestId('status-slider-label')).toHaveStyle('color: #D73A49'); + }); + + it('should color the arrow and label for the middle position if networkCongestion has not been set yet', () => { + const { getByTestId } = renderComponent({}); + expect(getByTestId('status-slider-arrow')).toHaveStyle( + 'border-top-color: #6A5D92', + ); + expect(getByTestId('status-slider-label')).toHaveStyle('color: #6A5D92'); + }); +}); diff --git a/ui/components/app/edit-gas-fee-popover/network-statistics/tooltips.js b/ui/components/app/edit-gas-fee-popover/network-statistics/tooltips.js new file mode 100644 index 000000000..e775c367f --- /dev/null +++ b/ui/components/app/edit-gas-fee-popover/network-statistics/tooltips.js @@ -0,0 +1,84 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { useI18nContext } from '../../../../hooks/useI18nContext'; +import Tooltip from '../../../ui/tooltip'; + +const NetworkStatusTooltip = ({ children, html, title }) => ( + + {children} + +); + +NetworkStatusTooltip.propTypes = { + children: PropTypes.node.isRequired, + html: PropTypes.node, + title: PropTypes.string, +}; + +export const BaseFeeTooltip = ({ children }) => { + const t = useI18nContext(); + return ( + + {t('medium')} + , + + {t('high')} + , + ])} + > + {children} + + ); +}; + +BaseFeeTooltip.propTypes = { + children: PropTypes.node.isRequired, +}; + +export const PriorityFeeTooltip = ({ children }) => { + const t = useI18nContext(); + return ( + + {children} + + ); +}; + +PriorityFeeTooltip.propTypes = { + children: PropTypes.node.isRequired, +}; + +export const NetworkStabilityTooltip = ({ children, color, tooltipLabel }) => { + const t = useI18nContext(); + + return ( + + {t(tooltipLabel)} + , + ])} + > + {children} + + ); +}; + +NetworkStabilityTooltip.propTypes = { + children: PropTypes.node.isRequired, + color: PropTypes.string.isRequired, + tooltipLabel: PropTypes.string.isRequired, +}; diff --git a/ui/components/app/edit-gas-fee-popover/network-status/index.js b/ui/components/app/edit-gas-fee-popover/network-status/index.js deleted file mode 100644 index 0ba1f18f3..000000000 --- a/ui/components/app/edit-gas-fee-popover/network-status/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './network-status'; diff --git a/ui/components/app/edit-gas-fee-popover/network-status/network-status.js b/ui/components/app/edit-gas-fee-popover/network-status/network-status.js deleted file mode 100644 index bd102f81e..000000000 --- a/ui/components/app/edit-gas-fee-popover/network-status/network-status.js +++ /dev/null @@ -1,52 +0,0 @@ -import React from 'react'; - -import { COLORS } from '../../../../helpers/constants/design-system'; -import { useGasFeeContext } from '../../../../contexts/gasFee'; -import I18nValue from '../../../ui/i18n-value'; -import Typography from '../../../ui/typography/typography'; - -import StatusSlider from './status-slider'; - -const NetworkStatus = () => { - const { gasFeeEstimates } = useGasFeeContext(); - - return ( -
- - - -
-
- - {gasFeeEstimates?.estimatedBaseFee && - `${gasFeeEstimates?.estimatedBaseFee} GWEI`} - - Base fee -
-
-
- - 0.5 - 22 GWEI - - - Priority fee - -
-
-
- -
-
-
- ); -}; - -NetworkStatus.propTypes = {}; - -export default NetworkStatus; diff --git a/ui/components/app/edit-gas-fee-popover/network-status/status-slider/status-slider.js b/ui/components/app/edit-gas-fee-popover/network-status/status-slider/status-slider.js deleted file mode 100644 index 8271cac6a..000000000 --- a/ui/components/app/edit-gas-fee-popover/network-status/status-slider/status-slider.js +++ /dev/null @@ -1,53 +0,0 @@ -import React from 'react'; - -import I18nValue from '../../../../ui/i18n-value'; - -const GRADIENT_COLORS = [ - '#037DD6', - '#1876C8', - '#2D70BA', - '#4369AB', - '#57629E', - '#6A5D92', - '#805683', - '#9A4D71', - '#B44561', - '#C54055', -]; - -const StatusSlider = () => { - // todo: value below to be replaced with dynamic values from api once it is available - // corresponding test cases also to be added - const statusValue = 0.5; - const sliderValueNumeric = Math.round(statusValue * 10); - - let statusLabel = 'stable'; - if (statusValue <= 0.33) { - statusLabel = 'notBusy'; - } else if (statusValue > 0.66) { - statusLabel = 'busy'; - } - - return ( -
-
-
-
-
-
- -
-
- ); -}; - -export default StatusSlider; diff --git a/ui/components/app/edit-gas-fee-popover/network-status/status-slider/status-slider.test.js b/ui/components/app/edit-gas-fee-popover/network-status/status-slider/status-slider.test.js deleted file mode 100644 index a10ed82bb..000000000 --- a/ui/components/app/edit-gas-fee-popover/network-status/status-slider/status-slider.test.js +++ /dev/null @@ -1,51 +0,0 @@ -import React from 'react'; -import { screen } from '@testing-library/react'; - -import { renderWithProvider } from '../../../../../../test/jest'; -import { ETH } from '../../../../../helpers/constants/common'; -import { GasFeeContextProvider } from '../../../../../contexts/gasFee'; -import configureStore from '../../../../../store/store'; - -import StatusSlider from './status-slider'; - -jest.mock('../../../../../store/actions', () => ({ - disconnectGasFeeEstimatePoller: jest.fn(), - getGasFeeEstimatesAndStartPolling: jest - .fn() - .mockImplementation(() => Promise.resolve()), - addPollingTokenToAppState: jest.fn(), - getGasFeeTimeEstimate: jest - .fn() - .mockImplementation(() => Promise.resolve('unknown')), -})); - -const renderComponent = () => { - const store = configureStore({ - metamask: { - nativeCurrency: ETH, - provider: {}, - cachedBalances: {}, - accounts: { - '0xAddress': { - address: '0xAddress', - balance: '0x176e5b6f173ebe66', - }, - }, - selectedAddress: '0xAddress', - }, - }); - - return renderWithProvider( - - - , - store, - ); -}; - -describe('NetworkStatus', () => { - it('should renders stable for statusValue > 0.33 and <= 0.66', () => { - renderComponent(); - expect(screen.queryByText('Stable')).toBeInTheDocument(); - }); -}); diff --git a/ui/components/app/edit-gas-popover/edit-gas-popover.component.js b/ui/components/app/edit-gas-popover/edit-gas-popover.component.js index 60b73d170..314627cec 100644 --- a/ui/components/app/edit-gas-popover/edit-gas-popover.component.js +++ b/ui/components/app/edit-gas-popover/edit-gas-popover.component.js @@ -261,7 +261,7 @@ export default function EditGasPopover({ ) : ( <> - {process.env.IN_TEST === 'true' ? null : } + {process.env.IN_TEST ? null : } {story()}], id: __filename, }; -export const Basic = () => { +export const DefaultStory = () => { return (
{ ); }; -export const BasicWithDifferentButtonText = () => { +DefaultStory.storyName = 'Default'; + +export const WithDifferentButtonText = () => { return (
{ ); }; -export const EducationalContentFlow = () => { +export const WithEducationalContentFlow = () => { return (
+ + + +## Component API + + + +## Usage + +The following describes the props and example usage for this component. + +### Status + +There are 4 statuses the `SnapSettingsCard` can have: `'installing'`,`'running'`,`'stopped'` and `'crashed'`. + + + + + +### isEnabled / onToggle + +Use the `isEnabled` and `onToggle` to control the `ToggleButton` component inside of the `SnapSettingsCard` + +```jsx +const [isEnabled, setIsEnabled] = React.useState(false); + +const handleOnToggle = () => { + setIsEnabled(!isEnabled); +}; + +return ; +``` diff --git a/ui/components/app/flask/snap-settings-card/index.js b/ui/components/app/flask/snap-settings-card/index.js new file mode 100644 index 000000000..e3a7f5872 --- /dev/null +++ b/ui/components/app/flask/snap-settings-card/index.js @@ -0,0 +1 @@ +export { default } from './snap-settings-card'; diff --git a/ui/components/app/flask/snap-settings-card/index.scss b/ui/components/app/flask/snap-settings-card/index.scss new file mode 100644 index 000000000..728a2e62c --- /dev/null +++ b/ui/components/app/flask/snap-settings-card/index.scss @@ -0,0 +1,57 @@ +$version-max-width: 56px; // Increase to show more of the version number +$body-line-clamp: 4; // Number of lines in card body before truncating + +.snap-settings-card { + &__title { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__toggle-container { + margin-left: auto; + + &__toggle-button { + margin-right: -12px; // react-toggle-button width fix + } + } + + &__body { + overflow: hidden; + /* stylelint-disable */ + display: -webkit-box; + -webkit-line-clamp: $body-line-clamp; + -webkit-box-orient: vertical; + /* stylelint-enable */ + } + + &__version.box { + margin-left: auto; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: $version-max-width; + flex: 0 0 $version-max-width; + text-align: right; + } + + &__chip.chip { + margin: 0; + margin-left: auto; + display: inline-flex; + align-items: center; + text-transform: capitalize; + } + + &__button.button { + padding: 4px 16px; + display: inline-flex; + align-items: center; + } + + &__date-added { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +} diff --git a/ui/components/app/flask/snap-settings-card/snap-settings-card.js b/ui/components/app/flask/snap-settings-card/snap-settings-card.js new file mode 100644 index 000000000..c16068dfb --- /dev/null +++ b/ui/components/app/flask/snap-settings-card/snap-settings-card.js @@ -0,0 +1,279 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; + +import { useI18nContext } from '../../../../hooks/useI18nContext'; + +import Card from '../../../ui/card'; +import Box from '../../../ui/box'; +import IconWithFallback from '../../../ui/icon-with-fallback'; +import IconBorder from '../../../ui/icon-border'; +import Typography from '../../../ui/typography/typography'; +import ToggleButton from '../../../ui/toggle-button'; +import Chip from '../../../ui/chip'; +import ColorIndicator from '../../../ui/color-indicator'; +import Button from '../../../ui/button'; + +import { + COLORS, + TYPOGRAPHY, + FONT_WEIGHT, + ALIGN_ITEMS, + DISPLAY, + TEXT_ALIGN, +} from '../../../../helpers/constants/design-system'; + +const STATUSES = { + INSTALLING: 'installing', + RUNNING: 'running', + STOPPED: 'stopped', + CRASHED: 'crashed', +}; + +const STATUS_COLORS = { + [STATUSES.INSTALLING]: COLORS.ALERT1, + [STATUSES.RUNNING]: COLORS.SUCCESS1, + [STATUSES.STOPPED]: COLORS.UI4, + [STATUSES.CRASHED]: COLORS.ERROR1, +}; + +const SnapSettingsCard = ({ + name, + description, + icon, + dateAdded, + version, + url, + onToggle, + isEnabled = false, + onClick, + status, + className, + cardProps, + toggleButtonProps, + buttonProps, + chipProps, +}) => { + const t = useI18nContext(); + const [chipStatus, setChipStatus] = useState(STATUSES.INSTALLING); + + // TODO: use state directly in place of memoization + const handleStatus = useCallback(() => { + switch (status) { + case STATUSES.INSTALLING: { + setChipStatus(STATUSES.INSTALLING); + break; + } + case STATUSES.RUNNING: { + setChipStatus(STATUSES.RUNNING); + break; + } + case STATUSES.STOPPED: { + setChipStatus(STATUSES.STOPPED); + break; + } + case STATUSES.CRASHED: { + setChipStatus(STATUSES.CRASHED); + break; + } + default: { + setChipStatus(STATUSES.INSTALLING); + } + } + }, [status]); + + useEffect(() => { + handleStatus(status); + }, [handleStatus, status]); + + return ( + + + {(icon || name) && ( + + + + + + )} + + {name} + + + + + + + {description} + + + + + + + + + + + } + label={chipStatus} + labelProps={{ + color: COLORS.UI4, + margin: [0, 1], + }} + backgroundColor={COLORS.UI1} + className="snap-settings-card__chip" + {...chipProps} + /> + + + + {(dateAdded || version) && ( + <> + + {`${ + dateAdded && t('flaskSnapSettingsCardDateAddedOn') + } ${dateAdded} ${url && t('flaskSnapSettingsCardFrom')} ${url}`} + + + v {version} + + + )} + + + + ); +}; + +SnapSettingsCard.propTypes = { + /** + * Name of the snap used for the title of the card and fallback letter for the snap icon + */ + name: PropTypes.string, + /** + * Description of the snap. Truncates after 4 lines + */ + description: PropTypes.string, + /** + * Image source of the snap icon for the IconWithFallback component + */ + icon: PropTypes.string, + /** + * Date the snap was added. Date will need formatting + */ + dateAdded: PropTypes.string, + /** + * The version of the snap in semver. Will truncate after 4 numbers e.g. 10.5.1... + */ + version: PropTypes.string, + /** + * Url of the snap website + */ + url: PropTypes.string, + /** + * The onChange function for the ToggleButton component + */ + onToggle: PropTypes.func, + /** + * Whether the snap is enabled. `value` prop of the ToggleButton + */ + isEnabled: PropTypes.bool, + /** + * onClick function of the "See Details" Button + */ + onClick: PropTypes.func, + /** + * Status of the snap must be one + */ + status: PropTypes.oneOf(Object.values(STATUSES)).isRequired, + /** + * Additional className added to the root div of the SnapSettingsCard component + */ + className: PropTypes.string, + /** + * Optional additional props passed to the Card component + */ + cardProps: PropTypes.shape(Card.propTypes), + /** + * Optional additional props passed to the ToggleButton component + */ + toggleButtonProps: PropTypes.shape(ToggleButton.propTypes), + /** + * Optional additional props passed to the Button component + */ + buttonProps: PropTypes.shape(Button.propTypes), + /** + * Optional additional props passed to the Chip component + */ + chipProps: PropTypes.shape(Chip.propTypes), +}; + +export default SnapSettingsCard; diff --git a/ui/components/app/flask/snap-settings-card/snap-settings-card.stories.js b/ui/components/app/flask/snap-settings-card/snap-settings-card.stories.js new file mode 100644 index 000000000..9d1d28075 --- /dev/null +++ b/ui/components/app/flask/snap-settings-card/snap-settings-card.stories.js @@ -0,0 +1,152 @@ +import React from 'react'; +import { useArgs } from '@storybook/client-api'; + +import README from './README.mdx'; +import SnapSettingsCard from '.'; + +export default { + title: 'Components/App/Flask/SnapSettingsCard', + id: __filename, + component: SnapSettingsCard, + parameters: { + docs: { + page: README, + }, + }, + argTypes: { + name: { + control: 'text', + }, + description: { + control: 'text', + }, + icon: { + control: 'text', + }, + dateAdded: { + control: 'text', + }, + version: { + control: 'text', + }, + url: { + control: 'text', + }, + onToggle: { + action: 'onToggle', + }, + isEnabled: { + control: 'boolean', + }, + onClick: { + action: 'onClick', + }, + status: { + control: { + type: 'select', + }, + options: ['installing', 'stopped', 'running', 'crashed'], + }, + className: { + control: 'string', + }, + cardProps: { + control: 'object', + }, + toggleButtonProps: { + control: 'object', + }, + buttonProps: { + control: 'object', + }, + chipProps: { + control: 'object', + }, + }, +}; + +export const DefaultStory = (args) => { + const [{ isEnabled }, updateArgs] = useArgs(); + + const handleOnToggle = () => { + updateArgs({ + isEnabled: !isEnabled, + status: isEnabled ? 'stopped' : 'running', + }); + }; + return ( + + ); +}; + +DefaultStory.storyName = 'Default'; + +let d = new Date(); +d = d.toDateString(); + +DefaultStory.args = { + name: 'Snap name', + description: + 'This snap provides developers everywhere access to an entirely new data storage paradigm, even letting your programs store data autonomously.', + icon: 'AST.png', + dateAdded: d, + version: '10.5.1234', + url: 'https://metamask.io/', + status: 'stopped', +}; + +export const Status = () => ( + <> + + + + + +); diff --git a/ui/components/app/flask/snap-settings-card/snap-settings-card.test.js b/ui/components/app/flask/snap-settings-card/snap-settings-card.test.js new file mode 100644 index 000000000..9a09e4da9 --- /dev/null +++ b/ui/components/app/flask/snap-settings-card/snap-settings-card.test.js @@ -0,0 +1,76 @@ +import * as React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import SnapSettingsCard from '.'; + +describe('SnapSettingsCard', () => { + const args = { + name: 'Snap name', + description: + 'This snap provides developers everywhere access to an entirely new data storage paradigm, even letting your programs store data autonomously.', + dateAdded: new Date().toDateString(), + version: '10.5.1234', + url: 'https://metamask.io', + status: 'stopped', + icon: './AST.png', + }; + it('should render the SnapsSettingCard without crashing', () => { + const { getByText } = render(); + expect(getByText('Snap name')).toBeDefined(); + }); + + it('should render the pill as installing when given a status of installing', () => { + args.status = 'installing'; + const { getByText } = render(); + expect(getByText('installing')).toBeDefined(); + }); + + it('should render the pill as running when given a status of running', () => { + args.status = 'running'; + const { getByText } = render(); + expect(getByText('running')).toBeDefined(); + }); + + it('should render the pill as installing when given a status of stopped', () => { + args.status = 'stopped'; + const { getByText } = render(); + expect(getByText('stopped')).toBeDefined(); + }); + + it('should render the pill as crashed when given a status of crashed', () => { + args.status = 'crashed'; + const { getByText } = render(); + expect(getByText('crashed')).toBeDefined(); + }); + + it('should call onToggle prop when toggle button is clicked', () => { + const onToggle = jest.fn(); + args.onToggle = onToggle; + const { container } = render(); + const toggleBtn = container.querySelector('.toggle-button').firstChild; + fireEvent.click(toggleBtn); + expect(onToggle).toHaveBeenCalled(); + }); + + it('should call onClick prop when See Details button is clicked', () => { + const onClick = jest.fn(); + args.onClick = onClick; + const { container } = render(); + const seeDetailsBtn = container.querySelector( + '.snap-settings-card__button', + ); + fireEvent.click(seeDetailsBtn); + expect(onClick).toHaveBeenCalled(); + }); + + it('should render an icon image', () => { + const { getByAltText } = render(); + const image = getByAltText(args.name); + expect(image).toBeDefined(); + expect(image).toHaveAttribute('src', args.icon); + }); + + it('should render the icon fallback using the first letter of the name', () => { + const { getByText } = render(); + expect(getByText('S')).toBeDefined(); + }); +}); diff --git a/ui/components/app/flask/snaps-authorship-pill/index.js b/ui/components/app/flask/snaps-authorship-pill/index.js new file mode 100644 index 000000000..79acafe31 --- /dev/null +++ b/ui/components/app/flask/snaps-authorship-pill/index.js @@ -0,0 +1 @@ +export { default } from './snaps-authorship-pill'; diff --git a/ui/components/app/flask/snaps-authorship-pill/index.scss b/ui/components/app/flask/snaps-authorship-pill/index.scss new file mode 100644 index 000000000..3ff6cb3eb --- /dev/null +++ b/ui/components/app/flask/snaps-authorship-pill/index.scss @@ -0,0 +1,16 @@ +@import "design-system"; + +.snaps-authorship-pill { + display: inline-block; + + &:hover, + &:focus { + .chip { + background-color: $ui-1; + } + } +} + +.snaps-authorship-icon { + color: $ui-4; +} diff --git a/ui/components/app/flask/snaps-authorship-pill/snaps-authorship-pill.js b/ui/components/app/flask/snaps-authorship-pill/snaps-authorship-pill.js new file mode 100644 index 000000000..584c3c686 --- /dev/null +++ b/ui/components/app/flask/snaps-authorship-pill/snaps-authorship-pill.js @@ -0,0 +1,56 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import Chip from '../../../ui/chip'; +import Box from '../../../ui/box'; +import Typography from '../../../ui/typography'; +import { + COLORS, + TYPOGRAPHY, +} from '../../../../helpers/constants/design-system'; + +const SnapsAuthorshipPill = ({ packageName, className, url }) => { + return ( + + + + + } + backgroundColor={COLORS.WHITE} + > + + {packageName} + + + + ); +}; + +SnapsAuthorshipPill.propTypes = { + /** + * NPM package name of the snap + */ + packageName: PropTypes.string, + /** + * The className of the SnapsAuthorshipPill + */ + className: PropTypes.string, + /** + * The url of the snap's package + */ + url: PropTypes.string, +}; + +export default SnapsAuthorshipPill; diff --git a/ui/components/app/flask/snaps-authorship-pill/snaps-authorship-pill.stories.js b/ui/components/app/flask/snaps-authorship-pill/snaps-authorship-pill.stories.js new file mode 100644 index 000000000..2bd50d0df --- /dev/null +++ b/ui/components/app/flask/snaps-authorship-pill/snaps-authorship-pill.stories.js @@ -0,0 +1,25 @@ +import React from 'react'; +import SnapsAuthorshipPill from '.'; + +export default { + title: 'Components/App/Flask/SnapsAuthorshipPill', + id: __filename, + component: SnapsAuthorshipPill, + argTypes: { + packageName: { + control: 'text', + }, + url: { + control: 'text', + }, + }, +}; + +export const DefaultStory = (args) => ; + +DefaultStory.storyName = 'Default'; + +DefaultStory.args = { + packageName: 'npm-package-name', + url: 'https://www.npmjs.com/package/@airswap/protocols', +}; diff --git a/ui/components/app/gas-timing/gas-timing.component.js b/ui/components/app/gas-timing/gas-timing.component.js index 49ba84075..4bf3aa629 100644 --- a/ui/components/app/gas-timing/gas-timing.component.js +++ b/ui/components/app/gas-timing/gas-timing.component.js @@ -172,9 +172,7 @@ export default function GasTiming({ toHumanReadableTime(Number(customEstimatedTime?.upperTimeBound), t), ]); } - } - // code below needs to cleaned-up once EIP_1559_V2 flag is removed - else if (supportsEIP1559V2) { + } else if (supportsEIP1559V2) { text = t('gasTimingNegative', [ toHumanReadableTime(low.maxWaitTimeEstimate, t), ]); diff --git a/ui/components/app/home-notification/home-notification.component.js b/ui/components/app/home-notification/home-notification.component.js index b5186db39..1344c74ea 100644 --- a/ui/components/app/home-notification/home-notification.component.js +++ b/ui/components/app/home-notification/home-notification.component.js @@ -91,14 +91,55 @@ const HomeNotification = ({ }; HomeNotification.propTypes = { + /** + * The text for the "Accept" button. This must be accompanied by the `onAccept` prop. + * + * The "Accept" button is only rendered if this prop is set. + */ acceptText: PropTypes.node, + /** + * The text to display alongside the checkbox. + * + * The checkbox state is passed to the `onIgnore` handler, so this should only be used if the `onIgnore` prop is set. + * + * The checkbox is only rendered if this prop is set. + */ checkboxText: PropTypes.node, + /** + * The text to display in the checkbox tooltip. + * + * The tooltip is only rendered if this prop is set. + */ checkboxTooltipText: PropTypes.node, + /** + * Custom class names. + */ classNames: PropTypes.array, + /** + * The notification description. + */ descriptionText: PropTypes.node.isRequired, + /** + * The text for the "Ignore" button. This must be accompanied by the `onIgnore` prop. + * + * The "Ignore" button is only rendered if this prop is set. + */ ignoreText: PropTypes.node, + /** + * The text for the info icon tooltip in the top-right of the notification. + * + * The info-icon is only rendered if this prop is set. + */ infoText: PropTypes.node, + /** + * The handler for the "Accept" button. This must be accompanied by the `acceptText` prop. + */ onAccept: PropTypes.func, + /** + * The handler for the "Ignore" button. This must be accompanied by the `ignoreText` prop. + * + * If `checkboxText` is set, the checkbox state will be passed to this function as a boolean. + */ onIgnore: PropTypes.func, }; diff --git a/ui/components/app/home-notification/home-notification.stories.js b/ui/components/app/home-notification/home-notification.stories.js new file mode 100644 index 000000000..90c0d3847 --- /dev/null +++ b/ui/components/app/home-notification/home-notification.stories.js @@ -0,0 +1,87 @@ +import React from 'react'; + +import HomeNotification from './home-notification.component'; + +export default { + title: 'Components/App/HomeNotification', + id: __filename, + component: HomeNotification, + argTypes: { + acceptText: { + control: 'text', + }, + checkboxText: { + control: 'text', + }, + checkboxTooltipText: { + control: 'text', + }, + classNames: { + control: 'object', + }, + descriptionText: { + control: 'text', + }, + ignoreText: { + control: 'text', + }, + infoText: { + control: 'text', + }, + onAccept: { + action: 'onAccept', + }, + onIgnore: { + action: 'onIgnore', + }, + }, +}; + +const Template = (args) => ; + +export const DefaultStory = Template.bind({}); +DefaultStory.storyName = 'Default'; +DefaultStory.args = { + acceptText: 'Accept text', + descriptionText: 'Description text', + ignoreText: 'Ignore text', + infoText: 'Info text', +}; + +export const WithIgnoreCheckbox = Template.bind({}); +WithIgnoreCheckbox.storyName = 'WithIgnoreCheckbox'; +WithIgnoreCheckbox.args = { + ...DefaultStory.args, + checkboxText: "Don't show this again", + checkboxTooltipText: + 'The value of this checkbox is passed to the `onIgnore` function when the ignore button is pressed', + descriptionText: 'Description text', +}; + +export const OnlyDescription = Template.bind({}); +OnlyDescription.storyName = 'OnlyDescription'; +OnlyDescription.args = { + descriptionText: 'Non-Interactive notification.', +}; + +export const DescriptionAndInfo = Template.bind({}); +DescriptionAndInfo.storyName = 'DescriptionAndInfo'; +DescriptionAndInfo.args = { + descriptionText: 'Non-Interactive notification.', + infoText: 'Info text', +}; + +export const OnlyAccept = Template.bind({}); +OnlyAccept.storyName = 'OnlyAccept'; +OnlyAccept.args = { + acceptText: 'Mandatory Action', + descriptionText: + "The 'Accept' action for this notification is strongly recommended, so there is no option to dismiss", +}; + +export const OnlyIgnore = Template.bind({}); +OnlyIgnore.storyName = 'OnlyIgnore'; +OnlyIgnore.args = { + descriptionText: 'This is a dismissable notification.', + ignoreText: 'Dismiss', +}; diff --git a/ui/components/app/menu-droppo.js b/ui/components/app/menu-droppo.js index 6888d5d68..87ca1e72a 100644 --- a/ui/components/app/menu-droppo.js +++ b/ui/components/app/menu-droppo.js @@ -39,10 +39,9 @@ export default class MenuDroppoComponent extends Component { if ( this.props.isOpen && target !== container && - !isDescendant(this.container, event.target) && - this.props.onClickOutside + !this.container.contains(event.target) ) { - this.props.onClickOutside(event); + this.props.onClickOutside?.(event); } }; @@ -117,15 +116,3 @@ export default class MenuDroppoComponent extends Component { ); } } - -function isDescendant(parent, child) { - let node = child.parentNode; - while (node !== null) { - if (node === parent) { - return true; - } - node = node.parentNode; - } - - return false; -} diff --git a/ui/components/app/metamask-template-renderer/metamask-template-renderer.stories.js b/ui/components/app/metamask-template-renderer/metamask-template-renderer.stories.js index a877f33d7..9abcb9707 100644 --- a/ui/components/app/metamask-template-renderer/metamask-template-renderer.stories.js +++ b/ui/components/app/metamask-template-renderer/metamask-template-renderer.stories.js @@ -4,7 +4,7 @@ import { COLORS, TYPOGRAPHY } from '../../../helpers/constants/design-system'; import MetaMaskTemplateRenderer from '.'; export default { - title: 'MetaMask Template Renderer', + title: 'Components/App/MetamaskTemplateRenderer', id: __filename, }; @@ -83,11 +83,14 @@ const SECTIONS = { }, ], }; -export const metaMaskTemplateRenderer = () => ( + +export const DefaultStory = () => ( ); -export const withInvalidElement = () => ( +DefaultStory.storyName = 'Default'; + +export const WithInvalidElement = () => ( + + + +## Component API + + + +| Name | Description | +| ---------------- | ------------------------------------------------------------------------------------------------------- | +| `translationKey` | Translation object key `string` | +| `variables` | Array of variables for the MetaMaskTranslation. Can be an array of `string`, `number` or `SectionShape` | diff --git a/ui/components/app/metamask-translation/metamask-translation.js b/ui/components/app/metamask-translation/metamask-translation.js index b0df34155..3ba991423 100644 --- a/ui/components/app/metamask-translation/metamask-translation.js +++ b/ui/components/app/metamask-translation/metamask-translation.js @@ -6,7 +6,7 @@ import MetaMaskTemplateRenderer, { } from '../metamask-template-renderer/metamask-template-renderer'; /** - * MetaMaskTranslation is a simple helper Component for adding full translation + * MetaMaskTranslation is a simple helper component for adding full translation * support to the template system. We do pass the translation function to the * template getValues function, but passing it React components as variables * would require React to be in scope, and breaks the object pattern paradigm. @@ -69,7 +69,13 @@ export default function MetaMaskTranslation({ translationKey, variables }) { } MetaMaskTranslation.propTypes = { + /** + * Translation object key + */ translationKey: PropTypes.string.isRequired, + /** + * Array of variables for the MetaMaskTranslation component + */ variables: PropTypes.arrayOf( PropTypes.oneOfType([ PropTypes.string, diff --git a/ui/components/app/metamask-translation/metamask-translation.stories.js b/ui/components/app/metamask-translation/metamask-translation.stories.js index c3e677604..6494c606a 100644 --- a/ui/components/app/metamask-translation/metamask-translation.stories.js +++ b/ui/components/app/metamask-translation/metamask-translation.stories.js @@ -1,67 +1,47 @@ import React from 'react'; -import { select, object } from '@storybook/addon-knobs'; import { groupBy } from 'lodash'; import en from '../../../../app/_locales/en/messages.json'; +import README from './README.mdx'; import MetaMaskTranslation from './metamask-translation'; +const { keysWithoutSubstitution } = groupBy(Object.keys(en), (key) => { + if (en[key].message.includes('$1')) { + return 'keysWithSubstitution'; + } + return 'keysWithoutSubstitution'; +}); + export default { - title: 'MetaMaskTranslation', + title: 'Components/App/MetamaskTranslation', id: __filename, + component: MetaMaskTranslation, + parameters: { + docs: { + page: README, + }, + }, + argTypes: { + translationKey: { options: keysWithoutSubstitution, control: 'select' }, + variables: { control: 'array' }, + }, }; -const { keysWithSubstitution, keysWithoutSubstitution } = groupBy( - Object.keys(en), - (key) => { - if (en[key].message.includes('$1')) { - return 'keysWithSubstitution'; - } - return 'keysWithoutSubstitution'; - }, -); +export const DefaultStory = (args) => { + return ; +}; -export const withoutSubstitutions = () => ( - -); +DefaultStory.storyName = 'Default'; +DefaultStory.args = { + translationKey: keysWithoutSubstitution[0], +}; -export const withSubstitutions = () => ( +export const WithTemplate = (args) => ( {args.translationKey}]} /> ); -export const withTemplate = () => ( - -); +WithTemplate.args = { + translationKey: keysWithoutSubstitution[0], +}; diff --git a/ui/components/app/modals/account-details-modal/account-details-modal.component.js b/ui/components/app/modals/account-details-modal/account-details-modal.component.js index 1df675a01..bd3322148 100644 --- a/ui/components/app/modals/account-details-modal/account-details-modal.component.js +++ b/ui/components/app/modals/account-details-modal/account-details-modal.component.js @@ -17,6 +17,7 @@ export default class AccountDetailsModal extends Component { setAccountLabel: PropTypes.func, keyrings: PropTypes.array, rpcPrefs: PropTypes.object, + accounts: PropTypes.array, }; static contextTypes = { @@ -32,6 +33,7 @@ export default class AccountDetailsModal extends Component { setAccountLabel, keyrings, rpcPrefs, + accounts, } = this.props; const { name, address } = selectedIdentity; @@ -39,6 +41,12 @@ export default class AccountDetailsModal extends Component { return kr.accounts.includes(address); }); + const getAccountsNames = (allAccounts, currentName) => { + return Object.values(allAccounts) + .map((item) => item.name) + .filter((itemName) => itemName !== currentName); + }; + let exportPrivateKeyFeatureEnabled = true; // This feature is disabled for hardware wallets if (isHardwareKeyring(keyring?.type)) { @@ -51,6 +59,7 @@ export default class AccountDetailsModal extends Component { className="account-details-modal__name" defaultValue={name} onSubmit={(label) => setAccountLabel(address, label)} + accountsNames={getAccountsNames(accounts, name)} /> { selectedIdentity: getSelectedIdentity(state), keyrings: state.metamask.keyrings, rpcPrefs: getRpcPrefsForCurrentProvider(state), + accounts: getMetaMaskAccountsOrdered(state), }; }; diff --git a/ui/components/app/modals/account-details-modal/account-details-modal.test.js b/ui/components/app/modals/account-details-modal/account-details-modal.test.js index 2f0fdb788..917be6bf3 100644 --- a/ui/components/app/modals/account-details-modal/account-details-modal.test.js +++ b/ui/components/app/modals/account-details-modal/account-details-modal.test.js @@ -30,6 +30,12 @@ describe('Account Details Modal', () => { name: 'Account 1', }, }, + accounts: { + address: '0xAddress', + lastSelected: 1637764711510, + name: 'Account 1', + balance: '0x543a5fb6caccf599', + }, }; beforeEach(() => { diff --git a/ui/components/app/new-collectibles-notice/index.js b/ui/components/app/new-collectibles-notice/index.js deleted file mode 100644 index 2f54229ac..000000000 --- a/ui/components/app/new-collectibles-notice/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './new-collectibles-notice.component'; diff --git a/ui/components/app/permission-page-container/index.scss b/ui/components/app/permission-page-container/index.scss index ffd703e8e..31b0aee83 100644 --- a/ui/components/app/permission-page-container/index.scss +++ b/ui/components/app/permission-page-container/index.scss @@ -23,7 +23,6 @@ } } - &__header { display: flex; flex-direction: column; diff --git a/ui/components/app/permission-page-container/permission-page-container-content/permission-page-container-content.component.js b/ui/components/app/permission-page-container/permission-page-container-content/permission-page-container-content.component.js index 7cacc40c8..a48c22857 100644 --- a/ui/components/app/permission-page-container/permission-page-container-content/permission-page-container-content.component.js +++ b/ui/components/app/permission-page-container/permission-page-container-content/permission-page-container-content.component.js @@ -2,19 +2,18 @@ import PropTypes from 'prop-types'; import React, { PureComponent } from 'react'; import PermissionsConnectHeader from '../../permissions-connect-header'; import Tooltip from '../../../ui/tooltip'; -import CheckBox from '../../../ui/check-box'; +import PermissionsConnectPermissionList from '../../permissions-connect-permission-list'; export default class PermissionPageContainerContent extends PureComponent { static propTypes = { - domainMetadata: PropTypes.shape({ - extensionId: PropTypes.string, - icon: PropTypes.string, - host: PropTypes.string.isRequired, + subjectMetadata: PropTypes.shape({ name: PropTypes.string.isRequired, origin: PropTypes.string.isRequired, + subjectType: PropTypes.string.isRequired, + extensionId: PropTypes.string, + iconUrl: PropTypes.string, }), selectedPermissions: PropTypes.object.isRequired, - onPermissionToggle: PropTypes.func.isRequired, selectedIdentities: PropTypes.array, allIdentitiesSelected: PropTypes.bool, }; @@ -29,43 +28,11 @@ export default class PermissionPageContainerContent extends PureComponent { }; renderRequestedPermissions() { - const { selectedPermissions, onPermissionToggle } = this.props; - const { t } = this.context; - - const items = Object.keys(selectedPermissions).map((permissionName) => { - const description = t(permissionName); - // don't allow deselecting eth_accounts - const isDisabled = permissionName === 'eth_accounts'; - const isChecked = Boolean(selectedPermissions[permissionName]); - const title = isChecked - ? t('permissionCheckedIconDescription') - : t('permissionUncheckedIconDescription'); - - return ( -
{ - if (!isDisabled) { - onPermissionToggle(permissionName); - } - }} - > - - -
- ); - }); + const { selectedPermissions } = this.props; return (
- {items} +
); } @@ -101,14 +68,14 @@ export default class PermissionPageContainerContent extends PureComponent { getTitle() { const { - domainMetadata, + subjectMetadata, selectedIdentities, allIdentitiesSelected, } = this.props; const { t } = this.context; - if (domainMetadata.extensionId) { - return t('externalExtension', [domainMetadata.extensionId]); + if (subjectMetadata.extensionId) { + return t('externalExtension', [subjectMetadata.extensionId]); } else if (allIdentitiesSelected) { return t('connectToAll', [ this.renderAccountTooltip(t('connectToAllAccounts')), @@ -124,7 +91,7 @@ export default class PermissionPageContainerContent extends PureComponent { } render() { - const { domainMetadata } = this.props; + const { subjectMetadata } = this.props; const { t } = this.context; const title = this.getTitle(); @@ -133,15 +100,15 @@ export default class PermissionPageContainerContent extends PureComponent {
{this.renderRequestedPermissions()} diff --git a/ui/components/app/permission-page-container/permission-page-container.component.js b/ui/components/app/permission-page-container/permission-page-container.component.js index 2a8daa2f6..fee70f720 100644 --- a/ui/components/app/permission-page-container/permission-page-container.component.js +++ b/ui/components/app/permission-page-container/permission-page-container.component.js @@ -13,12 +13,12 @@ export default class PermissionPageContainer extends Component { allIdentitiesSelected: PropTypes.bool, request: PropTypes.object, requestMetadata: PropTypes.object, - targetDomainMetadata: PropTypes.shape({ - extensionId: PropTypes.string, - icon: PropTypes.string, - host: PropTypes.string.isRequired, + targetSubjectMetadata: PropTypes.shape({ name: PropTypes.string.isRequired, origin: PropTypes.string.isRequired, + subjectType: PropTypes.string.isRequired, + extensionId: PropTypes.string, + iconUrl: PropTypes.string, }), }; @@ -62,15 +62,6 @@ export default class PermissionPageContainer extends Component { return Object.keys(props.request.permissions || {}); } - onPermissionToggle = (methodName) => { - this.setState({ - selectedPermissions: { - ...this.state.selectedPermissions, - [methodName]: !this.state.selectedPermissions[methodName], - }, - }); - }; - componentDidMount() { this.context.metricsEvent({ eventOpts: { @@ -97,6 +88,9 @@ export default class PermissionPageContainer extends Component { const request = { ..._request, permissions: { ..._request.permissions }, + approvedAccounts: selectedIdentities.map( + (selectedIdentity) => selectedIdentity.address, + ), }; Object.keys(this.state.selectedPermissions).forEach((key) => { @@ -106,10 +100,7 @@ export default class PermissionPageContainer extends Component { }); if (Object.keys(request.permissions).length > 0) { - approvePermissionsRequest( - request, - selectedIdentities.map((selectedIdentity) => selectedIdentity.address), - ); + approvePermissionsRequest(request); } else { rejectPermissionsRequest(request.metadata.id); } @@ -118,7 +109,7 @@ export default class PermissionPageContainer extends Component { render() { const { requestMetadata, - targetDomainMetadata, + targetSubjectMetadata, selectedIdentities, allIdentitiesSelected, } = this.props; @@ -127,9 +118,8 @@ export default class PermissionPageContainer extends Component {
diff --git a/ui/components/app/permissions-connect-footer/permissions-connect-footer.component.js b/ui/components/app/permissions-connect-footer/permissions-connect-footer.component.js index 635ebbb96..b1a36435b 100644 --- a/ui/components/app/permissions-connect-footer/permissions-connect-footer.component.js +++ b/ui/components/app/permissions-connect-footer/permissions-connect-footer.component.js @@ -21,7 +21,7 @@ export default class PermissionsConnectFooter extends Component { }); }} > - {t('learnMore')} + {t('learnMoreUpperCase')}
diff --git a/ui/components/app/permissions-connect-header/index.scss b/ui/components/app/permissions-connect-header/index.scss index 89297a094..e49e872fd 100644 --- a/ui/components/app/permissions-connect-header/index.scss +++ b/ui/components/app/permissions-connect-header/index.scss @@ -9,20 +9,6 @@ display: flex; flex-direction: column; align-items: center; - - .icon-with-fallback__identicon-container, - .icon-with-fallback__identicon-border { - height: 64px; - width: 64px; - } - - .icon-with-fallback__identicon-border { - border: 1px solid $Grey-100; - } - - .icon-with-fallback__identicon-container { - margin-bottom: 8px; - } } &__title { @@ -31,24 +17,14 @@ text-align: center; color: $Black-100; margin-top: 16px; + font-weight: bold; } - &__text, &__subtitle { @include H6; text-align: center; - color: $Grey-500; - } - - &__text { - width: 100%; - text-overflow: ellipsis; - overflow: hidden; - margin-top: 8px; - - /*rtl:ignore*/ - direction: rtl; + color: $Black-100; } &__subtitle { diff --git a/ui/components/app/permissions-connect-header/permissions-connect-header.component.js b/ui/components/app/permissions-connect-header/permissions-connect-header.component.js index b0d8920ff..70ec3ce25 100644 --- a/ui/components/app/permissions-connect-header/permissions-connect-header.component.js +++ b/ui/components/app/permissions-connect-header/permissions-connect-header.component.js @@ -1,10 +1,10 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import SiteIcon from '../../ui/site-icon'; +import SiteOrigin from '../../ui/site-origin/site-origin'; export default class PermissionsConnectHeader extends Component { static propTypes = { - icon: PropTypes.string, + iconUrl: PropTypes.string, iconName: PropTypes.string.isRequired, siteOrigin: PropTypes.string.isRequired, headerTitle: PropTypes.node, @@ -12,18 +12,17 @@ export default class PermissionsConnectHeader extends Component { }; static defaultProps = { - icon: null, + iconUrl: null, headerTitle: '', headerText: '', }; renderHeaderIcon() { - const { icon, iconName, siteOrigin } = this.props; + const { iconUrl, iconName, siteOrigin } = this.props; return (
- -
{siteOrigin}
+
); } diff --git a/ui/components/app/permissions-connect-permission-list/index.js b/ui/components/app/permissions-connect-permission-list/index.js new file mode 100644 index 000000000..0690579c6 --- /dev/null +++ b/ui/components/app/permissions-connect-permission-list/index.js @@ -0,0 +1 @@ +export { default } from './permissions-connect-permission-list'; diff --git a/ui/components/app/permissions-connect-permission-list/index.scss b/ui/components/app/permissions-connect-permission-list/index.scss new file mode 100644 index 000000000..cc58047bd --- /dev/null +++ b/ui/components/app/permissions-connect-permission-list/index.scss @@ -0,0 +1,22 @@ +.permissions-connect-permission-list { + .permission { + @include H6; + + width: 100%; + padding-bottom: 16px; + border-bottom: 1px solid $Grey-100; + display: flex; + flex-direction: row; + align-items: center; + color: $Black-100; + + i { + display: block; + padding: 16px; + min-width: 16px; + min-height: 16px; + color: $Grey-500; + font-size: 1rem; + } + } +} diff --git a/ui/components/app/permissions-connect-permission-list/permissions-connect-permission-list.js b/ui/components/app/permissions-connect-permission-list/permissions-connect-permission-list.js new file mode 100644 index 000000000..95ed4e912 --- /dev/null +++ b/ui/components/app/permissions-connect-permission-list/permissions-connect-permission-list.js @@ -0,0 +1,33 @@ +import React, { useMemo } from 'react'; +import PropTypes from 'prop-types'; +import { useI18nContext } from '../../../hooks/useI18nContext'; + +export default function PermissionsConnectPermissionList({ permissions }) { + const t = useI18nContext(); + + const PERMISSION_TYPES = useMemo(() => { + return { + eth_accounts: { + leftIcon: 'fas fa-eye', + label: t('eth_accounts'), + rightIcon: null, + }, + }; + }, [t]); + + return ( +
+ {Object.keys(permissions).map((permission) => ( +
+ + {PERMISSION_TYPES[permission].label} + +
+ ))} +
+ ); +} + +PermissionsConnectPermissionList.propTypes = { + permissions: PropTypes.objectOf(PropTypes.bool).isRequired, +}; diff --git a/ui/components/app/permissions-connect-permission-list/permissions-connect-permission-list.stories.js b/ui/components/app/permissions-connect-permission-list/permissions-connect-permission-list.stories.js new file mode 100644 index 000000000..4faa6924d --- /dev/null +++ b/ui/components/app/permissions-connect-permission-list/permissions-connect-permission-list.stories.js @@ -0,0 +1,24 @@ +import React from 'react'; + +import PermissionsConnectList from '.'; + +export default { + title: 'Components/App/PermissionsConnectList', + id: __filename, + component: PermissionsConnectList, + argTypes: { + permissions: { + control: 'object', + }, + }, +}; + +export const DefaultStory = (args) => ; + +DefaultStory.storyName = 'Default'; + +DefaultStory.args = { + permissions: { + eth_accounts: true, + }, +}; diff --git a/ui/components/app/signature-request-original/signature-request-original.component.js b/ui/components/app/signature-request-original/signature-request-original.component.js index 2ee7d005f..8b83e77e0 100644 --- a/ui/components/app/signature-request-original/signature-request-original.component.js +++ b/ui/components/app/signature-request-original/signature-request-original.component.js @@ -6,6 +6,7 @@ import { ObjectInspector } from 'react-inspector'; import LedgerInstructionField from '../ledger-instruction-field'; import { MESSAGE_TYPE } from '../../../../shared/constants/app'; +import { getURLHostName } from '../../../helpers/utils/util'; import Identicon from '../../ui/identicon'; import AccountListItem from '../account-list-item'; import { conversionUtil } from '../../../../shared/modules/conversion.utils'; @@ -32,7 +33,7 @@ export default class SignatureRequestOriginal extends Component { requesterAddress: PropTypes.string, sign: PropTypes.func.isRequired, txData: PropTypes.object.isRequired, - domainMetadata: PropTypes.object, + subjectMetadata: PropTypes.object, hardwareWalletRequiresConnection: PropTypes.bool, isLedgerWallet: PropTypes.bool, nativeCurrency: PropTypes.string.isRequired, @@ -121,11 +122,11 @@ export default class SignatureRequestOriginal extends Component { }; renderOriginInfo = () => { - const { txData, domainMetadata } = this.props; + const { txData, subjectMetadata } = this.props; const { t } = this.context; - const originMetadata = txData.msgParams.origin - ? domainMetadata?.[txData.msgParams.origin] + const targetSubjectMetadata = txData.msgParams.origin + ? subjectMetadata?.[txData.msgParams.origin] : null; return ( @@ -133,10 +134,13 @@ export default class SignatureRequestOriginal extends Component {
{`${t('origin')}:`}
- {originMetadata?.icon ? ( + {targetSubjectMetadata?.iconUrl ? ( ) : null} diff --git a/ui/components/app/signature-request-original/signature-request-original.container.js b/ui/components/app/signature-request-original/signature-request-original.container.js index bcbc140bf..8031d92ed 100644 --- a/ui/components/app/signature-request-original/signature-request-original.container.js +++ b/ui/components/app/signature-request-original/signature-request-original.container.js @@ -7,7 +7,7 @@ import { goHome } from '../../../store/actions'; import { accountsWithSendEtherInfoSelector, conversionRateSelector, - getDomainMetadata, + getSubjectMetadata, doesAddressRequireLedgerHidConnection, } from '../../../selectors'; import { getAccountByAddress } from '../../../helpers/utils/util'; @@ -40,7 +40,7 @@ function mapStateToProps(state, ownProps) { nativeCurrency: getNativeCurrency(state), // not passed to component allAccounts: accountsWithSendEtherInfoSelector(state), - domainMetadata: getDomainMetadata(state), + subjectMetadata: getSubjectMetadata(state), }; } diff --git a/ui/components/app/signature-request/README.mdx b/ui/components/app/signature-request/README.mdx new file mode 100644 index 000000000..0df4dd77e --- /dev/null +++ b/ui/components/app/signature-request/README.mdx @@ -0,0 +1,15 @@ +import { Story, Canvas, ArgsTable } from '@storybook/addon-docs'; + +import SignatureRequest from '.'; + +# Signature Request + +dApp requesting a signature from the user. + + + + + +## Component API + + diff --git a/ui/components/app/signature-request/signature-request-footer/signature-request-footer.component.js b/ui/components/app/signature-request/signature-request-footer/signature-request-footer.component.js index 37df2a177..a42e4f2ae 100644 --- a/ui/components/app/signature-request/signature-request-footer/signature-request-footer.component.js +++ b/ui/components/app/signature-request/signature-request-footer/signature-request-footer.component.js @@ -6,7 +6,7 @@ export default class SignatureRequestFooter extends PureComponent { static propTypes = { cancelAction: PropTypes.func.isRequired, signAction: PropTypes.func.isRequired, - disabled: PropTypes.boolean, + disabled: PropTypes.bool, }; static contextTypes = { diff --git a/ui/components/app/signature-request/signature-request.component.js b/ui/components/app/signature-request/signature-request.component.js index 4acfac4c0..c7ff1a004 100644 --- a/ui/components/app/signature-request/signature-request.component.js +++ b/ui/components/app/signature-request/signature-request.component.js @@ -2,22 +2,41 @@ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import Identicon from '../../ui/identicon'; import LedgerInstructionField from '../ledger-instruction-field'; +import { sanitizeMessage } from '../../../helpers/utils/util'; import Header from './signature-request-header'; import Footer from './signature-request-footer'; import Message from './signature-request-message'; export default class SignatureRequest extends PureComponent { static propTypes = { + /** + * The display content of transaction data + */ txData: PropTypes.object.isRequired, + /** + * The display content of sender account + */ fromAccount: PropTypes.shape({ address: PropTypes.string.isRequired, balance: PropTypes.string, name: PropTypes.string, }).isRequired, + /** + * Check if the wallet is ledget wallet or not + */ isLedgerWallet: PropTypes.bool, + /** + * Handler for cancel button + */ cancel: PropTypes.func.isRequired, + /** + * Handler for sign button + */ sign: PropTypes.func.isRequired, - hardwareWalletRequiresConnection: PropTypes.func.isRequired, + /** + * Whether the hardware wallet requires a connection disables the sign button if true. + */ + hardwareWalletRequiresConnection: PropTypes.bool.isRequired, }; static contextTypes = { @@ -45,7 +64,7 @@ export default class SignatureRequest extends PureComponent { hardwareWalletRequiresConnection, } = this.props; const { address: fromAddress } = fromAccount; - const { message, domain = {} } = JSON.parse(data); + const { message, domain = {}, primaryType, types } = JSON.parse(data); const { metricsEvent } = this.context; const onSign = (event) => { @@ -105,7 +124,7 @@ export default class SignatureRequest extends PureComponent {
) : null} - +
{ describe('render', () => { - const fromAddress = '0x123456789abcdef'; - it('should render a div with one child', () => { + let fromAddress; + let messageData; + + beforeEach(() => { + fromAddress = '0x123456789abcdef'; + messageData = { + domain: { + chainId: 97, + name: 'Ether Mail', + verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC', + version: '1', + }, + message: { + contents: 'Hello, Bob!', + from: { + name: 'Cow', + wallets: [ + '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826', + '0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF', + ], + }, + to: [ + { + name: 'Bob', + wallets: [ + '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB', + '0xB0BdaBea57B0BDABeA57b0bdABEA57b0BDabEa57', + '0xB0B0b0b0b0b0B000000000000000000000000000', + ], + }, + ], + }, + primaryType: 'Mail', + types: { + EIP712Domain: [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, + ], + Mail: [ + { name: 'from', type: 'Person' }, + { name: 'to', type: 'Person[]' }, + { name: 'contents', type: 'string' }, + ], + Person: [ + { name: 'name', type: 'string' }, + { name: 'wallets', type: 'address[]' }, + ], + }, + }; + }); + + it('should render a div message parsed', () => { + const msgParams = { + data: JSON.stringify(messageData), + version: 'V4', + origin: 'test', + }; const wrapper = shallowWithContext( false} clearConfirmTransaction={() => undefined} cancel={() => undefined} sign={() => undefined} txData={{ - msgParams: { - data: '{"message": {"from": {"name": "hello"}}}', - from: fromAddress, - }, + msgParams, + }} + fromAccount={{ address: fromAddress }} + />, + ); + + expect(wrapper.is('div')).toStrictEqual(true); + expect(wrapper).toHaveLength(1); + expect(wrapper.hasClass('signature-request')).toStrictEqual(true); + const messageWrapper = wrapper.find(Message); + expect(messageWrapper).toHaveLength(1); + const { data } = messageWrapper.props(); + expect(data.contents).toStrictEqual('Hello, Bob!'); + expect(data.from.name).toStrictEqual('Cow'); + expect(data.from.wallets).toBeDefined(); + expect(data.from.wallets).toHaveLength(2); + expect(data.to).toBeDefined(); + const dataTo = data.to; + expect(dataTo[0].name).toStrictEqual('Bob'); + expect(dataTo[0].wallets).toHaveLength(3); + }); + + it('should render a div message parsed without typeless data', () => { + messageData.message.do_not_display = 'one'; + messageData.message.do_not_display_2 = { + do_not_display: 'two', + }; + const msgParams = { + data: JSON.stringify(messageData), + version: 'V4', + origin: 'test', + }; + const wrapper = shallowWithContext( + false} + clearConfirmTransaction={() => undefined} + cancel={() => undefined} + sign={() => undefined} + txData={{ + msgParams, }} fromAccount={{ address: fromAddress }} />, @@ -24,6 +119,20 @@ describe('Signature Request Component', () => { expect(wrapper.is('div')).toStrictEqual(true); expect(wrapper).toHaveLength(1); expect(wrapper.hasClass('signature-request')).toStrictEqual(true); + const messageWrapper = wrapper.find(Message); + expect(messageWrapper).toHaveLength(1); + const { data } = messageWrapper.props(); + expect(data.contents).toStrictEqual('Hello, Bob!'); + expect(data.from.name).toStrictEqual('Cow'); + expect(data.from.wallets).toBeDefined(); + expect(data.from.wallets).toHaveLength(2); + expect(data.to).toBeDefined(); + const dataTo = data.to; + expect(dataTo[0].name).toStrictEqual('Bob'); + expect(dataTo[0].wallets).toHaveLength(3); + + expect(data.do_not_display).toBeUndefined(); + expect(data.do_not_display2).toBeUndefined(); }); }); }); diff --git a/ui/components/app/signature-request/signature-request.stories.js b/ui/components/app/signature-request/signature-request.stories.js index b13dc3679..a1c53a69b 100644 --- a/ui/components/app/signature-request/signature-request.stories.js +++ b/ui/components/app/signature-request/signature-request.stories.js @@ -1,38 +1,60 @@ import React from 'react'; import testData from '../../../../.storybook/test-data'; +import README from './README.mdx'; import SignatureRequest from './signature-request.component'; const primaryIdentity = Object.values(testData.metamask.identities)[0]; -const containerStyle = { - width: '357px', -}; - export default { - title: 'Signature Request', + title: 'Components/App/SignatureRequest', id: __filename, + component: SignatureRequest, + parameters: { + docs: { + page: README, + }, + }, + argTypes: { + txData: { control: 'object' }, + fromAccount: { + table: { + address: { control: 'text' }, + balance: { control: 'text' }, + name: { control: 'text' }, + }, + }, + isLedgerWallet: { control: 'boolean' }, + clearConfirmTransaction: { action: 'Clean Confirm' }, + cancel: { action: 'Cancel' }, + sign: { action: 'Sign' }, + hardwareWalletRequiresConnection: { + action: 'hardwareWalletRequiresConnection', + }, + }, +}; + +export const DefaultStory = (args) => { + return ; }; -export const FirstLook = () => { - return ( -
- -
- ); +DefaultStory.storyName = 'Default'; + +DefaultStory.args = { + txData: { + msgParams: { + data: JSON.stringify({ + domain: { + name: 'happydapp.website', + }, + message: { + string: 'haay wuurl', + number: 42, + }, + }), + origin: 'https://happydapp.website/governance?futarchy=true', + }, + }, + fromAccount: primaryIdentity, }; + +DefaultStory.storyName = 'Default'; diff --git a/ui/components/app/transaction-activity-log/transaction-activity-log.util.js b/ui/components/app/transaction-activity-log/transaction-activity-log.util.js index 6eaba0977..76c8d5e36 100644 --- a/ui/components/app/transaction-activity-log/transaction-activity-log.util.js +++ b/ui/components/app/transaction-activity-log/transaction-activity-log.util.js @@ -24,6 +24,7 @@ const STATUS_PATH = '/status'; const GAS_PRICE_PATH = '/txParams/gasPrice'; const GAS_LIMIT_PATH = '/txParams/gas'; const ESTIMATE_BASE_FEE_PATH = '/estimatedBaseFee'; +const BLOCKTIMESTAMP = '/blockTimestamp'; // op constants const REPLACE_OP = 'replace'; @@ -32,6 +33,7 @@ const eventPathsHash = { [STATUS_PATH]: true, [GAS_PRICE_PATH]: true, [GAS_LIMIT_PATH]: true, + [BLOCKTIMESTAMP]: true, }; const statusHash = { @@ -144,7 +146,6 @@ export function getActivities(transaction, isFirstTransaction = false) { eventKey = TRANSACTION_CANCEL_SUCCESS_EVENT; } } - events.push({ id, hash, @@ -155,7 +156,6 @@ export function getActivities(transaction, isFirstTransaction = false) { value: gasFee, }); } - break; } @@ -189,7 +189,16 @@ export function getActivities(transaction, isFirstTransaction = false) { gasPrice: cachedGasPrice, }); } + break; + } + case BLOCKTIMESTAMP: { + const filteredAcc = acc.find( + (ac) => ac.eventKey === TRANSACTION_CONFIRMED_EVENT, + ); + filteredAcc.timestamp = new Date( + parseInt(entry.value, 16) * 1000, + ).getTime(); break; } diff --git a/ui/components/app/transaction-detail-item/README.mdx b/ui/components/app/transaction-detail-item/README.mdx new file mode 100644 index 000000000..56bbee1cc --- /dev/null +++ b/ui/components/app/transaction-detail-item/README.mdx @@ -0,0 +1,15 @@ +import { Story, Canvas, ArgsTable } from '@storybook/addon-docs'; + +import TransactionDetailItem from '.'; + +# Transaction Detail Item + +Transaction detail that includes estimated gas fees. Intended to be used as an array item in the array passed to the `rows` prop of `` + + + + + +## Component API + + diff --git a/ui/components/app/transaction-detail-item/transaction-detail-item.component.js b/ui/components/app/transaction-detail-item/transaction-detail-item.component.js index cbd01182c..c62b347d3 100644 --- a/ui/components/app/transaction-detail-item/transaction-detail-item.component.js +++ b/ui/components/app/transaction-detail-item/transaction-detail-item.component.js @@ -80,12 +80,36 @@ export default function TransactionDetailItem({ } TransactionDetailItem.propTypes = { + /** + * Detail title text wrapped in Typography component. + */ detailTitle: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + /** + * The color of the detailTitle text accepts all Typography color props + */ detailTitleColor: PropTypes.string, + /** + * Text to show on the left of the detailTotal. Wrapped in Typography component. + */ detailText: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + /** + * Total amount to show. Wrapped in Typography component. Will be bold if boldHeadings is true + */ detailTotal: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + /** + * Subtitle text. Checks if React.isValidElement before displaying. Displays under detailTitle + */ subTitle: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + /** + * Text to show under detailTotal. Wrapped in Typography component. + */ subText: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + /** + * Whether detailTotal is bold or not. Defaults to true + */ boldHeadings: PropTypes.bool, + /** + * Changes width to auto for transaction-detail-item__detail-values + */ flexWidthValues: PropTypes.bool, }; diff --git a/ui/components/app/transaction-detail-item/transaction-detail-item.stories.js b/ui/components/app/transaction-detail-item/transaction-detail-item.stories.js index bb98c0bfb..59462b550 100644 --- a/ui/components/app/transaction-detail-item/transaction-detail-item.stories.js +++ b/ui/components/app/transaction-detail-item/transaction-detail-item.stories.js @@ -1,34 +1,62 @@ import React from 'react'; import InfoTooltip from '../../ui/info-tooltip/info-tooltip'; -import GasTiming from '../gas-timing/gas-timing.component'; + +import { COLORS } from '../../../helpers/constants/design-system'; + +import README from './README.mdx'; import TransactionDetailItem from '.'; export default { - title: 'Transaction Detail Item', + title: 'Components/App/TransactionDetailItem', id: __filename, + component: TransactionDetailItem, + parameters: { + docs: { + page: README, + }, + }, + argTypes: { + detailTitle: { control: 'object' }, + detailTitleColor: { + control: { + type: 'select', + }, + options: Object.values(COLORS), + }, + detailText: { control: 'text' }, + detailTotal: { control: 'text' }, + subTitle: { control: 'object' }, + subText: { control: 'object' }, + }, }; -export const basic = () => { +export const DefaultStory = (args) => { return (
- - Estimated gas fee - - - - - } - detailText="16565.30" - detailTotal="0.0089 ETH" - subTitle={} - subText={ - <> - From $16565 - $19000 - - } - /> +
); }; + +DefaultStory.storyName = 'Default'; + +DefaultStory.args = { + detailTitle: ( + <> + Estimated gas fee + + + + + ), + detailText: '16565.30', + detailTotal: '0.0089 ETH', + subTitle: 'Likely in < 30 seconds', + boldHeadings: true, + flexWidthValues: false, + subText: ( + + From $16565 - $19000 + + ), +}; diff --git a/ui/components/app/transaction-detail/README.mdx b/ui/components/app/transaction-detail/README.mdx new file mode 100644 index 000000000..3a1959b14 --- /dev/null +++ b/ui/components/app/transaction-detail/README.mdx @@ -0,0 +1,16 @@ +import { Story, Canvas, ArgsTable } from '@storybook/addon-docs'; + +import TransactionDetail from '.'; + +# Transaction Detail + +Show transaction detail including estimated gas fee and total fee. + + + + + +## Component API + + + diff --git a/ui/components/app/transaction-detail/index.scss b/ui/components/app/transaction-detail/index.scss index a24bd4ede..fc2b2fb77 100644 --- a/ui/components/app/transaction-detail/index.scss +++ b/ui/components/app/transaction-detail/index.scss @@ -16,56 +16,6 @@ } } - &-edit-V2 { - 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%; - } - } - } - &-rows { margin-top: 10px; } diff --git a/ui/components/app/transaction-detail/transaction-detail.component.js b/ui/components/app/transaction-detail/transaction-detail.component.js index 919d76dac..57d4a9df8 100644 --- a/ui/components/app/transaction-detail/transaction-detail.component.js +++ b/ui/components/app/transaction-detail/transaction-detail.component.js @@ -2,86 +2,30 @@ import React from 'react'; import PropTypes from 'prop-types'; import { useGasFeeContext } from '../../../contexts/gasFee'; -import { useTransactionModalContext } from '../../../contexts/transaction-modal'; -import InfoTooltip from '../../ui/info-tooltip/info-tooltip'; -import Typography from '../../ui/typography/typography'; +import { useI18nContext } from '../../../hooks/useI18nContext'; +import Box from '../../ui/box'; +import EditGasFeeButton from '../edit-gas-fee-button'; 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, - userAcknowledgedGasMissing, + userAcknowledgedGasMissing = false, }) { const t = useI18nContext(); - const { - gasLimit, - hasSimulationError, - estimateUsed, - maxFeePerGas, - maxPriorityFeePerGas, - supportsEIP1559V2, - transaction, - } = useGasFeeContext(); - const { openModal } = useTransactionModalContext(); - - if (supportsEIP1559V2 && estimateUsed) { - const editEnabled = !hasSimulationError || userAcknowledgedGasMissing; - if (!editEnabled) return null; - - return ( -
-
- - {estimateUsed === 'custom' && onEdit && ( - - )} - {estimateUsed === 'dappSuggested' && ( - - - {t('dappSuggestedTooltip', [transaction.origin])} - - - {t('maxBaseFee')} - {maxFeePerGas} - - - {t('maxPriorityFee')} - {maxPriorityFeePerGas} - - - {t('gasLimit')} - {gasLimit} - -
- } - position="top" - /> - )} -
-
{rows}
-
- ); - } + const { supportsEIP1559V2 } = useGasFeeContext(); return (
- {onEdit && ( + {supportsEIP1559V2 && ( + + + + )} + {!supportsEIP1559V2 && onEdit && (
@@ -92,7 +36,13 @@ export default function TransactionDetail({ } TransactionDetail.propTypes = { + /** + * Show item content for transaction detail. Array of TransactionDetailItem components + */ rows: PropTypes.arrayOf(TransactionDetailItem).isRequired, + /** + * onClick handler for the Edit link + */ onEdit: PropTypes.func, - userAcknowledgedGasMissing: PropTypes.bool.isRequired, + userAcknowledgedGasMissing: PropTypes.bool, }; diff --git a/ui/components/app/transaction-detail/transaction-detail.component.test.js b/ui/components/app/transaction-detail/transaction-detail.component.test.js index 7209c872e..5850a2a15 100644 --- a/ui/components/app/transaction-detail/transaction-detail.component.test.js +++ b/ui/components/app/transaction-detail/transaction-detail.component.test.js @@ -1,10 +1,7 @@ import React from 'react'; import { screen } from '@testing-library/react'; -import { - GAS_ESTIMATE_TYPES, - PRIORITY_LEVELS, -} from '../../../../shared/constants/gas'; +import { GAS_ESTIMATE_TYPES } from '../../../../shared/constants/gas'; import { TRANSACTION_ENVELOPE_TYPES } from '../../../../shared/constants/transaction'; import { GasFeeContextProvider } from '../../../contexts/gasFee'; @@ -67,72 +64,6 @@ describe('TransactionDetail', () => { expect(screen.queryByText('Low')).toBeInTheDocument(); }); - it('should render edit link with text markey if medium gas estimates are selected', () => { - render({ contextProps: { 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({ contextProps: { 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({ - contextProps: { - transaction: { - userFeeLevel: PRIORITY_LEVELS.DAPP_SUGGESTED, - 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({ - contextProps: { - defaultEstimateToUse: 'custom', - }, - }); - expect(screen.queryByText('⚙')).toBeInTheDocument(); - expect(screen.queryByText('Advanced')).toBeInTheDocument(); - expect(screen.queryByText('Edit')).toBeInTheDocument(); - }); - - it('should not render edit link if transaction has simulation error and prop userAcknowledgedGasMissing is false', () => { - render({ - contextProps: { - transaction: { - simulationFails: true, - userFeeLevel: 'low', - }, - }, - componentProps: { userAcknowledgedGasMissing: false }, - }); - expect(screen.queryByRole('button')).not.toBeInTheDocument(); - expect(screen.queryByText('Low')).not.toBeInTheDocument(); - }); - - it('should render edit link if userAcknowledgedGasMissing is true even if transaction has simulation error', () => { - render({ - contextProps: { - transaction: { - simulationFails: true, - userFeeLevel: 'low', - }, - }, - componentProps: { userAcknowledgedGasMissing: true }, - }); - expect(screen.queryByRole('button')).toBeInTheDocument(); - expect(screen.queryByText('Low')).toBeInTheDocument(); - }); - it('should render edit link with text edit for legacy transactions', () => { render({ contextProps: { diff --git a/ui/components/app/transaction-detail/transaction-detail.stories.js b/ui/components/app/transaction-detail/transaction-detail.stories.js index b0cbfeb31..a53ada8c6 100644 --- a/ui/components/app/transaction-detail/transaction-detail.stories.js +++ b/ui/components/app/transaction-detail/transaction-detail.stories.js @@ -2,11 +2,22 @@ import React from 'react'; import InfoTooltip from '../../ui/info-tooltip/info-tooltip'; import TransactionDetailItem from '../transaction-detail-item/transaction-detail-item.component'; import GasTiming from '../gas-timing/gas-timing.component'; +import README from './README.mdx'; import TransactionDetail from '.'; export default { - title: 'Transaction Detail', + title: 'Components/App/TransactionDetail', id: __filename, + component: TransactionDetail, + parameters: { + docs: { + page: README, + }, + }, + argTypes: { + rows: { control: 'array' }, + onEdit: { action: 'onEdit' }, + }, }; const rows = [ @@ -43,18 +54,12 @@ const rows = [ />, ]; -export const basic = () => { - return ( -
- -
- ); +export const DefaultStory = (args) => { + return ; }; -export const editable = () => { - return ( -
- console.log('Edit!')} /> -
- ); +DefaultStory.storyName = 'Default'; + +DefaultStory.args = { + rows, }; diff --git a/ui/components/app/transaction-list/transaction-list.stories.js b/ui/components/app/transaction-list/transaction-list.stories.js index eea8cc97f..faef67b5c 100644 --- a/ui/components/app/transaction-list/transaction-list.stories.js +++ b/ui/components/app/transaction-list/transaction-list.stories.js @@ -3,7 +3,7 @@ import React from 'react'; import TransactionList from '.'; export default { - title: 'Transaction List', + title: 'Components/App/TransactionList', id: __filename, }; @@ -11,10 +11,12 @@ const PageSet = ({ children }) => { return children; }; -export const TxList = () => { +export const DefaultStory = () => { return ( ); }; + +DefaultStory.storyName = 'Default'; diff --git a/ui/components/app/transaction-total-banner/transaction-total-banner.stories.js b/ui/components/app/transaction-total-banner/transaction-total-banner.stories.js index 576a6a742..eb2159a4d 100644 --- a/ui/components/app/transaction-total-banner/transaction-total-banner.stories.js +++ b/ui/components/app/transaction-total-banner/transaction-total-banner.stories.js @@ -2,7 +2,7 @@ import React from 'react'; import TransactionTotalBanner from '.'; export default { - title: 'Transaction Total Banner', + title: 'Components/App/TransactionTotalBanner', id: __filename, }; diff --git a/ui/components/app/user-preferenced-currency-display/user-preferenced-currency-display.component.js b/ui/components/app/user-preferenced-currency-display/user-preferenced-currency-display.component.js index e572d8adb..9143c94a1 100644 --- a/ui/components/app/user-preferenced-currency-display/user-preferenced-currency-display.component.js +++ b/ui/components/app/user-preferenced-currency-display/user-preferenced-currency-display.component.js @@ -9,7 +9,7 @@ export default function UserPreferencedCurrencyDisplay({ ethLogoHeight = 12, ethNumberOfDecimals, fiatNumberOfDecimals, - 'numberOfDecimals': propsNumberOfDecimals, + numberOfDecimals: propsNumberOfDecimals, showEthLogo, type, ...restProps @@ -40,22 +40,22 @@ export default function UserPreferencedCurrencyDisplay({ } UserPreferencedCurrencyDisplay.propTypes = { - 'className': PropTypes.string, + className: PropTypes.string, 'data-testid': PropTypes.string, - 'prefix': PropTypes.string, - 'value': PropTypes.string, - 'numberOfDecimals': PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - 'hideLabel': PropTypes.bool, - 'hideTitle': PropTypes.bool, - 'style': PropTypes.object, - 'showEthLogo': PropTypes.bool, - 'ethLogoHeight': PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - 'type': PropTypes.oneOf([PRIMARY, SECONDARY]), - 'ethNumberOfDecimals': PropTypes.oneOfType([ + prefix: PropTypes.string, + value: PropTypes.string, + numberOfDecimals: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + hideLabel: PropTypes.bool, + hideTitle: PropTypes.bool, + style: PropTypes.object, + showEthLogo: PropTypes.bool, + ethLogoHeight: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + type: PropTypes.oneOf([PRIMARY, SECONDARY]), + ethNumberOfDecimals: PropTypes.oneOfType([ PropTypes.string, PropTypes.number, ]), - 'fiatNumberOfDecimals': PropTypes.oneOfType([ + fiatNumberOfDecimals: PropTypes.oneOfType([ PropTypes.string, PropTypes.number, ]), diff --git a/ui/components/ui/account-list/account-list.js b/ui/components/ui/account-list/account-list.js new file mode 100644 index 000000000..e9a8049c8 --- /dev/null +++ b/ui/components/ui/account-list/account-list.js @@ -0,0 +1,175 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import { useI18nContext } from '../../../hooks/useI18nContext'; +import CheckBox, { CHECKED, INDETERMINATE, UNCHECKED } from '../check-box'; +import Identicon from '../identicon'; +import UserPreferencedCurrencyDisplay from '../../app/user-preferenced-currency-display'; +import { PRIMARY } from '../../../helpers/constants/common'; +import Tooltip from '../tooltip'; + +const AccountList = ({ + selectNewAccountViaModal, + accounts, + addressLastConnectedMap, + selectedAccounts, + nativeCurrency, + allAreSelected, + deselectAll, + selectAll, + handleAccountClick, +}) => { + const t = useI18nContext(); + + const Header = () => { + let checked; + if (allAreSelected()) { + checked = CHECKED; + } else if (selectedAccounts.size === 0) { + checked = UNCHECKED; + } else { + checked = INDETERMINATE; + } + + return ( +
1, + })} + > + {accounts.length > 1 ? ( +
+ (allAreSelected() ? deselectAll() : selectAll())} + /> +
+ {t('selectAll')} +
+ + {t('selectingAllWillAllow')} +
+ } + > + + +
+ ) : null} +
selectNewAccountViaModal(handleAccountClick)} + > + {t('newAccount')} +
+
+ ); + }; + + const List = () => { + return ( +
+
+ {accounts.map((account, index) => { + const { address, addressLabel, balance } = account; + return ( +
handleAccountClick(address)} + className="choose-account-list__account" + > +
+ + +
+
+ {addressLabel} +
+ +
+
+ {addressLastConnectedMap[address] ? ( + + + + ) : null} +
+ ); + })} +
+
+ ); + }; + + return ( +
+
+ +
+ ); +}; + +AccountList.propTypes = { + /** + * Array of user account objects + */ + accounts: PropTypes.arrayOf( + PropTypes.shape({ + address: PropTypes.string, + addressLabel: PropTypes.string, + lastConnectedDate: PropTypes.string, + balance: PropTypes.string, + }), + ).isRequired, + /** + * Function to select a new account via modal + */ + selectNewAccountViaModal: PropTypes.func.isRequired, + /** + * A map of the last connected addresses + */ + addressLastConnectedMap: PropTypes.object, + /** + * Native currency of current chain + */ + nativeCurrency: PropTypes.string.isRequired, + /** + * Currently selected accounts + */ + selectedAccounts: PropTypes.object.isRequired, + /** + * Function to check if all accounts are selected + */ + allAreSelected: PropTypes.func.isRequired, + /** + * Function to deselect all accounts + */ + deselectAll: PropTypes.func.isRequired, + /** + * Function to select all accounts + */ + selectAll: PropTypes.func.isRequired, + /** + * Function to handle account click + */ + handleAccountClick: PropTypes.func.isRequired, +}; + +export default AccountList; diff --git a/ui/components/ui/account-list/index.js b/ui/components/ui/account-list/index.js new file mode 100644 index 000000000..8ce06b6f8 --- /dev/null +++ b/ui/components/ui/account-list/index.js @@ -0,0 +1 @@ +export { default } from './account-list'; diff --git a/ui/components/ui/account-list/index.scss b/ui/components/ui/account-list/index.scss new file mode 100644 index 000000000..4e984f290 --- /dev/null +++ b/ui/components/ui/account-list/index.scss @@ -0,0 +1,140 @@ +.choose-account-list { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + align-items: center; + + &__header--one-item, + &__header--multiple-items { + display: flex; + flex: 0; + margin-top: 36px; + width: 100%; + padding-inline-start: 15px; + padding-inline-end: 17px; + } + + &__header--one-item { + justify-content: flex-end; + } + + &__header--multiple-items { + justify-content: space-between; + } + + &__select-all { + display: flex; + margin-inline-start: 16px; + align-items: center; + } + + &__header-check-box { + margin-right: 16px; + } + + &__wrapper { + width: 92%; + display: flex; + } + + &__list { + flex: 2 1 0; + width: 92%; + max-height: max-content; + border: 1px solid #d0d5da; + box-sizing: border-box; + border-radius: 8px; + margin-top: 8px; + overflow-y: auto; + } + + &__account { + display: flex; + align-items: center; + padding: 16px; + border-bottom: 1px solid #d2d8dd; + justify-content: space-between; + + &:last-of-type { + border-bottom: none; + } + + &:hover { + background: $Grey-000; + cursor: pointer; + } + + &__info { + display: flex; + flex-direction: column; + margin-inline-start: 16px; + min-width: 0; + } + + &__label { + @include H6; + + color: $Black-100; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + + &__balance { + @include H7; + + color: $Grey-500; + } + + &__last-connected { + @include H8; + + display: flex; + flex-direction: column; + align-items: flex-end; + color: $primary-blue; + } + } + + &__account-info-wrapper { + display: flex; + justify-content: flex-start; + align-items: center; + min-width: 0; + } + + &__list-check-box { + margin-inline-end: 16px; + + i { + font-size: 0.8rem; + } + } + + .fa-info-circle { + color: $Grey-200; + cursor: pointer; + margin-inline-start: 8px; + font-size: 0.9rem; + } + + .fa-info-circle:hover { + color: $Grey-300; + } + + &__text, + &__text-blue, + &__text-grey { + @include H6; + } + + &__text-blue { + color: $primary-blue; + cursor: pointer; + } + + &__text-grey { + color: $Grey-500; + } +} diff --git a/ui/components/ui/actionable-message/README.mdx b/ui/components/ui/actionable-message/README.mdx new file mode 100644 index 000000000..c01e3b749 --- /dev/null +++ b/ui/components/ui/actionable-message/README.mdx @@ -0,0 +1,47 @@ +import { Story, Canvas, ArgsTable } from '@storybook/addon-docs'; + +import ActionableMessage from '.'; + +# Actionable Message + +Popup component that give the user information. Actionable Message component can generate a tooltip and a maximum of two action buttons. + + + + + +## Component API + + + +### One Action + +Add actionable message component with one button + + + + + +### Two Actions + +Add actionable message component with two buttons + + + + + +### Left Aligned + +Align actionable message component's text to left + + + + + +### With Icon + +Add tooltip icon to the left of the component + + + + diff --git a/ui/components/ui/actionable-message/actionable-message.js b/ui/components/ui/actionable-message/actionable-message.js index ccae2b676..ae32b434c 100644 --- a/ui/components/ui/actionable-message/actionable-message.js +++ b/ui/components/ui/actionable-message/actionable-message.js @@ -102,24 +102,58 @@ export default function ActionableMessage({ } ActionableMessage.propTypes = { + /** + * Text inside actionable message + */ message: PropTypes.node.isRequired, + /** + * First button props that have label and onClick props + */ primaryAction: PropTypes.shape({ label: PropTypes.string, onClick: PropTypes.func, }), + /** + * Another style of primary action. + * This probably shouldn't have been added. A `children` prop might have been more appropriate. + */ primaryActionV2: PropTypes.shape({ label: PropTypes.string, onClick: PropTypes.func, }), + /** + * Second button props that have label and onClick props + */ secondaryAction: PropTypes.shape({ label: PropTypes.string, onClick: PropTypes.func, }), + /** + * Additional css className for the component based on the parent css + */ className: PropTypes.string, + /** + * change color theme for the component that already predefined in css + */ type: PropTypes.string, + /** + * change text align to left and button to bottom right + */ withRightButton: PropTypes.bool, + /** + * Add tooltip and custom message + */ infoTooltipText: PropTypes.string, + /** + * Add tooltip icon in the left component without message + */ useIcon: PropTypes.bool, + /** + * change tooltip icon color + */ iconFillColor: PropTypes.string, + /** + * Whether the buttons are rounded + */ roundedButtons: PropTypes.bool, }; diff --git a/ui/components/ui/actionable-message/actionable-message.stories.js b/ui/components/ui/actionable-message/actionable-message.stories.js index edb6aa823..afeb1eafb 100644 --- a/ui/components/ui/actionable-message/actionable-message.stories.js +++ b/ui/components/ui/actionable-message/actionable-message.stories.js @@ -1,85 +1,109 @@ import React from 'react'; -import { action } from '@storybook/addon-actions'; -import { text } from '@storybook/addon-knobs'; +import README from './README.mdx'; import ActionableMessage from '.'; export default { - title: 'ActionableMessage', + title: 'Components/UI/ActionableMessage', id: __filename, + component: ActionableMessage, + parameters: { docs: { page: README } }, + argTypes: { + message: { control: 'text' }, + 'primaryAction.label': { control: 'text' }, + 'primaryAction.onClick': { action: 'primaryAction.onClick' }, + 'primaryActionV2.label': { control: 'text' }, + 'primaryActionV2.onClick': { action: 'primaryActionV2.onClick' }, + 'secondaryAction.label': { control: 'text' }, + 'secondaryAction.onClick': { action: 'secondaryAction.onClick' }, + className: { control: 'text' }, + type: { control: 'text' }, + withRightButton: { control: 'boolean' }, + infoTooltipText: { control: 'text' }, + useIcon: { control: 'boolean' }, + iconFillColor: { control: 'color' }, + }, }; -export const NoAction = () => ( -
- -
+export const DefaultStory = (args) => ( + ); -export const OneAction = () => ( -
- -
-); +DefaultStory.storyName = 'Default'; -export const TwoActions = () => ( -
- -
-); +DefaultStory.args = { + message: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', +}; -export const LeftAligned = () => ( -
- -
-); +export const OneAction = (args) => ; -export const withIcon = () => ( -
- -
-); +OneAction.args = { + message: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + primaryAction: { + label: 'Dismiss', + }, +}; + +export const TwoActions = (args) => ; + +TwoActions.args = { + message: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + primaryAction: { + label: 'Dismiss', + }, + secondaryAction: { + label: 'Okay', + }, + className: 'actionable-message--warning', +}; + +export const LeftAligned = (args) => ; + +LeftAligned.args = { + message: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + primaryAction: { + label: 'Dismiss', + }, + className: 'actionable-message--left-aligned', +}; + +export const WithIcon = (args) => ; + +WithIcon.args = { + message: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + className: 'actionable-message--left-aligned actionable-message--warning', + useIcon: true, + iconFillColor: '#f8c000', +}; + +export const PrimaryV2Action = (args) => ; + +PrimaryV2Action.args = { + message: + 'We were not able to estimate gas. There might be an error in the contract and this transaction may fail.', + useIcon: true, + iconFillColor: '#d73a49', + type: 'danger', + primaryActionV2: { + label: 'I want to proceed anyway', + }, +}; diff --git a/ui/components/ui/alert-circle-icon/alert-circle-icon.stories.js b/ui/components/ui/alert-circle-icon/alert-circle-icon.stories.js index fe34a15a8..c02480a64 100644 --- a/ui/components/ui/alert-circle-icon/alert-circle-icon.stories.js +++ b/ui/components/ui/alert-circle-icon/alert-circle-icon.stories.js @@ -3,7 +3,7 @@ import README from './README.mdx'; import AlertCircleIcon from './alert-circle-icon.component'; export default { - title: 'Components/UI/Alert Circle Icon', + title: 'Components/UI/AlertCircleIcon', id: __filename, component: AlertCircleIcon, parameters: { @@ -17,13 +17,11 @@ export default { }; export const DefaultStory = (args) => ; -DefaultStory.storyName = 'Default'; -DefaultStory.args = { - type: 'danger', -}; +DefaultStory.storyName = 'Default'; export const Warning = (args) => ; + Warning.args = { type: 'warning', }; diff --git a/ui/components/ui/box/box.stories.js b/ui/components/ui/box/box.stories.js index b7168786b..732ddad2c 100644 --- a/ui/components/ui/box/box.stories.js +++ b/ui/components/ui/box/box.stories.js @@ -12,13 +12,13 @@ import { import Box from './box'; export default { - title: 'Box', + title: 'Components/UI/Box', id: __filename, }; const sizeKnobOptions = [undefined, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; -export const box = () => { +export const DefaultStory = () => { const items = []; const size = number( 'size', @@ -86,3 +86,5 @@ export const box = () => { ); }; + +DefaultStory.storyName = 'Default'; diff --git a/ui/components/ui/button/button.component.js b/ui/components/ui/button/button.component.js index 178713230..1d10b885a 100644 --- a/ui/components/ui/button/button.component.js +++ b/ui/components/ui/button/button.component.js @@ -12,16 +12,16 @@ const CLASSNAME_ROUNDED = 'btn--rounded'; const CLASSNAME_FIRST_TIME = 'btn--first-time'; const typeHash = { - 'default': CLASSNAME_DEFAULT, - 'primary': CLASSNAME_PRIMARY, - 'secondary': CLASSNAME_SECONDARY, - 'warning': 'btn-warning', - 'danger': 'btn-danger', + default: CLASSNAME_DEFAULT, + primary: CLASSNAME_PRIMARY, + secondary: CLASSNAME_SECONDARY, + warning: 'btn-warning', + danger: 'btn-danger', 'danger-primary': 'btn-danger-primary', - 'link': 'btn-link', + link: 'btn-link', // TODO: Legacy button type to be deprecated - 'confirm': CLASSNAME_CONFIRM, - 'raised': CLASSNAME_RAISED, + confirm: CLASSNAME_CONFIRM, + raised: CLASSNAME_RAISED, 'first-time': CLASSNAME_FIRST_TIME, }; diff --git a/ui/components/ui/button/button.stories.js b/ui/components/ui/button/button.stories.js index 05d61ec3c..c1da912ab 100644 --- a/ui/components/ui/button/button.stories.js +++ b/ui/components/ui/button/button.stories.js @@ -6,7 +6,7 @@ import README from './README.mdx'; import Button from '.'; export default { - title: 'Button', + title: 'Components/UI/Button', id: __filename, component: Button, parameters: { diff --git a/ui/components/ui/callout/callout.stories.js b/ui/components/ui/callout/callout.stories.js index 241fe34f9..4bdfc516f 100644 --- a/ui/components/ui/callout/callout.stories.js +++ b/ui/components/ui/callout/callout.stories.js @@ -10,11 +10,11 @@ import Typography from '../typography'; import Callout from './callout'; export default { - title: 'Callout', + title: 'Components/UI/Callout', id: __filename, }; -export const persistentCallout = () => ( +export const PersistentCallout = () => ( This is your private key: diff --git a/ui/components/ui/card/card.stories.js b/ui/components/ui/card/card.stories.js index 1dd2075e2..3fe77495c 100644 --- a/ui/components/ui/card/card.stories.js +++ b/ui/components/ui/card/card.stories.js @@ -15,7 +15,7 @@ import Card from '.'; const sizeOptions = [undefined, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; export default { - title: 'UI/Card', + title: 'Components/UI/Card', id: __filename, component: Card, parameters: { diff --git a/ui/components/ui/chip/chip.js b/ui/components/ui/chip/chip.js index e23dd8289..f84534535 100644 --- a/ui/components/ui/chip/chip.js +++ b/ui/components/ui/chip/chip.js @@ -16,6 +16,7 @@ export default function Chip({ leftIcon, rightIcon, onClick, + maxContent = true, }) { const onKeyPress = (event) => { if (event.key === 'Enter' && onClick) { @@ -35,6 +36,7 @@ export default function Chip({ 'chip--with-right-icon': Boolean(rightIcon), [`chip--border-color-${borderColor}`]: true, [`chip--background-color-${backgroundColor}`]: true, + 'chip--max-content': maxContent, })} role={isInteractive ? 'button' : undefined} tabIndex={isInteractive ? 0 : undefined} @@ -99,4 +101,9 @@ Chip.propTypes = { * The onClick handler to be passed to the Chip component */ onClick: PropTypes.func, + /** + * If the width: max-content; is used in css. + * max-content can overflow the parent's width and break designs + */ + maxContent: PropTypes.bool, }; diff --git a/ui/components/ui/chip/chip.scss b/ui/components/ui/chip/chip.scss index a52e37afa..17e96d582 100644 --- a/ui/components/ui/chip/chip.scss +++ b/ui/components/ui/chip/chip.scss @@ -9,7 +9,6 @@ margin: 0 4px; display: flex; align-items: center; - width: max-content; &__left-icon, &__right-icon { @@ -63,4 +62,8 @@ margin-right: 8px; } } + + &--max-content { + width: max-content; + } } diff --git a/ui/components/ui/chip/chip.stories.js b/ui/components/ui/chip/chip.stories.js index 06c638812..fd6f960c5 100644 --- a/ui/components/ui/chip/chip.stories.js +++ b/ui/components/ui/chip/chip.stories.js @@ -11,7 +11,7 @@ import README from './README.mdx'; import Chip from '.'; export default { - title: 'UI/Chip', + title: 'Components/UI/Chip', id: __filename, component: Chip, parameters: { diff --git a/ui/components/ui/circle-icon/circle-icon.stories.js b/ui/components/ui/circle-icon/circle-icon.stories.js index 5fb969737..90c63d817 100644 --- a/ui/components/ui/circle-icon/circle-icon.stories.js +++ b/ui/components/ui/circle-icon/circle-icon.stories.js @@ -3,7 +3,7 @@ import README from './README.mdx'; import CircleIcon from './circle-icon.component'; export default { - title: 'Components/UI/Circle Icon', + title: 'Components/UI/CircleIcon', id: __filename, component: CircleIcon, parameters: { diff --git a/ui/components/ui/color-indicator/color-indicator.stories.js b/ui/components/ui/color-indicator/color-indicator.stories.js index 828d23570..c5e480439 100644 --- a/ui/components/ui/color-indicator/color-indicator.stories.js +++ b/ui/components/ui/color-indicator/color-indicator.stories.js @@ -4,11 +4,11 @@ import { COLORS, SIZES } from '../../../helpers/constants/design-system'; import ColorIndicator from './color-indicator'; export default { - title: 'ColorIndicator', + title: 'Components/UI/ColorIndicator', id: __filename, }; -export const colorIndicator = () => ( +export const DefaultStory = () => ( ( /> ); -export const withIcon = () => ( +DefaultStory.storyName = 'Default'; + +export const WithIcon = () => ( ( +export const DefaultStory = () => ( ); -export const withTooltips = () => ( +DefaultStory.storyName = 'Default'; + +export const WithTooltips = () => ( ( /> ); -export const withTypographyControl = () => ( +export const WithTypographyControl = () => ( + + + +## Component API + + + +## Usage + +### Options Without Names + + + + + +### Options With Long Names + + + + + +### Options With Long Names And Short Width + + + + diff --git a/ui/components/ui/dropdown/dropdown.js b/ui/components/ui/dropdown/dropdown.js index ed207d760..5b678a5fb 100644 --- a/ui/components/ui/dropdown/dropdown.js +++ b/ui/components/ui/dropdown/dropdown.js @@ -4,10 +4,10 @@ import classnames from 'classnames'; const Dropdown = ({ className, - disabled, + disabled = false, onChange, options, - selectedOption, + selectedOption = null, style, title, }) => { @@ -41,26 +41,39 @@ const Dropdown = ({ }; Dropdown.propTypes = { + /** + * Additional css className to add to root of Dropdown component + */ className: PropTypes.string, + /** + * Disable dropdown by setting to true + */ disabled: PropTypes.bool, + /** + * Title of the dropdown + */ title: PropTypes.string, + /** + * On options change handler + */ onChange: PropTypes.func.isRequired, + /** + * Predefined options for component + */ options: PropTypes.arrayOf( PropTypes.exact({ name: PropTypes.string, value: PropTypes.string.isRequired, }), ).isRequired, + /** + * Selected options of dropdown + */ selectedOption: PropTypes.string, + /** + * Add inline style for the component + */ style: PropTypes.object, }; -Dropdown.defaultProps = { - className: undefined, - disabled: false, - title: undefined, - selectedOption: null, - style: undefined, -}; - export default Dropdown; diff --git a/ui/components/ui/dropdown/dropdown.stories.js b/ui/components/ui/dropdown/dropdown.stories.js index 4eb0430eb..111ac5b65 100644 --- a/ui/components/ui/dropdown/dropdown.stories.js +++ b/ui/components/ui/dropdown/dropdown.stories.js @@ -1,13 +1,7 @@ import React from 'react'; -import { action } from '@storybook/addon-actions'; -import { boolean, select, text } from '@storybook/addon-knobs'; +import README from './README.mdx'; import Dropdown from '.'; -export default { - title: 'Dropdown', - id: __filename, -}; - const unnamedOptions = [...Array(10).keys()].map((index) => { return { value: `option${index}` }; }); @@ -23,63 +17,63 @@ const namedOptionsWithVeryLongNames = unnamedOptions.map((option, index) => { }; }); -export const simple = () => ( - option.value), - namedOptions[0].value, - )} - /> -); +export default { + title: 'Components/UI/Dropdown', + id: __filename, + component: Dropdown, + parameters: { + docs: { + page: README, + }, + }, + argTypes: { + className: { control: 'text' }, + disabled: { control: 'boolean' }, + title: { control: 'text' }, + onChange: { action: 'onChange' }, + options: { control: 'array' }, + selectedOption: { control: 'text' }, + style: { control: 'object' }, + }, +}; -export const optionsWithoutNames = () => ( - option.value), - unnamedOptions[0].value, - )} - /> -); +export const DefaultStory = (args) => ; -export const optionsWithLongNames = () => ( - option.value), - namedOptionsWithVeryLongNames[0].value, - )} - /> -); +DefaultStory.storyName = 'Default'; -export const optionsWithLongNamesAndShortWidth = () => ( - option.value), - namedOptionsWithVeryLongNames[0].value, - )} - style={{ width: '200px' }} - /> +DefaultStory.args = { + disabled: false, + title: 'Test Dropdown Name', + options: namedOptions, + selectedOption: namedOptions[0].value, +}; + +export const OptionsWithoutNames = (args) => ; + +OptionsWithoutNames.args = { + disabled: false, + title: 'Test Dropdown Name', + options: unnamedOptions, + selectedOption: unnamedOptions[0].value, +}; + +export const OptionsWithLongNames = (args) => ; + +OptionsWithLongNames.args = { + disabled: false, + title: 'Test Dropdown Name', + options: namedOptionsWithVeryLongNames, + selectedOption: namedOptionsWithVeryLongNames[0].value, +}; + +export const OptionsWithLongNamesAndShortWidth = (args) => ( + ); + +OptionsWithLongNamesAndShortWidth.args = { + disabled: false, + title: 'Test Dropdown Name', + options: namedOptionsWithVeryLongNames, + selectedOption: namedOptionsWithVeryLongNames[0].value, + style: { width: '200px' }, +}; diff --git a/ui/components/ui/editable-label/editable-label.js b/ui/components/ui/editable-label/editable-label.js index acda2400b..5c39d3d16 100644 --- a/ui/components/ui/editable-label/editable-label.js +++ b/ui/components/ui/editable-label/editable-label.js @@ -7,6 +7,11 @@ class EditableLabel extends Component { onSubmit: PropTypes.func.isRequired, defaultValue: PropTypes.string, className: PropTypes.string, + accountsNames: PropTypes.array, + }; + + static contextTypes = { + t: PropTypes.func, }; state = { @@ -16,8 +21,9 @@ class EditableLabel extends Component { handleSubmit() { const { value } = this.state; + const { accountsNames } = this.props; - if (value === '') { + if (value === '' || accountsNames.includes(value)) { return; } @@ -28,6 +34,7 @@ class EditableLabel extends Component { renderEditing() { const { value } = this.state; + const { accountsNames } = this.props; return [ this.setState({ value: event.target.value })} className={classnames('large-input', 'editable-label__input', { - 'editable-label__input--error': value === '', + 'editable-label__input--error': + value === '' || accountsNames.includes(value), })} autoFocus />, @@ -73,13 +81,25 @@ class EditableLabel extends Component { } render() { - const { isEditing } = this.state; - const { className } = this.props; + const { isEditing, value } = this.state; + const { className, accountsNames } = this.props; return ( -
- {isEditing ? this.renderEditing() : this.renderReadonly()} -
+ <> +
+ {isEditing ? this.renderEditing() : this.renderReadonly()} +
+ {accountsNames.includes(value) ? ( +
+ {this.context.t('accountNameDuplicate')} +
+ ) : null} + ); } } diff --git a/ui/components/ui/editable-label/index.scss b/ui/components/ui/editable-label/index.scss index 23c5df4a2..9e843e7a5 100644 --- a/ui/components/ui/editable-label/index.scss +++ b/ui/components/ui/editable-label/index.scss @@ -34,4 +34,15 @@ cursor: pointer; color: $dusty-gray; } + + &__error { + @include H7; + + left: 8px; + color: $red; + } + + &__error-amount { + margin-top: 5px; + } } diff --git a/ui/components/ui/error-message/README.mdx b/ui/components/ui/error-message/README.mdx new file mode 100644 index 000000000..c59f25dd4 --- /dev/null +++ b/ui/components/ui/error-message/README.mdx @@ -0,0 +1,15 @@ +import { Story, Canvas, ArgsTable } from '@storybook/addon-docs'; + +import ErrorMessage from '.'; + +# Error Message + +This component highlights error messages + + + + + +## Component API + + diff --git a/ui/components/ui/error-message/error-message.component.js b/ui/components/ui/error-message/error-message.component.js index 1786e332a..ceaf354db 100644 --- a/ui/components/ui/error-message/error-message.component.js +++ b/ui/components/ui/error-message/error-message.component.js @@ -18,7 +18,13 @@ const ErrorMessage = (props, context) => { }; ErrorMessage.propTypes = { + /** + * The text content for the error message + */ errorMessage: PropTypes.string, + /** + * The translate key for localization. Uses context.t(). Will override the error message + */ errorKey: PropTypes.string, }; diff --git a/ui/components/ui/error-message/error-message.stories.js b/ui/components/ui/error-message/error-message.stories.js index c803ae7b3..b3eb09c2e 100644 --- a/ui/components/ui/error-message/error-message.stories.js +++ b/ui/components/ui/error-message/error-message.stories.js @@ -1,12 +1,26 @@ import React from 'react'; -import { text } from '@storybook/addon-knobs'; +import README from './README.mdx'; import ErrorMessage from '.'; export default { - title: 'ErrorMessage', + title: 'Components/UI/ErrorMessage', id: __filename, + component: ErrorMessage, + parameters: { + docs: { + page: README, + }, + }, + argTypes: { + errorMessage: { control: 'text' }, + errorKey: { control: 'text' }, + }, }; -export const primaryType = () => ( - -); +export const DefaultStory = (args) => ; + +DefaultStory.storyName = 'Default'; + +DefaultStory.args = { + errorMessage: 'There was an error!', +}; diff --git a/ui/components/ui/form-field/README.mdx b/ui/components/ui/form-field/README.mdx new file mode 100644 index 000000000..2b610b36a --- /dev/null +++ b/ui/components/ui/form-field/README.mdx @@ -0,0 +1,31 @@ +import { Story, Canvas, ArgsTable } from '@storybook/addon-docs'; + +import FormField from '.'; + +# Form Field + +Various data fields available for forms and pages. + + + + + +## Component API + + + +### Title detail + +Show form fields with title detail on the left of the title + + + + + +### Error + +Show form fields with error state + + + + diff --git a/ui/components/ui/form-field/form-field.js b/ui/components/ui/form-field/form-field.js index 5cd1fd5b6..b8af84fab 100644 --- a/ui/components/ui/form-field/form-field.js +++ b/ui/components/ui/form-field/form-field.js @@ -113,19 +113,61 @@ export default function FormField({ } FormField.propTypes = { + /** + * Identifier for testing purpose + */ dataTestId: PropTypes.string, + /** + * Form Fields Title + */ titleText: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + /** + * Show unit (eg. ETH) + */ titleUnit: PropTypes.string, + /** + * Add Tooltip and text content + */ tooltipText: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + /** + * Show content (text, image, component) in title + */ titleDetail: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + /** + * Show error message + */ error: PropTypes.string, + /** + * Handler when fields change + */ onChange: PropTypes.func, + /** + * Field value + */ value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + /** + * Show detail text if field mode is numeric + */ detailText: PropTypes.string, + /** + * Set autofocus on render + */ autoFocus: PropTypes.bool, + /** + * Set numeric mode, the default is text + */ numeric: PropTypes.bool, + /** + * Set password mode + */ password: PropTypes.bool, + /** + * Allow decimals on the field + */ allowDecimals: PropTypes.bool, + /** + * Check if the form disabled + */ disabled: PropTypes.bool, }; diff --git a/ui/components/ui/form-field/form-field.stories.js b/ui/components/ui/form-field/form-field.stories.js index d555690eb..745c845a0 100644 --- a/ui/components/ui/form-field/form-field.stories.js +++ b/ui/components/ui/form-field/form-field.stories.js @@ -1,31 +1,54 @@ /* eslint-disable react/prop-types */ import React, { useState } from 'react'; -import { select } from '@storybook/addon-knobs'; +import README from './README.mdx'; import FormField from '.'; export default { - title: 'FormField', + title: 'Components/UI/FormField', id: __filename, + component: FormField, + parameters: { + docs: { + page: README, + }, + }, + argTypes: { + titleText: { control: 'text' }, + titleUnit: { control: 'text' }, + tooltipText: { control: 'text' }, + titleDetail: { + options: ['text', 'button', 'checkmark'], + control: { type: 'select' }, + }, + error: { control: 'text' }, + onChange: { action: 'onChange' }, + value: { control: 'number' }, + detailText: { control: 'text' }, + autoFocus: { control: 'boolean' }, + numeric: { control: 'boolean' }, + password: { control: 'boolean' }, + allowDecimals: { control: 'boolean' }, + disabled: { control: 'boolean' }, + }, }; -export const Plain = ({ ...props }) => { - const options = { text: false, numeric: true }; +export const DefaultStory = (args) => { const [value, setValue] = useState(''); return (
- +
); }; -export const FormFieldWithTitleDetail = () => { +DefaultStory.storyName = 'Default'; +DefaultStory.args = { + numeric: false, + titleText: 'Title', +}; + +export const FormFieldWithTitleDetail = (args) => { const [clicked, setClicked] = useState(false); const detailOptions = { text:
Detail
, @@ -39,18 +62,20 @@ export const FormFieldWithTitleDetail = () => { ), checkmark: , }; - return ( - - ); + + return ; +}; + +FormFieldWithTitleDetail.args = { + titleText: 'Title', + titleDetail: 'text', +}; + +export const FormFieldWithError = (args) => { + return ; }; -export const FormFieldWithError = () => { - return ; +FormFieldWithError.args = { + titleText: 'Title', + error: 'Incorrect Format', }; diff --git a/ui/components/ui/icon-button/icon-button.js b/ui/components/ui/icon-button/icon-button.js index 2f43f8334..dacd1ef25 100644 --- a/ui/components/ui/icon-button/icon-button.js +++ b/ui/components/ui/icon-button/icon-button.js @@ -36,11 +36,11 @@ export default function IconButton({ } IconButton.propTypes = { - 'onClick': PropTypes.func.isRequired, - 'Icon': PropTypes.func.isRequired, - 'disabled': PropTypes.bool, - 'label': PropTypes.string.isRequired, - 'tooltipRender': PropTypes.func, - 'className': PropTypes.string, + onClick: PropTypes.func.isRequired, + Icon: PropTypes.func.isRequired, + disabled: PropTypes.bool, + label: PropTypes.string.isRequired, + tooltipRender: PropTypes.func, + className: PropTypes.string, 'data-testid': PropTypes.string, }; diff --git a/ui/components/ui/icon/README.mdx b/ui/components/ui/icon/README.mdx new file mode 100644 index 000000000..1c7c0b739 --- /dev/null +++ b/ui/components/ui/icon/README.mdx @@ -0,0 +1,141 @@ +import { Story, Canvas, ArgsTable } from '@storybook/addon-docs'; + +import Approve from './approve-icon.component'; +import BuyIcon from './overview-buy-icon.component'; +import CopyIcon from './copy-icon.component'; +import InfoIcon from './info-icon.component'; +import InfoIconInverted from './info-icon-inverted.component'; +import Interaction from './interaction-icon.component'; +import PaperAirplane from './paper-airplane-icon'; +import Preloader from './preloader'; +import ReceiveIcon from './receive-icon.component'; +import SendIcon from './send-icon.component'; +import Sign from './sign-icon.component'; +import SunCheck from './sun-check-icon.component'; +import Swap from './swap-icon-for-list.component'; +import SwapIcon from './overview-send-icon.component'; +import SwapIconComponent from './swap-icon.component'; + +# Icon + +A range of SVG icon components + +> 💡 A lot of the our icons have different props and are not consistent we will need to work on fixing this. + +## Approve + + + + + + + +## Sign + + + + + + + +## Swap + + + + + + + +## SendIcon + + + + + + + +## ReceiveIcon + + + + + + + +## Interaction + + + + + + + +## InfoIcon + + + + + + + +## InfoIconInverted + + + + + + + +## SunCheck + + + + + + + +## BuyIcon + + + + + + + +## SwapIcon + + + + + + + +## Send/SwapIcon + + + + + + + +## PaperAirplane + + + + + +## CopyIcon + + + + + + + +## Preloader + + + + + + diff --git a/ui/components/ui/icon/approve-icon.component.js b/ui/components/ui/icon/approve-icon.component.js index e60d604da..07745eeea 100644 --- a/ui/components/ui/icon/approve-icon.component.js +++ b/ui/components/ui/icon/approve-icon.component.js @@ -28,8 +28,17 @@ Approve.defaultProps = { }; Approve.propTypes = { + /** + * Additional className + */ className: PropTypes.string, + /** + * Size of the icon should adhere to 8px grid. e.g: 8, 16, 24, 32, 40 + */ size: PropTypes.number.isRequired, + /** + * Color of the icon should be a valid design system color and is required + */ color: PropTypes.string.isRequired, }; diff --git a/ui/components/ui/icon/copy-icon.component.js b/ui/components/ui/icon/copy-icon.component.js index e9b2dc78c..5274398df 100644 --- a/ui/components/ui/icon/copy-icon.component.js +++ b/ui/components/ui/icon/copy-icon.component.js @@ -24,8 +24,17 @@ Copy.defaultProps = { }; Copy.propTypes = { + /** + * Additional className + */ className: PropTypes.string, + /** + * Size of the icon should adhere to 8px grid. e.g: 8, 16, 24, 32, 40 + */ size: PropTypes.number.isRequired, + /** + * Color of the icon should be a valid design system color and is required + */ color: PropTypes.string.isRequired, }; diff --git a/ui/components/ui/icon/icon.stories.js b/ui/components/ui/icon/icon.stories.js index 104dc1c0f..76a050203 100644 --- a/ui/components/ui/icon/icon.stories.js +++ b/ui/components/ui/icon/icon.stories.js @@ -1,56 +1,241 @@ +import PropTypes from 'prop-types'; import React from 'react'; -import { color, number, select } from '@storybook/addon-knobs'; import { SEVERITIES } from '../../../helpers/constants/design-system'; +import Card from '../card'; +import Typography from '../typography'; +import Box from '../box'; + +import README from './README.mdx'; + import Approve from './approve-icon.component'; -import Copy from './copy-icon.component'; -import Interaction from './interaction-icon.component'; -import Preloader from './preloader'; -import Receive from './receive-icon.component'; -import Send from './send-icon.component'; +import BuyIcon from './overview-buy-icon.component'; +import CopyIcon from './copy-icon.component'; import InfoIcon from './info-icon.component'; import InfoIconInverted from './info-icon-inverted.component'; +import Interaction from './interaction-icon.component'; +import PaperAirplane from './paper-airplane-icon'; +import Preloader from './preloader'; +import ReceiveIcon from './receive-icon.component'; +import SendIcon from './send-icon.component'; +import Sign from './sign-icon.component'; +import SunCheck from './sun-check-icon.component'; +import Swap from './swap-icon-for-list.component'; +import SwapIcon from './overview-send-icon.component'; +import SwapIconComponent from './swap-icon.component'; export default { - title: 'Icon', + title: 'Components/UI/Icon', id: __filename, + parameters: { + docs: { + page: README, + }, + }, }; -export const copy = () => ( - -); +const IconItem = ({ Component }) => { + return ( + + {Component} + {`${Component.type.__docgenInfo.displayName}`} + + ); +}; -export const send = () => ( - -); +IconItem.propTypes = { + Component: PropTypes.node, +}; -export const receive = () => ( - +export const DefaultStory = (args) => ( +
+ + Icons + + + + Circle Icons + +
+ } /> + } /> + } /> + } /> + } /> + } /> +
+
+ + + Invertible Icons + +
+ } /> + } /> + } /> + } /> +
+
+ + + Other Icons + +
+ } /> + } /> + } /> + } /> + } /> + } /> +
+
+
); -export const siteInteraction = () => ( - -); +DefaultStory.args = { + width: '17', + height: '21', + fill: '#2F80ED', + size: 40, + color: '#2F80ED', + severity: 'info', + reverseColors: false, +}; -export const approveSpendLimit = () => ( - -); +export const ApproveStory = (args) => ; +ApproveStory.args = { + size: 40, + color: '#2F80ED', +}; +ApproveStory.storyName = 'Approve'; -export const preloader = () => ; +export const SignStory = (args) => ; +SignStory.args = { + size: 40, + color: '#2F80ED', +}; +SignStory.storyName = 'Sign'; -export const PaperAirplane = () => ( - -); +export const SwapStory = (args) => ; +SwapStory.args = { + size: 40, + color: '#2F80ED', +}; +SwapStory.storyName = 'Swap'; -export const info = () => ( - -); +export const SendIconStory = (args) => ; +SendIconStory.args = { + size: 40, + color: '#2F80ED', +}; +SendIconStory.storyName = 'SendIcon'; -export const infoInverted = () => ( - -); +export const ReceiveIconStory = (args) => ; +ReceiveIconStory.args = { + size: 40, + color: '#2F80ED', +}; +ReceiveIconStory.storyName = 'ReceiveIcon'; + +export const InteractionStory = (args) => ; +InteractionStory.args = { + size: 40, + color: '#2F80ED', +}; +InteractionStory.storyName = 'Interaction'; + +export const InfoIconStory = (args) => ; +InfoIconStory.args = { + severity: SEVERITIES.INFO, +}; +InfoIconStory.argTypes = { + severity: { + control: 'select', + options: ['warning', 'info', 'danger', 'success'], + }, +}; +InfoIconStory.storyName = 'InfoIcon'; + +export const InfoIconInvertedStory = (args) => ; +InfoIconInvertedStory.args = { + severity: SEVERITIES.INFO, +}; +InfoIconInvertedStory.argTypes = { + severity: { + control: 'select', + options: ['warning', 'info', 'danger', 'success'], + }, +}; +InfoIconInvertedStory.storyName = 'InfoIconInverted'; + +export const SunCheckStory = (args) => ; +SunCheckStory.args = { + reverseColors: false, +}; +SunCheckStory.argTypes = { + reverseColors: { + control: 'boolean', + }, +}; +SunCheckStory.storyName = 'SunCheck'; + +export const BuyIconStory = (args) => ; +BuyIconStory.args = { + width: '17', + height: '21', + fill: '#2F80ED', +}; +BuyIconStory.storyName = 'BuyIcon'; + +export const SwapIconStory = (args) => ; +SwapIconStory.args = { + width: '17', + height: '21', + fill: '#2F80ED', +}; +SwapIconStory.storyName = 'SwapIcon'; + +export const SendSwapIconStory = (args) => ; +SendSwapIconStory.args = { + width: '17', + height: '17', + color: '#2F80ED', +}; +SendSwapIconStory.storyName = 'Send/SwapIcon'; + +export const PaperAirplaneStory = (args) => ; +PaperAirplaneStory.args = { + size: 40, + color: '#2F80ED', +}; +PaperAirplaneStory.storyName = 'PaperAirplane'; + +export const CopyIconStory = (args) => ; +CopyIconStory.args = { + size: 40, + color: '#2F80ED', +}; +CopyIconStory.storyName = 'CopyIcon'; + +export const PreloaderStory = (args) => ; +PreloaderStory.args = { + size: 40, +}; +PreloaderStory.storyName = 'Preloader'; diff --git a/ui/components/ui/icon/info-icon-inverted.component.js b/ui/components/ui/icon/info-icon-inverted.component.js index 3eef46cc0..8a48fae54 100644 --- a/ui/components/ui/icon/info-icon-inverted.component.js +++ b/ui/components/ui/icon/info-icon-inverted.component.js @@ -25,5 +25,8 @@ export default function InfoIconInverted({ severity }) { } InfoIconInverted.propTypes = { + /** + * Severity can be 1 of 4 states:'danger', 'warning', 'info' or 'success' + */ severity: PropTypes.oneOf(Object.values(SEVERITIES)), }; diff --git a/ui/components/ui/icon/info-icon.component.js b/ui/components/ui/icon/info-icon.component.js index 824a81a40..ad4f34975 100644 --- a/ui/components/ui/icon/info-icon.component.js +++ b/ui/components/ui/icon/info-icon.component.js @@ -24,5 +24,8 @@ export default function InfoIcon({ severity }) { } InfoIcon.propTypes = { + /** + * Severity can be 1 of 4 states:'danger', 'warning', 'info' or 'success' + */ severity: PropTypes.oneOf(Object.values(SEVERITIES)), }; diff --git a/ui/components/ui/icon/interaction-icon.component.js b/ui/components/ui/icon/interaction-icon.component.js index 93ef6e08e..6e5570ede 100644 --- a/ui/components/ui/icon/interaction-icon.component.js +++ b/ui/components/ui/icon/interaction-icon.component.js @@ -28,8 +28,17 @@ Interaction.defaultProps = { }; Interaction.propTypes = { + /** + * Additional className + */ className: PropTypes.string, + /** + * Size of the icon should adhere to 8px grid. e.g: 8, 16, 24, 32, 40 + */ size: PropTypes.number.isRequired, + /** + * Color of the icon should be a valid design system color and is required + */ color: PropTypes.string.isRequired, }; diff --git a/ui/components/ui/icon/overview-buy-icon.component.js b/ui/components/ui/icon/overview-buy-icon.component.js index c7be24b95..a8deb10ae 100644 --- a/ui/components/ui/icon/overview-buy-icon.component.js +++ b/ui/components/ui/icon/overview-buy-icon.component.js @@ -31,7 +31,16 @@ export default function BuyIcon({ } BuyIcon.propTypes = { + /** + * Width of the icon + */ width: PropTypes.string, + /** + * Height of the icon + */ height: PropTypes.string, + /** + * Fill of the icon should be a valid design system color + */ fill: PropTypes.string, }; diff --git a/ui/components/ui/icon/overview-send-icon.component.js b/ui/components/ui/icon/overview-send-icon.component.js index 05879ed2f..5c29e0d1d 100644 --- a/ui/components/ui/icon/overview-send-icon.component.js +++ b/ui/components/ui/icon/overview-send-icon.component.js @@ -23,7 +23,16 @@ export default function SwapIcon({ } SwapIcon.propTypes = { + /** + * Width of the icon + */ width: PropTypes.string, + /** + * Height of the icon + */ height: PropTypes.string, + /** + * Fill of the icon should be a valid design system color + */ fill: PropTypes.string, }; diff --git a/ui/components/ui/icon/paper-airplane-icon.js b/ui/components/ui/icon/paper-airplane-icon.js index 5bd156704..c27ce255d 100644 --- a/ui/components/ui/icon/paper-airplane-icon.js +++ b/ui/components/ui/icon/paper-airplane-icon.js @@ -26,7 +26,16 @@ PaperAirplane.defaultProps = { }; PaperAirplane.propTypes = { + /** + * Additional className + */ className: PropTypes.string, + /** + * Size of the icon should adhere to 8px grid. e.g: 8, 16, 24, 32, 40 and is required + */ size: PropTypes.number.isRequired, + /** + * Color of the icon should be a valid design system color + */ color: PropTypes.string, }; diff --git a/ui/components/ui/icon/receive-icon.component.js b/ui/components/ui/icon/receive-icon.component.js index 530fd5170..00c74f4f0 100644 --- a/ui/components/ui/icon/receive-icon.component.js +++ b/ui/components/ui/icon/receive-icon.component.js @@ -31,8 +31,17 @@ Receive.defaultProps = { }; Receive.propTypes = { + /** + * Additional className + */ className: PropTypes.string, + /** + * Size of the icon should adhere to 8px grid. e.g: 8, 16, 24, 32, 40 and is required + */ size: PropTypes.number.isRequired, + /** + * Color of the icon should be a valid design system color and is required + */ color: PropTypes.string.isRequired, }; diff --git a/ui/components/ui/icon/send-icon.component.js b/ui/components/ui/icon/send-icon.component.js index d1ee9d24f..f23614927 100644 --- a/ui/components/ui/icon/send-icon.component.js +++ b/ui/components/ui/icon/send-icon.component.js @@ -23,8 +23,17 @@ Send.defaultProps = { }; Send.propTypes = { + /** + * Additional className + */ className: PropTypes.string, + /** + * Size of the icon should adhere to 8px grid. e.g: 8, 16, 24, 32, 40 and is required + */ size: PropTypes.number.isRequired, + /** + * Color of the icon should be a valid design system color and is required + */ color: PropTypes.string.isRequired, }; diff --git a/ui/components/ui/icon/sign-icon.component.js b/ui/components/ui/icon/sign-icon.component.js index 887085826..2efd94f16 100644 --- a/ui/components/ui/icon/sign-icon.component.js +++ b/ui/components/ui/icon/sign-icon.component.js @@ -28,7 +28,16 @@ export default function Sign({ className, size, color }) { } Sign.propTypes = { + /** + * Additional className + */ className: PropTypes.string, + /** + * Size of the icon should adhere to 8px grid. e.g: 8, 16, 24, 32, 40 and is required + */ size: PropTypes.number.isRequired, + /** + * Color of the icon should be a valid design system color and is required + */ color: PropTypes.string.isRequired, }; diff --git a/ui/components/ui/icon/sun-check-icon.component.js b/ui/components/ui/icon/sun-check-icon.component.js index 31dd19404..582680516 100644 --- a/ui/components/ui/icon/sun-check-icon.component.js +++ b/ui/components/ui/icon/sun-check-icon.component.js @@ -25,5 +25,8 @@ export default function SunCheck({ reverseColors }) { } SunCheck.propTypes = { + /** + * If true reverses the colors of the SunCheck icon + */ reverseColors: PropTypes.bool, }; diff --git a/ui/components/ui/icon/swap-icon-for-list.component.js b/ui/components/ui/icon/swap-icon-for-list.component.js index 5c20b51f5..81e0c7af2 100644 --- a/ui/components/ui/icon/swap-icon-for-list.component.js +++ b/ui/components/ui/icon/swap-icon-for-list.component.js @@ -28,8 +28,17 @@ Swap.defaultProps = { }; Swap.propTypes = { + /** + * Additional className + */ className: PropTypes.string, + /** + * Size of the icon should adhere to 8px grid. e.g: 8, 16, 24, 32, 40 and is required + */ size: PropTypes.number.isRequired, + /** + * Color of the icon should be a valid design system color and is required + */ color: PropTypes.string.isRequired, }; diff --git a/ui/components/ui/icon/swap-icon.component.js b/ui/components/ui/icon/swap-icon.component.js index 32dd6f916..b55a68d38 100644 --- a/ui/components/ui/icon/swap-icon.component.js +++ b/ui/components/ui/icon/swap-icon.component.js @@ -25,7 +25,16 @@ export default function SwapIcon({ } SwapIcon.propTypes = { + /** + * Width of the icon + */ width: PropTypes.string, + /** + * Height of the icon + */ height: PropTypes.string, + /** + * Color of the icon should be a valid design system color + */ color: PropTypes.string, }; diff --git a/ui/components/ui/identicon/README.mdx b/ui/components/ui/identicon/README.mdx new file mode 100644 index 000000000..8f36cc26b --- /dev/null +++ b/ui/components/ui/identicon/README.mdx @@ -0,0 +1,37 @@ +import { Story, Canvas, ArgsTable } from '@storybook/addon-docs'; + +import Identicon from './identicon.component'; + +# Identicon + +An identifier component that can be supplied an image or randomly generates one based on the account address. + + + + + +## Component API + + + +## Usage + +### With Image + +Use with custom image + + + + + +### With Blockie + + + + + +### With Border + + + + diff --git a/ui/components/ui/identicon/identicon.component.js b/ui/components/ui/identicon/identicon.component.js index 8215690d0..2c004621b 100644 --- a/ui/components/ui/identicon/identicon.component.js +++ b/ui/components/ui/identicon/identicon.component.js @@ -12,15 +12,47 @@ const getStyles = (diameter) => ({ export default class Identicon extends PureComponent { static propTypes = { + /** + * Adds blue border around the Identicon used for selected account. + * Increases the width and height of the Identicon by 8px + */ addBorder: PropTypes.bool, + /** + * Address used for generating random image + */ address: PropTypes.string, + /** + * Add custom css class + */ className: PropTypes.string, + /** + * Sets the width and height of the inner img element + * If addBorder is true will increase components height and width by 8px + */ diameter: PropTypes.number, + /** + * Used as the image source of the Identicon + */ image: PropTypes.string, + /** + * Use the blockie type random image generator + */ useBlockie: PropTypes.bool, + /** + * The alt text of the image + */ alt: PropTypes.string, + /** + * Check if show image border + */ imageBorder: PropTypes.bool, + /** + * Check if use token detection + */ useTokenDetection: PropTypes.bool, + /** + * Add list of token in object + */ tokenList: PropTypes.object, }; @@ -95,6 +127,8 @@ export default class Identicon extends PureComponent { useTokenDetection, tokenList, } = this.props; + const size = diameter + 8; + if (image) { return this.renderImage(); } @@ -111,6 +145,7 @@ export default class Identicon extends PureComponent { return (
{useBlockie ? this.renderBlockie() : this.renderJazzicon()}
diff --git a/ui/components/ui/identicon/identicon.stories.js b/ui/components/ui/identicon/identicon.stories.js index b7103ff0f..781a9e628 100644 --- a/ui/components/ui/identicon/identicon.stories.js +++ b/ui/components/ui/identicon/identicon.stories.js @@ -1,47 +1,62 @@ import React from 'react'; -import { text, boolean, number } from '@storybook/addon-knobs'; +import README from './README.mdx'; import Identicon from './identicon.component'; export default { - title: 'Identicon', + title: 'Components/UI/Identicon', id: __filename, + component: Identicon, + parameters: { + docs: { + page: README, + }, + }, + argTypes: { + addBorder: { control: 'boolean' }, + address: { control: 'text' }, + className: { control: 'text' }, + diameter: { control: 'number' }, + image: { control: 'text' }, + useBlockie: { control: 'boolean' }, + alt: { control: 'text' }, + imageBorder: { control: 'boolean' }, + useTokenDetection: { control: 'boolean' }, + tokenList: { control: 'object' }, + }, }; -const diameterOptions = { - range: true, - min: 10, - max: 200, - step: 1, -}; -export const standard = () => ( - -); +export const DefaultStory = (args) => ; -export const image = () => ; +DefaultStory.storyName = 'Default'; +DefaultStory.args = { + addBorder: false, + address: '0x5CfE73b6021E818B776b421B1c4Db2474086a7e1', + diameter: 32, + useBlockie: false, +}; -export const blockie = () => ( - -); +export const WithImage = (args) => ; +WithImage.args = { + addBorder: false, + diameter: 32, + useBlockie: false, + image: './images/eth_logo.svg', + alt: 'Ethereum', + imageBorder: true, +}; -// The border size is hard-coded in CSS, and was designed with this size identicon in mind -const withBorderDiameter = 32; +export const WithBlockie = (args) => ; +WithBlockie.args = { + addBorder: false, + address: '0x5CfE73b6021E818B776b421B1c4Db2474086a7e1', + diameter: 32, + useBlockie: true, +}; -export const withBorder = () => ( - -); +export const WithBorder = (args) => ; +WithBorder.args = { + addBorder: true, + address: '0x5CfE73b6021E818B776b421B1c4Db2474086a7e1', + diameter: 32, + useBlockie: false, +}; diff --git a/ui/components/ui/identicon/index.scss b/ui/components/ui/identicon/index.scss index a467f4bc5..5f67c8b3a 100644 --- a/ui/components/ui/identicon/index.scss +++ b/ui/components/ui/identicon/index.scss @@ -8,9 +8,6 @@ overflow: hidden; &__address-wrapper { - height: 40px; - width: 40px; - border-radius: 18px; display: flex; justify-content: center; align-items: center; diff --git a/ui/components/ui/info-tooltip/README.mdx b/ui/components/ui/info-tooltip/README.mdx new file mode 100644 index 000000000..06f49f746 --- /dev/null +++ b/ui/components/ui/info-tooltip/README.mdx @@ -0,0 +1,37 @@ +import { Story, Canvas, ArgsTable } from '@storybook/addon-docs'; + +import InfoTooltip from './info-tooltip'; + +# Info Tooltip + +Text labels that appear when the user hovers over + + + + + +## Component API + + + +## Usage + +The following describes the props and example usage for this component. + +### Bottom + + + + + +### Left + + + + + +### Right + + + + diff --git a/ui/components/ui/info-tooltip/info-tooltip.js b/ui/components/ui/info-tooltip/info-tooltip.js index e590daebd..54d280085 100644 --- a/ui/components/ui/info-tooltip/info-tooltip.js +++ b/ui/components/ui/info-tooltip/info-tooltip.js @@ -41,10 +41,28 @@ export default function InfoTooltip({ } InfoTooltip.propTypes = { + /** + * Text label that shows up after hover + */ contentText: PropTypes.node, + /** + * Shows position of the tooltip + */ position: PropTypes.oneOf(['top', 'left', 'bottom', 'right']), + /** + * Set if the tooltip wide + */ wide: PropTypes.bool, + /** + * Add custom CSS class for container + */ containerClassName: PropTypes.string, + /** + * Add custom CSS class for the wrapper + */ wrapperClassName: PropTypes.string, + /** + * Add color for the icon + */ iconFillColor: PropTypes.string, }; diff --git a/ui/components/ui/info-tooltip/info-tooltip.stories.js b/ui/components/ui/info-tooltip/info-tooltip.stories.js index 22044191f..c77580bf6 100644 --- a/ui/components/ui/info-tooltip/info-tooltip.stories.js +++ b/ui/components/ui/info-tooltip/info-tooltip.stories.js @@ -1,48 +1,54 @@ import React from 'react'; -import { text } from '@storybook/addon-knobs'; +import README from './README.mdx'; import InfoTooltip from './info-tooltip'; export default { - title: 'InfoTooltip', + title: 'Components/UI/InfoTooltip', id: __filename, + component: InfoTooltip, + parameters: { + docs: { + page: README, + }, + }, + argTypes: { + contentText: { control: 'text' }, + position: { + control: 'select', + options: ['top', 'left', 'bottom', 'right'], + }, + wide: { control: 'boolean' }, + containerClassName: { control: 'text' }, + wrapperClassName: { control: 'text' }, + iconFillColor: { control: 'text' }, + }, }; -export const Top = () => ( - -); +export const DefaultStory = (args) => ; +DefaultStory.storyName = 'Default'; +DefaultStory.args = { + position: 'top', + contentText: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut gravida dictum diam et sagittis. Sed lorem arcu, consectetur consectetur et, lacinia hendrerit sapien.', +}; -export const Bottom = () => ( - -); +export const Bottom = (args) => ; +Bottom.args = { + position: 'bottom', + contentText: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut gravida dictum diam et sagittis. Sed lorem arcu, consectetur consectetur et, lacinia hendrerit sapien.', +}; -export const Left = () => ( - -); +export const Left = (args) => ; +Left.args = { + position: 'left', + contentText: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut gravida dictum diam et sagittis. Sed lorem arcu, consectetur consectetur et, lacinia hendrerit sapien.', +}; -export const Right = () => ( - -); +export const Right = (args) => ; +Right.args = { + position: 'right', + contentText: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut gravida dictum diam et sagittis. Sed lorem arcu, consectetur consectetur et, lacinia hendrerit sapien.', +}; diff --git a/ui/components/ui/list-item/list-item.component.js b/ui/components/ui/list-item/list-item.component.js index 699e24da2..9a13cbaf1 100644 --- a/ui/components/ui/list-item/list-item.component.js +++ b/ui/components/ui/list-item/list-item.component.js @@ -59,14 +59,14 @@ export default function ListItem({ } ListItem.propTypes = { - 'title': PropTypes.oneOfType([PropTypes.string, PropTypes.node]), - 'titleIcon': PropTypes.node, - 'subtitle': PropTypes.node, - 'children': PropTypes.node, - 'icon': PropTypes.node, - 'rightContent': PropTypes.node, - 'midContent': PropTypes.node, - 'className': PropTypes.string, - 'onClick': PropTypes.func, + title: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + titleIcon: PropTypes.node, + subtitle: PropTypes.node, + children: PropTypes.node, + icon: PropTypes.node, + rightContent: PropTypes.node, + midContent: PropTypes.node, + className: PropTypes.string, + onClick: PropTypes.func, 'data-testid': PropTypes.string, }; diff --git a/ui/components/ui/list-item/list-item.stories.js b/ui/components/ui/list-item/list-item.stories.js index 95b0746b3..4dd8388b3 100644 --- a/ui/components/ui/list-item/list-item.stories.js +++ b/ui/components/ui/list-item/list-item.stories.js @@ -10,7 +10,7 @@ import Button from '../button'; import ListItem from './list-item.component'; export default { - title: 'ListItem', + title: 'Components/UI/ListItem', id: __filename, }; @@ -31,7 +31,7 @@ Currencies.propTypes = { const okColor = '#2F80ED'; const failColor = '#D73A49'; -export const send = () => ( +export const SendComponent = () => ( } titleIcon={} @@ -54,7 +54,7 @@ export const send = () => ( ); -export const pending = () => ( +export const PendingComponent = () => ( } title={text('title', 'Hatch Turtles')} @@ -74,7 +74,7 @@ export const pending = () => ( /> ); -export const approve = () => ( +export const ApproveComponent = () => ( } title={text('title', 'Approve spend limit')} @@ -89,7 +89,7 @@ export const approve = () => ( /> ); -export const receive = () => ( +export const ReceiveComponent = () => ( } title={text('title', 'Hatch Turtles')} diff --git a/ui/components/ui/loading-heartbeat/index.js b/ui/components/ui/loading-heartbeat/index.js index 2c8e491e8..189d0cc2b 100644 --- a/ui/components/ui/loading-heartbeat/index.js +++ b/ui/components/ui/loading-heartbeat/index.js @@ -11,6 +11,8 @@ export default function LoadingHeartBeat() { useShouldAnimateGasEstimations(); const active = useSelector(getGasLoadingAnimationIsShowing); + if (process.env.IN_TEST) return null; + return (
{ const [lookAtDirection, setLookAtDirection] = useState(null); const [followMouseMode, setFollowMouseMode] = useState(false); const [clickToLookMode, setClickToLookMode] = useState(false); @@ -88,4 +88,6 @@ export function Demo() {
); -} +}; + +DefaultStory.storyName = 'Default'; diff --git a/ui/components/ui/menu/menu-item.js b/ui/components/ui/menu/menu-item.js index f35449b84..f62c76ba9 100644 --- a/ui/components/ui/menu/menu-item.js +++ b/ui/components/ui/menu/menu-item.js @@ -24,20 +24,20 @@ const MenuItem = ({ ); MenuItem.propTypes = { - 'children': PropTypes.node.isRequired, - 'className': PropTypes.string, + children: PropTypes.node.isRequired, + className: PropTypes.string, 'data-testid': PropTypes.string, - 'iconClassName': PropTypes.string, - 'onClick': PropTypes.func, - 'subtitle': PropTypes.node, + iconClassName: PropTypes.string, + onClick: PropTypes.func, + subtitle: PropTypes.node, }; MenuItem.defaultProps = { - 'className': undefined, + className: undefined, 'data-testid': undefined, - 'iconClassName': undefined, - 'onClick': undefined, - 'subtitle': undefined, + iconClassName: undefined, + onClick: undefined, + subtitle: undefined, }; export default MenuItem; diff --git a/ui/components/ui/menu/menu.stories.js b/ui/components/ui/menu/menu.stories.js index 73e0291eb..9585b6cfd 100644 --- a/ui/components/ui/menu/menu.stories.js +++ b/ui/components/ui/menu/menu.stories.js @@ -3,11 +3,11 @@ import { action } from '@storybook/addon-actions'; import { Menu, MenuItem } from '.'; export default { - title: 'Menu', + title: 'Components/UI/Menu', id: __filename, }; -export const Basic = () => { +export const DefaultStory = () => { return ( @@ -21,6 +21,8 @@ export const Basic = () => { ); }; +DefaultStory.storyName = 'Default'; + export const Anchored = () => { const [anchorElement, setAnchorElement] = useState(null); return ( diff --git a/ui/components/ui/nickname-popover/nickname-popover.stories.js b/ui/components/ui/nickname-popover/nickname-popover.stories.js index a1097e32c..9cf1921da 100644 --- a/ui/components/ui/nickname-popover/nickname-popover.stories.js +++ b/ui/components/ui/nickname-popover/nickname-popover.stories.js @@ -5,11 +5,11 @@ import Button from '../button'; import NicknamePopover from '.'; export default { - title: 'NicknamePopover', + title: 'Components/UI/NicknamePopover', id: __filename, }; -export const Default = () => { +export const DefaultStory = () => { const [showNicknamePopover, setShowNicknamePopover] = useState(false); return ( @@ -30,3 +30,5 @@ export const Default = () => {
); }; + +DefaultStory.storyName = 'Default'; diff --git a/ui/components/ui/numeric-input/numeric-input.stories.js b/ui/components/ui/numeric-input/numeric-input.stories.js index c86988bb6..79e241d69 100644 --- a/ui/components/ui/numeric-input/numeric-input.stories.js +++ b/ui/components/ui/numeric-input/numeric-input.stories.js @@ -2,13 +2,13 @@ import React from 'react'; import NumericInput from '.'; export default { - title: 'NumericInput', + title: 'Components/UI/NumericInput', id: __filename, }; const onChange = (e) => console.log('changed value: ', e.target.value); -export const numericInput = () => { +export const DefaultStory = () => { return (
@@ -16,7 +16,9 @@ export const numericInput = () => { ); }; -export const numericInputWithDetail = () => { +DefaultStory.storyName = 'Default'; + +export const WithDetail = () => { return (
@@ -24,7 +26,7 @@ export const numericInputWithDetail = () => { ); }; -export const numericInputWithError = () => { +export const WithError = () => { return (
+ + + +## Component API + + + diff --git a/ui/components/ui/popover/popover.component.js b/ui/components/ui/popover/popover.component.js index 637f07528..a2922daea 100644 --- a/ui/components/ui/popover/popover.component.js +++ b/ui/components/ui/popover/popover.component.js @@ -81,20 +81,56 @@ const Popover = ({ }; Popover.propTypes = { + /** + * Show title of the popover + */ title: PropTypes.string, + /** + * Show subtitle label on popover + */ subtitle: PropTypes.string, + /** + * Show children content could be react child or text + */ children: PropTypes.node, + /** + * Show footer content could be react child or text + */ footer: PropTypes.node, + /** + * Add custom CSS class for footer + */ footerClassName: PropTypes.string, + /** + * onBack handler + */ onBack: PropTypes.func, + /** + * onClose handler + */ onClose: PropTypes.func, CustomBackground: PropTypes.func, + /** + * Add custom CSS class for content + */ contentClassName: PropTypes.string, + /** + * Add custom CSS class + */ className: PropTypes.string, + /** + * Check if component would show arror + */ showArrow: PropTypes.bool, + /** + * The ref of the popover-wrap element + */ popoverRef: PropTypes.shape({ current: PropTypes.instanceOf(window.Element), }), + /** + * Check if use centered title + */ centerTitle: PropTypes.bool, }; diff --git a/ui/components/ui/popover/popover.stories.js b/ui/components/ui/popover/popover.stories.js index 960f9c994..e23bd91c4 100644 --- a/ui/components/ui/popover/popover.stories.js +++ b/ui/components/ui/popover/popover.stories.js @@ -1,65 +1,83 @@ -import React from 'react'; -import { text } from '@storybook/addon-knobs'; -import { action } from '@storybook/addon-actions'; +import React, { useState } from 'react'; +import Button from '../button'; +import Box from '../box'; +import README from './README.mdx'; import Popover from './popover.component'; -const containerStyle = { - width: 800, - height: 600, - background: 'pink', - position: 'relative', +export default { + title: 'Components/UI/Popover', + id: __filename, + component: Popover, + parameters: { + docs: { + page: README, + }, + }, + argTypes: { + title: { control: 'text' }, + subtitle: { control: 'text' }, + children: { control: 'object' }, + footer: { control: 'object' }, + footerClassName: { control: 'text' }, + onBack: { action: 'onBack' }, + onClose: { action: 'onClose' }, + contentClassName: { control: 'text' }, + className: { control: 'text' }, + showArrow: { control: 'boolean' }, + popoverRef: { control: 'object' }, + centerTitle: { control: 'boolean' }, + }, }; -const mainWrapperStyle = { - padding: '0 24px 24px', +export const DefaultStory = (args) => { + const [isShowingPopover, setIsShowingPopover] = useState(false); + return ( +
+ + {isShowingPopover && ( + setIsShowingPopover(false)} + title={args.title} + subtitle={args.subtitle} + footer={args.footer} + > + {args.children} + + )} +
+ ); }; -export default { - title: 'Popover', - id: __filename, +DefaultStory.storyName = 'Default'; +DefaultStory.args = { + title: 'Approve spend limit', + subtitle: 'This is the new limit', + footer: , + showArrow: false, + children: ( + +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod + tempor incididunt ut labore et dolore magna aliqua. Semper eget duis at + tellus at urna condimentum. Posuere urna nec tincidunt praesent semper. + Arcu dictum varius duis at. A lacus vestibulum sed arcu. Orci porta non + pulvinar neque laoreet suspendisse interdum. Pretium fusce id velit ut. + Ut consequat semper viverra nam libero justo laoreet sit. In ante metus + dictum at tempor commodo ullamcorper a lacus. Posuere morbi leo urna + molestie at elementum eu facilisis sed. Libero enim sed faucibus turpis + in eu mi bibendum neque. Amet massa vitae tortor condimentum lacinia + quis. Pretium viverra suspendisse potenti nullam ac. Pellentesque elit + eget gravida cum sociis natoque penatibus. Proin libero nunc consequat + interdum varius sit amet. Est ultricies integer quis auctor elit sed + vulputate. Ornare arcu odio ut sem nulla pharetra. Eget nullam non nisi + est sit. Leo vel fringilla est ullamcorper eget nulla. +

+
+ ), }; - -export const approve = () => ( -
- Example Footer} - > -
-

- Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do - eiusmod tempor incididunt ut labore et dolore magna aliqua. Semper - eget duis at tellus at urna condimentum. Posuere urna nec tincidunt - praesent semper. Arcu dictum varius duis at. A lacus vestibulum sed - arcu. Orci porta non pulvinar neque laoreet suspendisse interdum. - Pretium fusce id velit ut. Ut consequat semper viverra nam libero - justo laoreet sit. In ante metus dictum at tempor commodo ullamcorper - a lacus. Posuere morbi leo urna molestie at elementum eu facilisis - sed. Libero enim sed faucibus turpis in eu mi bibendum neque. Amet - massa vitae tortor condimentum lacinia quis. Pretium viverra - suspendisse potenti nullam ac. Pellentesque elit eget gravida cum - sociis natoque penatibus. Proin libero nunc consequat interdum varius - sit amet. Est ultricies integer quis auctor elit sed vulputate. Ornare - arcu odio ut sem nulla pharetra. Eget nullam non nisi est sit. Leo vel - fringilla est ullamcorper eget nulla. -

-

- Mattis pellentesque id nibh tortor id. Commodo sed egestas egestas - fringilla phasellus. Semper eget duis at tellus at urna. Tristique - nulla aliquet enim tortor at auctor urna nunc. Pellentesque habitant - morbi tristique senectus et netus et. Turpis egestas sed tempus urna - et pharetra pharetra massa massa. Mi eget mauris pharetra et ultrices - neque ornare aenean. Facilisis volutpat est velit egestas dui id - ornare arcu odio. Lacus sed turpis tincidunt id aliquet risus feugiat - in. Cras tincidunt lobortis feugiat vivamus. Blandit libero volutpat - sed cras ornare arcu. Facilisi morbi tempus iaculis urna id volutpat. - Risus viverra adipiscing at in tellus. Leo vel orci porta non pulvinar - neque. Malesuada fames ac turpis egestas integer. Euismod nisi porta - lorem mollis aliquam. -

-
-
-
-); diff --git a/ui/components/ui/pulse-loader/README.mdx b/ui/components/ui/pulse-loader/README.mdx new file mode 100644 index 000000000..8bd5e4037 --- /dev/null +++ b/ui/components/ui/pulse-loader/README.mdx @@ -0,0 +1,11 @@ +import { Story, Canvas, ArgsTable } from '@storybook/addon-docs'; + +import PulseLoader from '.'; + +# Pulse Loader + +Loading indicator with 3 pulsing dots + + + + diff --git a/ui/components/ui/pulse-loader/pulse-loader.stories.js b/ui/components/ui/pulse-loader/pulse-loader.stories.js index 122658d50..ed35145bc 100644 --- a/ui/components/ui/pulse-loader/pulse-loader.stories.js +++ b/ui/components/ui/pulse-loader/pulse-loader.stories.js @@ -1,9 +1,18 @@ import React from 'react'; +import README from './README.mdx'; import PulseLoader from '.'; export default { - title: 'PulseLoader', + title: 'Components/UI/PulseLoader', id: __filename, + component: PulseLoader, + parameters: { + docs: { + page: README, + }, + }, }; -export const common = () => ; +export const DefaultStory = () => ; + +DefaultStory.storyName = 'Default'; diff --git a/ui/components/ui/radio-group/README.mdx b/ui/components/ui/radio-group/README.mdx new file mode 100644 index 000000000..d9d1b447a --- /dev/null +++ b/ui/components/ui/radio-group/README.mdx @@ -0,0 +1,15 @@ +import { Story, Canvas, ArgsTable } from '@storybook/addon-docs'; + +import RadioGroup from '.'; + +# Radio Group + +A multiple-exclusion scope for a set of radio buttons. + + + + + +## Component API + + diff --git a/ui/components/ui/radio-group/radio-group.component.js b/ui/components/ui/radio-group/radio-group.component.js index 90c136fb9..a0501b4fc 100644 --- a/ui/components/ui/radio-group/radio-group.component.js +++ b/ui/components/ui/radio-group/radio-group.component.js @@ -85,9 +85,21 @@ export default function RadioGroup({ options, name, selectedValue, onChange }) { } RadioGroup.propTypes = { + /** + * Predefined options for radio group + */ options: PropTypes.array, + /** + * Show selected value + */ selectedValue: PropTypes.string, + /** + * Show name as label + */ name: PropTypes.string, + /** + * Handler for onChange + */ onChange: PropTypes.func, }; diff --git a/ui/components/ui/radio-group/radio-group.stories.js b/ui/components/ui/radio-group/radio-group.stories.js index c614e7bad..481cb9aef 100644 --- a/ui/components/ui/radio-group/radio-group.stories.js +++ b/ui/components/ui/radio-group/radio-group.stories.js @@ -1,28 +1,43 @@ import React from 'react'; import { GAS_RECOMMENDATIONS } from '../../../../shared/constants/gas'; +import README from './README.mdx'; import RadioGroup from '.'; export default { - title: 'RadioGroup', + title: 'Components/UI/RadioGroup', id: __filename, + component: RadioGroup, + parameters: { + docs: { + page: README, + }, + }, + argTypes: { + options: { control: 'array' }, + selectedValue: { control: 'text' }, + name: { control: 'text' }, + onChange: { action: 'onChange' }, + }, }; -export const radioGroup = () => { +export const DefaultStory = (args) => { return (
- +
); }; +DefaultStory.storyName = 'Default'; +DefaultStory.args = { + name: 'gas-recommendation', + options: [ + { value: GAS_RECOMMENDATIONS.LOW, label: 'Low', recommended: false }, + { + value: GAS_RECOMMENDATIONS.MEDIUM, + label: 'Medium', + recommended: false, + }, + { value: GAS_RECOMMENDATIONS.HIGH, label: 'High', recommended: true }, + ], + selectedValue: GAS_RECOMMENDATIONS.HIGH, +}; diff --git a/ui/components/ui/site-origin/index.js b/ui/components/ui/site-origin/index.js new file mode 100644 index 000000000..280ddb249 --- /dev/null +++ b/ui/components/ui/site-origin/index.js @@ -0,0 +1 @@ +export { default } from './site-origin'; diff --git a/ui/components/ui/site-origin/index.scss b/ui/components/ui/site-origin/index.scss new file mode 100644 index 000000000..16b9f0732 --- /dev/null +++ b/ui/components/ui/site-origin/index.scss @@ -0,0 +1,15 @@ +.site-origin { + .chip__left-icon { + padding: 4px 0 4px 8px; + } + + span { + display: inline-block; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + + /*rtl:ignore*/ + direction: rtl; + } +} diff --git a/ui/components/ui/site-origin/site-origin.js b/ui/components/ui/site-origin/site-origin.js new file mode 100644 index 000000000..2dc545a68 --- /dev/null +++ b/ui/components/ui/site-origin/site-origin.js @@ -0,0 +1,22 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Chip from '../chip'; +import IconWithFallback from '../icon-with-fallback'; + +export default function SiteOrigin({ siteOrigin, iconSrc, iconName }) { + return ( +
+ } + /> +
+ ); +} + +SiteOrigin.propTypes = { + siteOrigin: PropTypes.string.isRequired, + iconName: PropTypes.string, + iconSrc: PropTypes.string, +}; diff --git a/ui/components/ui/site-origin/site-origin.stories.js b/ui/components/ui/site-origin/site-origin.stories.js new file mode 100644 index 000000000..53a11c190 --- /dev/null +++ b/ui/components/ui/site-origin/site-origin.stories.js @@ -0,0 +1,30 @@ +import React from 'react'; + +import SiteOrigin from '.'; + +export default { + title: 'Components/UI/SiteOrigin', + id: __filename, + component: SiteOrigin, + argTypes: { + siteOrigin: { + control: 'text', + }, + iconSrc: { + control: 'text', + }, + iconName: { + control: 'text', + }, + }, +}; + +export const DefaultStory = (args) => ; + +DefaultStory.storyName = 'Default'; + +DefaultStory.args = { + siteOrigin: 'https://metamask.io', + iconName: 'MetaMask', + iconSrc: './metamark.svg', +}; diff --git a/ui/components/ui/slider/README.mdx b/ui/components/ui/slider/README.mdx new file mode 100644 index 000000000..29c8c4378 --- /dev/null +++ b/ui/components/ui/slider/README.mdx @@ -0,0 +1,37 @@ +import { Story, Canvas, ArgsTable } from '@storybook/addon-docs'; + +import Slider from '.'; + +# Slider + +Slider component to choose a value from predefined range of values. + + + + + +## Usage + +### With Steps + +Slider with predefined total steps + + + + + +### With Header + +Slider with header title + + + + + +### With Footer + +Slider with footer + + + + diff --git a/ui/components/ui/slider/slider.component.js b/ui/components/ui/slider/slider.component.js index 9bdb7309b..63be28236 100644 --- a/ui/components/ui/slider/slider.component.js +++ b/ui/components/ui/slider/slider.component.js @@ -28,14 +28,14 @@ const styles = { height: 6, }, thumb: { - 'height': 20, - 'width': 20, - 'marginTop': -7, - 'marginLeft': -7, - 'backgroundColor': '#037DD6', - 'border': '1px solid #EAF6FF', - 'boxSizing': 'border-box', - 'boxShadow': '0px 0px 14px 0px rgba(0, 0, 0, 0.18)', + height: 20, + width: 20, + marginTop: -7, + marginLeft: -7, + backgroundColor: '#037DD6', + border: '1px solid #EAF6FF', + boxSizing: 'border-box', + boxShadow: '0px 0px 14px 0px rgba(0, 0, 0, 0.18)', '&:focus, &$active': { height: 20, width: 20, @@ -118,17 +118,53 @@ Slider.defaultProps = { }; Slider.propTypes = { + /** + * Show edit text + */ editText: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + /** + * Show info text + */ infoText: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + /** + * Show title detail text + */ titleDetail: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + /** + * Show title text + */ titleText: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + /** + * Show tooltip Text + */ tooltipText: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + /** + * Show value text + */ valueText: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + /** + * Set maximum step + */ max: PropTypes.number, + /** + * Set minimum step + */ min: PropTypes.number, + /** + * Handler for onChange + */ onChange: PropTypes.func, + /** + * Handler for onEdit + */ onEdit: PropTypes.func, + /** + * Total steps + */ step: PropTypes.number, + /** + * Show value on slider + */ value: PropTypes.number, }; diff --git a/ui/components/ui/slider/slider.stories.js b/ui/components/ui/slider/slider.stories.js index 316ae3929..a41f71a68 100644 --- a/ui/components/ui/slider/slider.stories.js +++ b/ui/components/ui/slider/slider.stories.js @@ -1,33 +1,53 @@ import React from 'react'; +import README from './README.mdx'; import Slider from '.'; export default { - title: 'Slider', + title: 'Components/UI/Slider', id: __filename, + component: Slider, + parameters: { + docs: { + page: README, + }, + }, + argTypes: { + editText: { control: 'text' }, + infoText: { control: 'text' }, + titleDetail: { control: 'text' }, + titleText: { control: 'text' }, + tooltipText: { control: 'text' }, + valueText: { control: 'text' }, + max: { control: 'number' }, + min: { control: 'number' }, + onChange: { action: 'onChange' }, + onEdit: { action: 'onEdit' }, + step: { control: 'number' }, + value: { control: 'number' }, + }, }; -export const slider = () => ; +export const DefaultStory = (args) => ; +DefaultStory.storyName = 'Default'; -export const sliderWithSteps = () => ; +export const WithSteps = (args) => ; +WithSteps.args = { + step: 10, +}; -export const sliderWithHeader = () => ( - -); +export const WithHeader = (args) => ; +WithHeader.args = { + titleText: 'Slider Title Text', + tooltipText: 'Slider Tooltip Text', + valueText: '$ 00.00', + titleDetail: '100 GWEI', +}; -export const sliderWithFooter = () => ( - { - console.log('on edit click'); - }} - /> -); +export const WithFooter = (args) => ; +WithFooter.args = { + titleText: 'Slider Title Text', + tooltipText: 'Slider Tooltip Text', + valueText: '$ 00.00', + titleDetail: '100 GWEI', + infoText: 'Footer Info Text', +}; diff --git a/ui/components/ui/tabs/tab/tab.component.js b/ui/components/ui/tabs/tab/tab.component.js index 1335511b2..63325c025 100644 --- a/ui/components/ui/tabs/tab/tab.component.js +++ b/ui/components/ui/tabs/tab/tab.component.js @@ -31,13 +31,13 @@ const Tab = (props) => { }; Tab.propTypes = { - 'activeClassName': PropTypes.string, - 'className': PropTypes.string, + activeClassName: PropTypes.string, + className: PropTypes.string, 'data-testid': PropTypes.string, - 'isActive': PropTypes.bool, // required, but added using React.cloneElement - 'name': PropTypes.string.isRequired, - 'onClick': PropTypes.func, - 'tabIndex': PropTypes.number, // required, but added using React.cloneElement + isActive: PropTypes.bool, // required, but added using React.cloneElement + name: PropTypes.string.isRequired, + onClick: PropTypes.func, + tabIndex: PropTypes.number, // required, but added using React.cloneElement }; Tab.defaultProps = { diff --git a/ui/components/ui/tabs/tabs.stories.js b/ui/components/ui/tabs/tabs.stories.js index 5127c549d..cefd21b73 100644 --- a/ui/components/ui/tabs/tabs.stories.js +++ b/ui/components/ui/tabs/tabs.stories.js @@ -4,7 +4,7 @@ import Tab from './tab/tab.component'; import Tabs from './tabs.component'; export default { - title: 'Tabs', + title: 'Components/UI/Tabs', id: __filename, }; @@ -16,15 +16,15 @@ function renderTab(id) { ); } -export const twoTabs = () => { +export const TwoTabs = () => { return {['A', 'B'].map(renderTab)}; }; -export const manyTabs = () => { +export const ManyTabs = () => { return {['A', 'B', 'C', 'D', 'E'].map(renderTab)}; }; -export const singleTab = () => { +export const SingleTab = () => { return ( diff --git a/ui/components/ui/text-field/README.mdx b/ui/components/ui/text-field/README.mdx new file mode 100644 index 000000000..64de9308a --- /dev/null +++ b/ui/components/ui/text-field/README.mdx @@ -0,0 +1,51 @@ +import { Story, Canvas, ArgsTable } from '@storybook/addon-docs'; + +import TextField from '.'; + +# Text Field + +Text fields allow users to enter text into a UI. + + + + + +## Component API + + + +### Password Text Field + + + + + +### With Error + + + + + +### Mascara Text + + + + + +### With Material Theme + + + + + +### Password With Material Theme + + + + + +### With Material Theme Error + + + + diff --git a/ui/components/ui/text-field/text-field.component.js b/ui/components/ui/text-field/text-field.component.js index ba9fa28b0..8720ff19b 100644 --- a/ui/components/ui/text-field/text-field.component.js +++ b/ui/components/ui/text-field/text-field.component.js @@ -18,8 +18,8 @@ const styles = { '&$materialError': { color: '#aeaeae', }, - 'fontWeight': '400', - 'color': '#aeaeae', + fontWeight: '400', + color: '#aeaeae', }, materialFocused: {}, materialUnderline: { @@ -32,7 +32,7 @@ const styles = { color: '#aeaeae', }, materialWhitePaddedInput: { - 'padding': '8px', + padding: '8px', '&::placeholder': { color: '#aeaeae', @@ -61,12 +61,12 @@ const styles = { 'label + &': { marginTop: '9px', }, - 'border': '1px solid #BBC0C5', - 'height': '48px', - 'borderRadius': '6px', - 'padding': '0 16px', - 'display': 'flex', - 'alignItems': 'center', + border: '1px solid #BBC0C5', + height: '48px', + borderRadius: '6px', + padding: '0 16px', + display: 'flex', + alignItems: 'center', '&$inputFocused': { border: '1px solid #2f9ae0', }, @@ -188,8 +188,8 @@ const getBorderedThemeInputProps = ({ }); const themeToInputProps = { - 'material': getMaterialThemeInputProps, - 'bordered': getBorderedThemeInputProps, + material: getMaterialThemeInputProps, + bordered: getBorderedThemeInputProps, 'material-white-padded': getMaterialWhitePaddedThemeInputProps, }; @@ -243,14 +243,35 @@ TextField.defaultProps = { }; TextField.propTypes = { + /** + * Show error message + */ error: PropTypes.string, + /** + * Add custom CSS class + */ classes: PropTypes.object, dir: PropTypes.string, + /** + * Give theme to the text field + */ theme: PropTypes.oneOf(['bordered', 'material', 'material-white-padded']), startAdornment: PropTypes.element, + /** + * Show large label + */ largeLabel: PropTypes.bool, + /** + * Define min number input + */ min: PropTypes.number, + /** + * Define max number input + */ max: PropTypes.number, + /** + * Show auto complete text + */ autoComplete: PropTypes.string, onPaste: PropTypes.func, }; diff --git a/ui/components/ui/text-field/text-field.stories.js b/ui/components/ui/text-field/text-field.stories.js index af1d82325..804a6b75b 100644 --- a/ui/components/ui/text-field/text-field.stories.js +++ b/ui/components/ui/text-field/text-field.stories.js @@ -1,31 +1,75 @@ import React from 'react'; +import README from './README.mdx'; import TextField from '.'; export default { - title: 'TextField', + title: 'Components/UI/TextField', id: __filename, + component: TextField, + parameters: { + docs: { + page: README, + }, + }, + argTypes: { + error: { control: 'text' }, + classes: { control: 'object' }, + dir: { control: 'text' }, + theme: { + control: 'select', + options: ['bordered', 'material', 'material-white-padded'], + }, + startAdornment: { control: 'element' }, + largeLabel: { control: 'boolean' }, + min: { control: 'number' }, + max: { control: 'number' }, + autoComplete: { control: 'text' }, + }, }; -export const text = () => ; - -export const password = () => ; - -export const error = () => ( - -); +export const DefaultStory = (args) => ; +DefaultStory.storyName = 'Default'; +DefaultStory.args = { + label: 'Text', + type: 'text', +}; -export const mascaraText = () => ( - -); +export const Password = (args) => ; +Password.args = { + label: 'Password', + type: 'password', +}; +export const TextError = (args) => ; +TextError.args = { + type: 'text', + label: 'Name', + error: 'Invalid Value', +}; +export const MascaraText = (args) => ; +MascaraText.args = { + label: 'Text', + type: 'text', + largeLabel: true, +}; -export const materialText = () => ( - -); +export const MaterialText = (args) => ; +MaterialText.args = { + label: 'Text', + type: 'text', + theme: 'material', +}; -export const materialPassword = () => ( - -); +export const MaterialPassword = (args) => ; +MaterialPassword.args = { + label: 'Password', + type: 'password', + theme: 'material', +}; -export const materialError = () => ( - -); +export const MaterialError = (args) => ; +MaterialError.args = { + type: 'text', + label: 'Name', + error: 'Invalid Value', + theme: 'material', +}; diff --git a/ui/components/ui/textarea/README.mdx b/ui/components/ui/textarea/README.mdx new file mode 100644 index 000000000..6872c596c --- /dev/null +++ b/ui/components/ui/textarea/README.mdx @@ -0,0 +1,15 @@ +import { Story, Canvas, ArgsTable } from '@storybook/addon-docs'; + +import TextArea from '.'; + +# TextArea + +TextArea allows users to enter text into the UI + + + + + +## Component API + + diff --git a/ui/components/ui/textarea/index.js b/ui/components/ui/textarea/index.js new file mode 100644 index 000000000..90daa5b74 --- /dev/null +++ b/ui/components/ui/textarea/index.js @@ -0,0 +1 @@ +export { default } from './textarea'; diff --git a/ui/components/ui/textarea/index.scss b/ui/components/ui/textarea/index.scss new file mode 100644 index 000000000..aa0ca7f54 --- /dev/null +++ b/ui/components/ui/textarea/index.scss @@ -0,0 +1,25 @@ +@use "design-system"; + +.textarea { + display: block; + box-shadow: none; + color: design-system.$black; + + @include design-system.H6; + + font-size: 1rem; + + &--scrollable { + overflow-y: scroll; + } + + &--not-scrollable { + overflow-y: hidden; + } + + @each $size in design-system.$resize { + &--resize-#{$size} { + resize: $size; + } + } +} diff --git a/ui/components/ui/textarea/textarea.js b/ui/components/ui/textarea/textarea.js new file mode 100644 index 000000000..e51f715a0 --- /dev/null +++ b/ui/components/ui/textarea/textarea.js @@ -0,0 +1,91 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import classnames from 'classnames'; + +import { + COLORS, + RESIZE, + SIZES, + BORDER_STYLE, + BLOCK_SIZES, +} from '../../../helpers/constants/design-system'; + +import Box from '../box'; + +const TextArea = ({ + className, + value, + onChange, + resize = RESIZE.BOTH, + scrollable = false, + height, + boxProps, + ...props +}) => { + const textAreaClassnames = classnames( + 'textarea', + className, + `textarea--resize-${resize}`, + { + 'textarea--scrollable': scrollable, + 'textarea--not-scrollable': !scrollable, + }, + ); + return ( + + {(boxClassName) => ( + + + ); +}; + +DefaultStory.storyName = 'Default'; + +DefaultStory.args = { + value: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod temporld, Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod temporld, Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod temporld', + resize: RESIZE.BOTH, + scrollable: false, + boxProps: { + borderColor: COLORS.UI3, + borderRadius: SIZES.SM, + borderStyle: BORDER_STYLE.SOLID, + padding: [2, 4], + }, + height: 'auto', +}; + +export const Scrollable = (args) => { + const [{ value }, updateArgs] = useArgs(); + + const handleOnChange = (e) => { + updateArgs({ + value: e.target.value, + }); + }; + return ( +
+ +
+ ); +}; + +Scrollable.args = { + value: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod temporld, Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod temporld, Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod temporld. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod temporld, Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod temporld, Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod temporld', + resize: RESIZE.NONE, + scrollable: true, + height: 170, + boxProps: { + borderColor: COLORS.TRANSPARENT, + borderRadius: SIZES.NONE, + borderStyle: BORDER_STYLE.NONE, + padding: [2, 4], + width: BLOCK_SIZES.FULL, + }, +}; diff --git a/ui/components/ui/textarea/textarea.test.js b/ui/components/ui/textarea/textarea.test.js new file mode 100644 index 000000000..e1e5b61d5 --- /dev/null +++ b/ui/components/ui/textarea/textarea.test.js @@ -0,0 +1,105 @@ +import * as React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import { + COLORS, + RESIZE, + SIZES, + BORDER_STYLE, +} from '../../../helpers/constants/design-system'; +import TextArea from '.'; + +describe('TextArea', () => { + const text = + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod temporld, Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod temporld, Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod temporld'; + const onChange = jest.fn(); + let args; + beforeEach(() => { + args = { + name: 'Text area', + value: text, + resize: RESIZE.BOTH, + scrollable: false, + boxProps: { + borderColor: COLORS.UI3, + borderRadius: SIZES.SM, + borderStyle: BORDER_STYLE.SOLID, + padding: [2, 4], + }, + height: '100px', + onChange, + }; + }); + it('should render the TextArea component without crashing', () => { + const { getByText } = render(