Merge pull request #13268 from MetaMask/Version-v10.9.0

Version v10.9.0 RC
feature/default_network_editable
ryanml 3 years ago committed by GitHub
commit e2c4c512ba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      .circleci/config.yml
  2. 2
      .circleci/scripts/yarn-audit.sh
  3. 55
      .eslintrc.js
  4. 2
      .github/workflows/cla.yml
  5. 31
      .github/workflows/crowdin_action.yml
  6. 4
      .metamaskrc.dist
  7. 10
      .mocharc.js
  8. 5
      .mocharc.lax.js
  9. 0
      .storybook/1.INTRODUCTION.stories.mdx
  10. 46
      .storybook/2.DOCUMENTATION.stories.mdx
  11. BIN
      .storybook/images/catnip-spicywright.png
  12. 8
      .storybook/initial-states/approval-screens/token-approval.js
  13. 7
      .storybook/main.js
  14. 6
      .storybook/manager.js
  15. 14
      .storybook/metamask-storybook-theme.js
  16. 27
      .storybook/preview-head.html
  17. 11
      .storybook/preview.js
  18. 37
      .storybook/test-data.js
  19. 30
      CHANGELOG.md
  20. 22
      README.md
  21. 2
      app/_locales/de/messages.json
  22. 238
      app/_locales/en/messages.json
  23. 6
      app/_locales/es/messages.json
  24. 6
      app/_locales/es_419/messages.json
  25. 6
      app/_locales/hi/messages.json
  26. 6
      app/_locales/id/messages.json
  27. 6
      app/_locales/it/messages.json
  28. 6
      app/_locales/ja/messages.json
  29. 6
      app/_locales/ko/messages.json
  30. 6
      app/_locales/ph/messages.json
  31. 6
      app/_locales/pt_BR/messages.json
  32. 6
      app/_locales/ru/messages.json
  33. 6
      app/_locales/tl/messages.json
  34. 6
      app/_locales/vi/messages.json
  35. 6
      app/_locales/zh_CN/messages.json
  36. 4
      app/build-types/beta/manifest/chrome.json
  37. 16
      app/build-types/flask/images/logo/metamask-logo-horizontal-dark.svg
  38. 16
      app/build-types/flask/images/logo/metamask-logo-horizontal.svg
  39. 4
      app/build-types/flask/manifest/chrome.json
  40. 1
      app/images/curve-high.svg
  41. 1
      app/images/curve-low.svg
  42. 1
      app/images/curve-medium.svg
  43. 3
      app/images/down-arrow.svg
  44. 3
      app/images/level-arrow.svg
  45. 1
      app/images/low-arrow.svg
  46. 0
      app/images/up-arrow.svg
  47. 48
      app/scripts/background.js
  48. 2
      app/scripts/controllers/alert.js
  49. 50
      app/scripts/controllers/app-state.js
  50. 2
      app/scripts/controllers/cached-balances.js
  51. 10
      app/scripts/controllers/detect-tokens.js
  52. 14
      app/scripts/controllers/incoming-transactions.js
  53. 3
      app/scripts/controllers/incoming-transactions.test.js
  54. 27
      app/scripts/controllers/metametrics.js
  55. 2
      app/scripts/controllers/network/createJsonRpcClient.js
  56. 89
      app/scripts/controllers/network/network-controller.test.js
  57. 10
      app/scripts/controllers/network/network.js
  58. 56
      app/scripts/controllers/network/pending-middleware.test.js
  59. 13
      app/scripts/controllers/network/util.test.js
  60. 7
      app/scripts/controllers/onboarding.js
  61. 74
      app/scripts/controllers/permissions/background-api.js
  62. 187
      app/scripts/controllers/permissions/background-api.test.js
  63. 39
      app/scripts/controllers/permissions/caveat-mutators.js
  64. 32
      app/scripts/controllers/permissions/caveat-mutators.test.js
  65. 78
      app/scripts/controllers/permissions/enums.js
  66. 724
      app/scripts/controllers/permissions/index.js
  67. 39
      app/scripts/controllers/permissions/permission-log.js
  68. 353
      app/scripts/controllers/permissions/permission-log.test.js
  69. 1562
      app/scripts/controllers/permissions/permissions-controller.test.js
  70. 950
      app/scripts/controllers/permissions/permissions-middleware.test.js
  71. 112
      app/scripts/controllers/permissions/permissionsMethodMiddleware.js
  72. 174
      app/scripts/controllers/permissions/restricted-methods.test.js
  73. 40
      app/scripts/controllers/permissions/restrictedMethods.js
  74. 84
      app/scripts/controllers/permissions/selectors.js
  75. 116
      app/scripts/controllers/permissions/selectors.test.js
  76. 258
      app/scripts/controllers/permissions/specifications.js
  77. 340
      app/scripts/controllers/permissions/specifications.test.js
  78. 49
      app/scripts/controllers/preferences.js
  79. 25
      app/scripts/controllers/preferences.test.js
  80. 2
      app/scripts/controllers/swaps.js
  81. 212
      app/scripts/controllers/transactions/index.js
  82. 36
      app/scripts/controllers/transactions/lib/tx-state-history-helpers.js
  83. 8
      app/scripts/controllers/transactions/lib/tx-state-history-helpers.test.js
  84. 14
      app/scripts/controllers/transactions/lib/util.js
  85. 65
      app/scripts/controllers/transactions/pending-tx-tracker.js
  86. 32
      app/scripts/controllers/transactions/tx-gas-utils.js
  87. 18
      app/scripts/controllers/transactions/tx-state-manager.js
  88. 22
      app/scripts/controllers/transactions/tx-state-manager.test.js
  89. 3
      app/scripts/lib/ComposableObservableStore.js
  90. 54
      app/scripts/lib/ComposableObservableStore.test.js
  91. 12
      app/scripts/lib/account-tracker.js
  92. 10
      app/scripts/lib/buy-eth-url.js
  93. 30
      app/scripts/lib/buy-eth-url.test.js
  94. 1
      app/scripts/lib/cleanErrorStack.js
  95. 26
      app/scripts/lib/cleanErrorStack.test.js
  96. 1
      app/scripts/lib/createLoggerMiddleware.js
  97. 21
      app/scripts/lib/createMetaRPCHandler.js
  98. 74
      app/scripts/lib/createMetaRPCHandler.test.js
  99. 1
      app/scripts/lib/createOnboardingMiddleware.js
  100. 1
      app/scripts/lib/createOriginMiddleware.js
  101. Some files were not shown because too many files have changed in this diff Show More

@ -660,8 +660,8 @@ jobs:
- attach_workspace: - attach_workspace:
at: . at: .
- run: - run:
name: test:coverage name: test:coverage:mocha
command: yarn test:coverage command: yarn test:coverage:mocha
- run: - run:
name: test:coverage:jest name: test:coverage:jest
command: yarn test:coverage:jest command: yarn test:coverage:jest

@ -7,7 +7,7 @@ set -o pipefail
# use `improved-yarn-audit` since that allows for exclude # use `improved-yarn-audit` since that allows for exclude
# exclude 1002401 until we remove use of 3Box, 1002581 until we can find a better solution # exclude 1002401 until we remove use of 3Box, 1002581 until we can find a better solution
yarn run improved-yarn-audit --ignore-dev-deps --min-severity moderate --exclude 1002401,1002581,GHSA-93q8-gq69-wqmw,GHSA-257v-vj4p-3w2h yarn run improved-yarn-audit --ignore-dev-deps --min-severity moderate --exclude 1002401,1002581,GHSA-93q8-gq69-wqmw,GHSA-257v-vj4p-3w2h,GHSA-qrpm-p2h7-hrv2
audit_status="$?" audit_status="$?"
# Use a bitmask to ignore INFO and LOW severity audit results # Use a bitmask to ignore INFO and LOW severity audit results

@ -22,6 +22,7 @@ module.exports = {
ignorePatterns: [ ignorePatterns: [
'!.eslintrc.js', '!.eslintrc.js',
'!.mocharc.js',
'node_modules/**', 'node_modules/**',
'dist/**', 'dist/**',
'builds/**', 'builds/**',
@ -38,13 +39,9 @@ module.exports = {
'storybook-build/**', 'storybook-build/**',
], ],
extends: [ extends: ['@metamask/eslint-config', '@metamask/eslint-config-nodejs'],
'@metamask/eslint-config',
'@metamask/eslint-config-nodejs',
'prettier',
],
plugins: ['@babel', 'import', 'prettier'], plugins: ['@babel', 'import', 'jsdoc'],
globals: { globals: {
document: 'readonly', document: 'readonly',
@ -86,10 +83,44 @@ module.exports = {
'node/no-process-env': 'off', 'node/no-process-env': 'off',
// Allow tag `jest-environment` to work around Jest bug
// See: https://github.com/facebook/jest/issues/7780
'jsdoc/check-tag-names': ['error', { definedTags: ['jest-environment'] }],
// 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 // TODO: re-enable these rules
'node/no-sync': 'off', 'node/no-sync': 'off',
'node/no-unpublished-import': 'off', 'node/no-unpublished-import': 'off',
'node/no-unpublished-require': 'off', 'node/no-unpublished-require': 'off',
'jsdoc/match-description': 'off',
'jsdoc/require-description': 'off',
'jsdoc/require-jsdoc': 'off',
'jsdoc/require-param-description': 'off',
'jsdoc/require-param-type': 'off',
'jsdoc/require-returns-description': 'off',
'jsdoc/require-returns-type': 'off',
'jsdoc/require-returns': 'off',
'jsdoc/valid-types': 'off',
}, },
overrides: [ overrides: [
{ {
@ -136,8 +167,11 @@ module.exports = {
'ui/__mocks__/*.js', 'ui/__mocks__/*.js',
'shared/**/*.test.js', 'shared/**/*.test.js',
'development/**/*.test.js', 'development/**/*.test.js',
'app/scripts/lib/**/*.test.js',
'app/scripts/migrations/*.test.js', 'app/scripts/migrations/*.test.js',
'app/scripts/platforms/*.test.js', 'app/scripts/platforms/*.test.js',
'app/scripts/controllers/network/**/*.test.js',
'app/scripts/controllers/permissions/*.test.js',
], ],
extends: ['@metamask/eslint-config-mocha'], extends: ['@metamask/eslint-config-mocha'],
rules: { rules: {
@ -160,8 +194,11 @@ module.exports = {
'ui/__mocks__/*.js', 'ui/__mocks__/*.js',
'shared/**/*.test.js', 'shared/**/*.test.js',
'development/**/*.test.js', 'development/**/*.test.js',
'app/scripts/lib/**/*.test.js',
'app/scripts/migrations/*.test.js', 'app/scripts/migrations/*.test.js',
'app/scripts/platforms/*.test.js', 'app/scripts/platforms/*.test.js',
'app/scripts/controllers/network/**/*.test.js',
'app/scripts/controllers/permissions/*.test.js',
], ],
extends: ['@metamask/eslint-config-jest'], extends: ['@metamask/eslint-config-jest'],
rules: { rules: {
@ -184,7 +221,9 @@ module.exports = {
{ {
files: [ files: [
'.eslintrc.js', '.eslintrc.js',
'.mocharc.js',
'babel.config.js', 'babel.config.js',
'jest.config.js',
'nyc.config.js', 'nyc.config.js',
'stylelint.config.js', 'stylelint.config.js',
'app/scripts/lockdown-run.js', 'app/scripts/lockdown-run.js',
@ -195,7 +234,6 @@ module.exports = {
'test/setup.js', 'test/setup.js',
'test/helpers/protect-intrinsics-helpers.js', 'test/helpers/protect-intrinsics-helpers.js',
'test/lib/wait-until-called.js', 'test/lib/wait-until-called.js',
'jest.config.js',
], ],
parserOptions: { parserOptions: {
sourceType: 'script', sourceType: 'script',
@ -216,6 +254,9 @@ module.exports = {
], ],
settings: { settings: {
jsdoc: {
mode: 'typescript',
},
react: { react: {
// If this is set to 'detect', ESLint will import React in order to find // If this is set to 'detect', ESLint will import React in order to find
// its version. Because we run ESLint in the build system under LavaMoat, // its version. Because we run ESLint in the build system under LavaMoat,

@ -22,6 +22,6 @@ jobs:
url-to-cladocument: 'https://metamask.io/cla.html' url-to-cladocument: 'https://metamask.io/cla.html'
# This branch can't have protections, commits are made directly to the specified branch. # This branch can't have protections, commits are made directly to the specified branch.
branch: 'cla-signatures' branch: 'cla-signatures'
allowlist: 'dependabot[bot],metamaskbot' allowlist: 'dependabot[bot],metamaskbot,crowdin-bot'
allow-organization-members: true allow-organization-members: true
blockchain-storage-flag: false blockchain-storage-flag: false

@ -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 }}

@ -2,3 +2,7 @@
PASSWORD=METAMASK PASSWORD PASSWORD=METAMASK PASSWORD
INFURA_PROJECT_ID=00000000000 INFURA_PROJECT_ID=00000000000
SEGMENT_WRITE_KEY= SEGMENT_WRITE_KEY=
ONBOARDING_V2=
EIP_1559_V2=
SWAPS_USE_DEV_APIS=
COLLECTIBLES_V1=

@ -1,7 +1,13 @@
module.exports = { module.exports = {
// TODO: Remove the `exit` setting, it can hide broken tests. // TODO: Remove the `exit` setting, it can hide broken tests.
exit: true, 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, recursive: true,
require: ['test/env.js', 'test/setup.js'], require: ['test/env.js', 'test/setup.js'],
} };

@ -1,5 +0,0 @@
const baseConfig = require('./.mocharc');
module.exports = Object.assign({}, baseConfig, {
ignore: [...baseConfig.ignore, './app/scripts/controllers/permissions/*.test.js']
});

@ -4,7 +4,7 @@ import { Meta } from '@storybook/addon-docs';
# Documentation Guidelines # Documentation Guidelines
> 💡 To improve the quality of our component documentation we are currently in the process of updating our storybook to use Storyboook's [controls](https://storybook.js.org/addons/@storybook/addon-controls/), [a11y](https://storybook.js.org/addons/@storybook/addon-a11y/) and [docs](https://storybook.js.org/addons/@storybook/addon-docs/) plugins. You will find most components currently without documentation and use knobs for their primary interactivity. These will eventually be updated. > 💡 To improve the quality of our component documentation we are currently in the process of updating our storybook to use Storyboook's [controls](https://storybook.js.org/addons/@storybook/addon-controls/), [a11y](https://storybook.js.org/addons/@storybook/addon-a11y/) and [docs](https://storybook.js.org/addons/@storybook/addon-docs/) plugins. You will find most components currently without documentation and use [knobs](https://storybook.js.org/addons/@storybook/addon-knobs)(deprecated) for their primary interactivity. These will eventually be updated. Want to contribute? Check out the <a href="https://github.com/MetaMask/metamask-extension/issues?q=is%3Aopen+is%3Aissue+label%3Atype-story" targe="_blank">storybook issues on github</a>
## General Guidelines ## General Guidelines
@ -14,16 +14,34 @@ Some general documentation best practices to follow:
- Put yourself in the shoes of another developer trying to use the component you just created for the first time - Put yourself in the shoes of another developer trying to use the component you just created for the first time
- Write a brief description of the component and what it's used for in the `README.mdx` file - Write a brief description of the component and what it's used for in the `README.mdx` file
- Display the component's api using the `<ArgsTable of={YourComponent} />` component from storybook docs - Display the component's API using the `<ArgsTable of={YourComponent} />` component from storybook docs. Add descriptions of each prop by using jsDoc style comments in the `propTypes`.
- Use the [controls](https://storybook.js.org/addons/@storybook/addon-controls/) api over knobs - Use the [controls](https://storybook.js.org/addons/@storybook/addon-controls/) over [knobs](https://storybook.js.org/addons/@storybook/addon-knobs)(deprecated)
- Use the updated `argType` [actions](https://storybook.js.org/docs/react/essentials/actions) api over importing the actions plugin directly - Use the [action argType annotation](https://storybook.js.org/docs/react/essentials/actions#action-argtype-annotation) over importing the actions plugin directly
- Check the accessibility of the component using the **Accessibility** tab
See the [Button](https://metamask.github.io/metamask-storybook/index.html?path=/story/ui-components-ui-button-button-stories-js--default-story)(`ui/components/ui/button/button.stories.js`) component for reference See the [Button](https://metamask.github.io/metamask-storybook/index.html?path=/story/ui-components-ui-button-button-stories-js--default-story)(`ui/components/ui/button/button.stories.js`) component for reference
## Creating a Story ## 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 **without MDX** documentation 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 = () => <MyComponent />;
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 ```jsx
// Button component example story // Button component example story
@ -32,25 +50,27 @@ import React from 'react';
import BuyIcon from '../icon/overview-buy-icon.component'; import BuyIcon from '../icon/overview-buy-icon.component';
// The mdx file to document component api and usage // The mdx file to document component API and usage
import README from './README.mdx'; import README from './README.mdx';
import Button from '.'; import Button from '.';
// The default storybook component export should always follow the same template // The default storybook component export should always follow the same template
export default { 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 id: __filename, // The file name id is used to track what storybook files have changed in CI
component: Button, // The component you are documenting component: Button, // The component you are documenting
parameters: { parameters: {
docs: { 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 // the controls plugin argTypes are used for the interactivity of the component
argTypes: { argTypes: {
children: { control: 'text' }, children: { control: 'text' },
disabled: { control: 'boolean' }, disabled: { control: 'boolean' },
// use the updated action api to log actions in the actions tab // use the updated action API to log actions in the actions tab
onClick: { action: 'clicked' }, onClick: { action: 'clicked' },
type: { type: {
control: { control: {
@ -92,7 +112,8 @@ DefaultStory.storyName = 'Default';
// More stories should be added for different usage examples // More stories should be added for different usage examples
// You can add as many stories as you think appropriate to comprehensively document the component // You can add as many stories as you think appropriate to comprehensively document the component
export const Types = (args) => ( // A good convention is to name the story component after the prop you are highlighting
export const Type = (args) => (
<> <>
<Button {...args} type="default"> <Button {...args} type="default">
{args.children || 'Default'} {args.children || 'Default'}
@ -148,7 +169,8 @@ Buttons communicate actions that users can take.
## Component API ## Component API
<!-- Display the component api using the ArgsTable --> <!-- Display the component API using the ArgsTable. Use JSDoc style comments in the PropTypes of your component to add descriptions for props. See button.component.js Button.propTypes for an example of jsDoc style comments
-->
<ArgsTable of={Button} /> <ArgsTable of={Button} />

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

@ -46,11 +46,11 @@ export const currentNetworkTxListSample = {
] ]
} }
export const domainMetadata = { export const subjectMetadata = {
"https://metamask.github.io": { "https://metamask.github.io": {
"origin": "https://metamask.github.io",
"name": "E2E Test Dapp", "name": "E2E Test Dapp",
"icon": "https://metamask.github.io/test-dapp/metamask-fox.svg", "iconUrl": "https://metamask.github.io/test-dapp/metamask-fox.svg",
"lastUpdated": 1620723443380, "subjectType": "website"
"host": "metamask.github.io"
} }
} }

@ -3,7 +3,11 @@ const path = require('path');
const CopyWebpackPlugin = require('copy-webpack-plugin'); const CopyWebpackPlugin = require('copy-webpack-plugin');
module.exports = { module.exports = {
stories: ['../ui/**/*.stories.js', '../ui/**/*.stories.mdx'], stories: [
'../ui/**/*.stories.js',
'../ui/**/*.stories.mdx',
'./*.stories.mdx',
],
addons: [ addons: [
'@storybook/addon-essentials', '@storybook/addon-essentials',
'@storybook/addon-actions', '@storybook/addon-actions',
@ -30,7 +34,6 @@ module.exports = {
url: false, url: false,
}, },
}, },
'resolve-url-loader',
{ {
loader: 'sass-loader', loader: 'sass-loader',
options: { options: {

@ -0,0 +1,6 @@
import { addons } from '@storybook/addons';
import MetaMaskStorybookTheme from './metamask-storybook-theme';
addons.setConfig({
theme: MetaMaskStorybookTheme,
});

@ -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',
});

@ -0,0 +1,27 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inconsolata&display=swap"
rel="stylesheet"
/>
<style>
* {
--gray-pre-bg: #f8f8f8;
--font-family-monospace: Inconsolata, monospace;
--font-size-code: 0.875rem;
}
.docblock-source {
background: var(--gray-pre-bg) !important;
}
.docblock-source code {
font-family: var(--font-family-monospace) !important;
font-size: var(--font-size-code) !important;
}
.docblock-source code * {
font-family: var(--font-family-monospace) !important;
font-size: var(--font-size-code) !important;
}
</style>

@ -1,7 +1,6 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { addDecorator, addParameters } from '@storybook/react'; import { addDecorator, addParameters } from '@storybook/react';
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
import { withKnobs } from '@storybook/addon-knobs';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import configureStore from '../ui/store/store'; import configureStore from '../ui/store/store';
import '../ui/css/index.scss'; import '../ui/css/index.scss';
@ -13,6 +12,7 @@ import testData from './test-data.js';
import { Router } from 'react-router-dom'; import { Router } from 'react-router-dom';
import { createBrowserHistory } from 'history'; import { createBrowserHistory } from 'history';
import { _setBackgroundConnection } from '../ui/store/actions'; import { _setBackgroundConnection } from '../ui/store/actions';
import MetaMaskStorybookTheme from './metamask-storybook-theme';
addParameters({ addParameters({
backgrounds: { backgrounds: {
@ -22,6 +22,14 @@ addParameters({
{ name: 'dark', value: '#333333' }, { name: 'dark', value: '#333333' },
], ],
}, },
docs: {
theme: MetaMaskStorybookTheme,
},
options: {
storySort: {
order: ['Getting Started', 'Components', ['UI', 'App'], 'Pages'],
},
},
}); });
export const globalTypes = { export const globalTypes = {
@ -77,5 +85,4 @@ const metamaskDecorator = (story, context) => {
); );
}; };
addDecorator(withKnobs);
addDecorator(metamaskDecorator); addDecorator(metamaskDecorator);

@ -1013,34 +1013,25 @@ const state = {
goerli: null, goerli: null,
mainnet: 10902989, mainnet: 10902989,
}, },
permissionsRequests: [], subjects: {
permissionsDescriptions: {},
domains: {
'https://app.uniswap.org': { 'https://app.uniswap.org': {
permissions: [ permissions: {
{ 'eth_accounts': {
'@context': ['https://github.com/MetaMask/rpc-cap'],
invoker: 'https://app.uniswap.org', invoker: 'https://app.uniswap.org',
parentCapability: 'eth_accounts', parentCapability: 'eth_accounts',
id: 'a7342e4b-beae-4525-a36c-c0635fd03359', id: 'a7342e4b-beae-4525-a36c-c0635fd03359',
date: 1620710693178, date: 1620710693178,
caveats: [ caveats: [
{ {
type: 'limitResponseLength', type: 'restrictReturnedAccounts',
value: 1,
name: 'primaryAccountOnly',
},
{
type: 'filterResponse',
value: ['0x64a845a5b02460acf8a3d84503b0d68d028b4bb4'], value: ['0x64a845a5b02460acf8a3d84503b0d68d028b4bb4'],
name: 'exposedAccounts',
}, },
], ],
}, },
],
}, },
}, },
permissionsLog: [ },
permissionActivityLog: [
{ {
id: 522690215, id: 522690215,
method: 'eth_accounts', method: 'eth_accounts',
@ -1171,7 +1162,7 @@ const state = {
success: true, success: true,
}, },
], ],
permissionsHistory: { permissionHistory: {
'https://metamask.github.io': { 'https://metamask.github.io': {
eth_accounts: { eth_accounts: {
lastApproved: 1620710693213, lastApproved: 1620710693213,
@ -1181,18 +1172,18 @@ const state = {
}, },
}, },
}, },
domainMetadata: { subjectMetadata: {
'https://metamask.github.io': { 'https://metamask.github.io': {
name: 'E2E Test Dapp', name: 'E2E Test Dapp',
icon: 'https://metamask.github.io/test-dapp/metamask-fox.svg', origin: 'https://metamask.github.io',
lastUpdated: 1620723443380, iconUrl: 'https://metamask.github.io/test-dapp/metamask-fox.svg',
host: 'metamask.github.io', subjectType: 'website',
}, },
'https://app.uniswap.org': { 'https://app.uniswap.org': {
name: 'Uniswap', name: 'Uniswap',
icon: './UNI.png', origin: 'https://app.uniswap.org',
lastUpdated: 1620723443380, iconUrl: './UNI.png',
host: 'app.uniswap.org', subjectType: 'website',
}, },
}, },
threeBoxSyncingAllowed: false, threeBoxSyncingAllowed: false,

@ -6,6 +6,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [10.9.0]
### Added
- Alert users when the network is busy ([#12268](https://github.com/MetaMask/metamask-extension/pull/12268))
### Changed
- Clear the clipboard after seed phrase is pasted into restore vault form ([#12987](https://github.com/MetaMask/metamask-extension/pull/12987))
- Remove bottom space when hiding testnet ([#12821](https://github.com/MetaMask/metamask-extension/pull/12821))
- Prevent automatic rejection of confirmations ([#13194](https://github.com/MetaMask/metamask-extension/pull/13194))
- Capitalize "learn more" link in permissions connect screen ([#13092](https://github.com/MetaMask/metamask-extension/pull/13092))
- Allow keyboard accessiblity on restore vault form ([#12989](https://github.com/MetaMask/metamask-extension/pull/12989))
- Permission System 2.0 ([#12243](https://github.com/MetaMask/metamask-extension/pull/12243))
- Changed site origin and added permission list view ([#12832](https://github.com/MetaMask/metamask-extension/pull/12832))
- Changed accounts selection permissions screen ([#13039](https://github.com/MetaMask/metamask-extension/pull/13039))
- Optimize Swaps flow ([#12939](https://github.com/MetaMask/metamask-extension/pull/12939))
- Remove legacy node parent detection ([#12814](https://github.com/MetaMask/metamask-extension/pull/12814))
### Fixed
- Fixed Mainnet Tokens autopopulating in custom token fields on other networks ([#12800](https://github.com/MetaMask/metamask-extension/pull/12800))
- Adjust the padding of lock button for certain locales ([#13017](https://github.com/MetaMask/metamask-extension/pull/13017))
- Lock button active state fix when holding mouse click ([#13100](https://github.com/MetaMask/metamask-extension/pull/13100))
- Fix order of account list on the "Send To" screen ([#12999](https://github.com/MetaMask/metamask-extension/pull/12999))
- Display hex data from previous send tx screen to edit tx screen ([#12709](https://github.com/MetaMask/metamask-extension/pull/12709))
- Sanitize eth_signTypedData message when corresponding field in 'types' is missing ([#12905](https://github.com/MetaMask/metamask-extension/pull/12905))
- Identicon size fix ([#13014](https://github.com/MetaMask/metamask-extension/pull/13014))
- Fixed latest conversion date on currency conversion in general settings ([#12422](https://github.com/MetaMask/metamask-extension/pull/12422))
- Prevent account name duplicates ([#12867](https://github.com/MetaMask/metamask-extension/pull/12867))
## [10.8.2] ## [10.8.2]
### Fixed ### Fixed
- Add missing `appName` localized messages for Flask and Beta ([#13138](https://github.com/MetaMask/metamask-extension/pull/13138)) - Add missing `appName` localized messages for Flask and Beta ([#13138](https://github.com/MetaMask/metamask-extension/pull/13138))
@ -2660,7 +2687,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Uncategorized ### Uncategorized
- Added the ability to restore accounts from seed words. - Added the ability to restore accounts from seed words.
[Unreleased]: https://github.com/MetaMask/metamask-extension/compare/v10.8.2...HEAD [Unreleased]: https://github.com/MetaMask/metamask-extension/compare/v10.9.0...HEAD
[10.9.0]: https://github.com/MetaMask/metamask-extension/compare/v10.8.2...v10.9.0
[10.8.2]: https://github.com/MetaMask/metamask-extension/compare/v10.8.1...v10.8.2 [10.8.2]: https://github.com/MetaMask/metamask-extension/compare/v10.8.1...v10.8.2
[10.8.1]: https://github.com/MetaMask/metamask-extension/compare/v10.8.0...v10.8.1 [10.8.1]: https://github.com/MetaMask/metamask-extension/compare/v10.8.0...v10.8.1
[10.8.0]: https://github.com/MetaMask/metamask-extension/compare/v10.7.1...v10.8.0 [10.8.0]: https://github.com/MetaMask/metamask-extension/compare/v10.7.1...v10.8.0

@ -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 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) #### React and Redux DevTools
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)
[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 ### Running Unit Tests and Linting
@ -61,6 +70,7 @@ Whenever you change dependencies (adding, removing, or updating, either in `pack
* `yarn.lock`: * `yarn.lock`:
* Run `yarn setup` again after your changes to ensure `yarn.lock` has been properly updated. * 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` * The `allow-scripts` configuration in `package.json`
* Run `yarn allow-scripts auto` to update the `allow-scripts` configuration automatically. This config determines whether the package's install/postinstall scripts are allowed to run. Review each new package to determine whether the install script needs to run or not, testing if necessary. * Run `yarn allow-scripts auto` to update the `allow-scripts` configuration automatically. This config determines whether the package's install/postinstall scripts are allowed to run. Review each new package to determine whether the install script needs to run or not, testing if necessary.
* Unfortunately, `yarn allow-scripts auto` will behave inconsistently on different platforms. macOS and Windows users may see extraneous changes relating to optional dependencies. * Unfortunately, `yarn allow-scripts auto` will behave inconsistently on different platforms. macOS and Windows users may see extraneous changes relating to optional dependencies.

@ -744,7 +744,7 @@
"message": "Zurücksetzen" "message": "Zurücksetzen"
}, },
"resetAccount": { "resetAccount": {
"message": "Account zurücksetzten" "message": "Account zurücksetzen"
}, },
"resetAccountDescription": { "resetAccountDescription": {
"message": "Durch das Zurücksetzen Ihres Kontos wird Ihr Transaktionsverlauf gelöscht." "message": "Durch das Zurücksetzen Ihres Kontos wird Ihr Transaktionsverlauf gelöscht."

@ -73,6 +73,10 @@
"accountName": { "accountName": {
"message": "Account Name" "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": { "accountOptions": {
"message": "Account Options" "message": "Account Options"
}, },
@ -139,9 +143,6 @@
"addNFT": { "addNFT": {
"message": "Add NFT" "message": "Add NFT"
}, },
"addNFTLowerCase": {
"message": "add NFT"
},
"addNetwork": { "addNetwork": {
"message": "Add Network" "message": "Add Network"
}, },
@ -167,7 +168,13 @@
"message": "Advanced" "message": "Advanced"
}, },
"advancedBaseGasFeeToolTip": { "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."
},
"advancedGasFeeDefaultOptIn": {
"message": "Save these $1 as my default for \"Advanced\""
},
"advancedGasFeeDefaultOptOut": {
"message": "Always use these values and advanced setting as default."
}, },
"advancedGasFeeModalTitle": { "advancedGasFeeModalTitle": {
"message": "Advanced gas fee" "message": "Advanced gas fee"
@ -320,6 +327,9 @@
"balanceOutdated": { "balanceOutdated": {
"message": "Balance may be outdated" "message": "Balance may be outdated"
}, },
"baseFee": {
"message": "Base fee"
},
"basic": { "basic": {
"message": "Basic" "message": "Basic"
}, },
@ -412,6 +422,14 @@
"cancelSpeedUp": { "cancelSpeedUp": {
"message": "cancel or speed up a tranaction." "message": "cancel or speed up a tranaction."
}, },
"cancelSpeedUpLabel": {
"message": "This gas fee will $1 the original.",
"description": "$1 is text 'replace' in bold"
},
"cancelSpeedUpTransactionTooltip": {
"message": "To $1 a transaction the gas fee must be increased by at least 10% for it to be recognized by the network.",
"description": "$1 is string 'cancel' or 'speed up'"
},
"cancellationGasFee": { "cancellationGasFee": {
"message": "Cancellation Gas Fee" "message": "Cancellation Gas Fee"
}, },
@ -565,6 +583,9 @@
"contract": { "contract": {
"message": "Contract" "message": "Contract"
}, },
"contractAddress": {
"message": "Contract address"
},
"contractAddressError": { "contractAddressError": {
"message": "You are sending tokens to the token's contract address. This may result in the loss of these tokens." "message": "You are sending tokens to the token's contract address. This may result in the loss of these tokens."
}, },
@ -637,6 +658,10 @@
"customGas": { "customGas": {
"message": "Customize Gas" "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": { "customGasSubTitle": {
"message": "Increasing fee may decrease processing times, but it is not guaranteed." "message": "Increasing fee may decrease processing times, but it is not guaranteed."
}, },
@ -649,6 +674,10 @@
"dappSuggested": { "dappSuggested": {
"message": "Site suggested" "message": "Site suggested"
}, },
"dappSuggestedGasSettingToolTipMessage": {
"message": "$1 has suggested this price.",
"description": "$1 is url for the dapp that has suggested gas settings"
},
"dappSuggestedShortLabel": { "dappSuggestedShortLabel": {
"message": "Site" "message": "Site"
}, },
@ -706,6 +735,9 @@
"depositEther": { "depositEther": {
"message": "Deposit Ether" "message": "Deposit Ether"
}, },
"description": {
"message": "Description"
},
"details": { "details": {
"message": "Details" "message": "Details"
}, },
@ -715,6 +747,10 @@
"directDepositEtherExplainer": { "directDepositEtherExplainer": {
"message": "If you already have some Ether, the quickest way to get Ether in your new wallet by direct deposit." "message": "If you already have some Ether, the quickest way to get Ether in your new wallet by direct deposit."
}, },
"disabledGasOptionToolTipMessage": {
"message": "“$1” is disabled because it does not meet the minimum of a 10% increase from the original gas fee.",
"description": "$1 is gas estimate type which can be market or aggressive"
},
"disconnect": { "disconnect": {
"message": "Disconnect" "message": "Disconnect"
}, },
@ -769,6 +805,9 @@
"editAddressNickname": { "editAddressNickname": {
"message": "Edit address nickname" "message": "Edit address nickname"
}, },
"editCancellationGasFeeModalTitle": {
"message": "Edit cancellation gas fee"
},
"editContact": { "editContact": {
"message": "Edit Contact" "message": "Edit Contact"
}, },
@ -799,12 +838,25 @@
"editGasLimitOutOfBounds": { "editGasLimitOutOfBounds": {
"message": "Gas limit must be at least $1" "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": { "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”." "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": { "editGasLow": {
"message": "Low" "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"
},
"editGasMaxFeeHigh": { "editGasMaxFeeHigh": {
"message": "Max fee is higher than necessary" "message": "Max fee is higher than necessary"
}, },
@ -820,12 +872,21 @@
"editGasMaxPriorityFeeBelowMinimum": { "editGasMaxPriorityFeeBelowMinimum": {
"message": "Max priority fee must be greater than 0 GWEI" "message": "Max priority fee must be greater than 0 GWEI"
}, },
"editGasMaxPriorityFeeBelowMinimumV2": {
"message": "Priority fee must be greater than 0."
},
"editGasMaxPriorityFeeHigh": { "editGasMaxPriorityFeeHigh": {
"message": "Max priority fee is higher than necessary. You may pay more than needed." "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": { "editGasMaxPriorityFeeLow": {
"message": "Max priority fee is low for current network conditions" "message": "Max priority fee is low for current network conditions"
}, },
"editGasMaxPriorityFeeLowV2": {
"message": "Priority fee is low for current network conditions"
},
"editGasMaxPriorityFeeTooltip": { "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" "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"
}, },
@ -857,12 +918,6 @@
"editGasTooLowWarningTooltip": { "editGasTooLowWarningTooltip": {
"message": "This lowers your maximum fee but if network traffic increases your transaction may be delayed or fail." "message": "This lowers your maximum fee but if network traffic increases your transaction may be delayed or fail."
}, },
"editInGwei": {
"message": "Edit in GWEI"
},
"editInMultiplier": {
"message": "Edit in multiplier"
},
"editNonceField": { "editNonceField": {
"message": "Edit Nonce" "message": "Edit Nonce"
}, },
@ -872,6 +927,12 @@
"editPermission": { "editPermission": {
"message": "Edit Permission" "message": "Edit Permission"
}, },
"editSpeedUpEditGasFeeModalTitle": {
"message": "Edit speed up gas fee"
},
"enableAutoDetect": {
"message": " Enable Autodetect"
},
"enableFromSettings": { "enableFromSettings": {
"message": " Enable it from Settings." "message": " Enable it from Settings."
}, },
@ -993,7 +1054,7 @@
"message": "Backup gas price is provided as the main gas estimation service is unavailable right now." "message": "Backup gas price is provided as the main gas estimation service is unavailable right now."
}, },
"eth_accounts": { "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" "description": "The description for the `eth_accounts` permission"
}, },
"ethereumPublicAddress": { "ethereumPublicAddress": {
@ -1056,20 +1117,37 @@
"message": "File import not working? Click here!", "message": "File import not working? Click here!",
"description": "Helps user import their account from a JSON file" "description": "Helps user import their account from a JSON file"
}, },
"flaskExperimentalText1": { "flaskSnapSettingsCardButtonCta": {
"message": "Using Flask can greatly increase your risk of fund loss:" "message": "See details",
"description": "Call to action a user can take to see more information about the Snap that is installed"
}, },
"flaskExperimentalText2": { "flaskSnapSettingsCardDateAddedOn": {
"message": "if you use it to install non-trustworthy Snaps" "message": "Added on",
"description": "Start of the sentence describing when and where snap was added"
}, },
"flaskExperimentalText3": { "flaskSnapSettingsCardFrom": {
"message": "if you do not review confirmations before approving changes" "message": "from",
"description": "Part of the sentence describing when and where snap was added"
}, },
"flaskExperimentalText4": { "flaskWelcomeUninstall": {
"message": "if you interact with unfamiliar smart contracts" "message": "you should uninstall this extension",
"description": "This request is shown on the Flask Welcome screen. It is intended for non-developers, and will be bolded."
}, },
"flaskExperimentalText5": { "flaskWelcomeWarning1": {
"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?" "message": "Flask is for developers to experiment with new unstable APIs. Unless you are a developer or beta tester, $1.",
"description": "This is a warning shown on the Flask Welcome screen, intended to encourage non-developers not to proceed any further. $1 is the bolded message 'flaskWelcomeUninstall'"
},
"flaskWelcomeWarning2": {
"message": "We do not guarantee the safety or stability of this extension. The new APIs offered by Flask are not hardened against phishing attacks, meaning that any site or snap that requires Flask might be a malicious attempt to steal your assets.",
"description": "This explains the risks of using MetaMask Flask"
},
"flaskWelcomeWarning3": {
"message": "All Flask APIs are experimental. They may be changed or removed without notice, or they might stay on Flask indefinitely without ever being migrated to stable MetaMask. Use them at your own risk.",
"description": "This message warns developers about unstable Flask APIs"
},
"flaskWelcomeWarningAcceptButton": {
"message": "I accept the risks",
"description": "this text is shown on a button, which the user presses to confirm they understand the risks of using Flask"
}, },
"followUsOnTwitter": { "followUsOnTwitter": {
"message": "Follow us on Twitter" "message": "Follow us on Twitter"
@ -1093,6 +1171,9 @@
"functionType": { "functionType": {
"message": "Function Type" "message": "Function Type"
}, },
"gas": {
"message": "Gas"
},
"gasDisplayAcknowledgeDappButtonText": { "gasDisplayAcknowledgeDappButtonText": {
"message": "Edit suggested gas fee" "message": "Edit suggested gas fee"
}, },
@ -1116,6 +1197,9 @@
"message": "Gas limit must be at least $1", "message": "Gas limit must be at least $1",
"description": "$1 is the custom gas limit, in decimal." "description": "$1 is the custom gas limit, in decimal."
}, },
"gasLimitV2": {
"message": "Gas limit"
},
"gasOption": { "gasOption": {
"message": "Gas option" "message": "Gas option"
}, },
@ -1205,6 +1289,9 @@
"grantedToWithColon": { "grantedToWithColon": {
"message": "Granted to:" "message": "Granted to:"
}, },
"gwei": {
"message": "GWEI"
},
"happyToSeeYou": { "happyToSeeYou": {
"message": "We’re happy to see you." "message": "We’re happy to see you."
}, },
@ -1250,6 +1337,16 @@
"high": { "high": {
"message": "Aggressive" "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": { "history": {
"message": "History" "message": "History"
}, },
@ -1289,6 +1386,9 @@
"importMyWallet": { "importMyWallet": {
"message": "Import My Wallet" "message": "Import My Wallet"
}, },
"importNFTs": {
"message": "Import NFTs"
},
"importTokenQuestion": { "importTokenQuestion": {
"message": "Import token?" "message": "Import token?"
}, },
@ -1424,6 +1524,9 @@
"learnMore": { "learnMore": {
"message": "learn more" "message": "learn more"
}, },
"learnMoreUpperCase": {
"message": "Learn more"
},
"learnScamRisk": { "learnScamRisk": {
"message": "scams and security risks." "message": "scams and security risks."
}, },
@ -1481,6 +1584,9 @@
"likeToImportTokens": { "likeToImportTokens": {
"message": "Would you like to import these tokens?" "message": "Would you like to import these tokens?"
}, },
"link": {
"message": "Link"
},
"links": { "links": {
"message": "Links" "message": "Links"
}, },
@ -1505,12 +1611,22 @@
"low": { "low": {
"message": "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": { "lowPriorityMessage": {
"message": "Future transactions will queue after this one. This price was last seen was some time ago." "message": "Future transactions will queue after this one. This price was last seen was some time ago."
}, },
"mainnet": { "mainnet": {
"message": "Ethereum Mainnet" "message": "Ethereum Mainnet"
}, },
"mainnetToken": {
"message": "This address matches a known Ethereum Mainnet token address. Recheck the contract address and network for the token you are trying to add."
},
"makeAnotherSwap": { "makeAnotherSwap": {
"message": "Create a new swap" "message": "Create a new swap"
}, },
@ -1533,6 +1649,10 @@
"medium": { "medium": {
"message": "Market" "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": { "memo": {
"message": "memo" "message": "memo"
}, },
@ -1626,9 +1746,6 @@
"mobileSyncWarning": { "mobileSyncWarning": {
"message": "The 'Sync with extension' feature is temporarily disabled. If you want to use your extension wallet on MetaMask mobile, then on your mobile app: go back to the wallet setup options and select the 'Import with Secret Recovery Phrase' option. Use your extension wallet's secret phrase to then import your wallet into mobile." "message": "The 'Sync with extension' feature is temporarily disabled. If you want to use your extension wallet on MetaMask mobile, then on your mobile app: go back to the wallet setup options and select the 'Import with Secret Recovery Phrase' option. Use your extension wallet's secret phrase to then import your wallet into mobile."
}, },
"multiplier": {
"message": "multiplier"
},
"mustSelectOne": { "mustSelectOne": {
"message": "Must select at least 1 token." "message": "Must select at least 1 token."
}, },
@ -1664,6 +1781,9 @@
"networkDetails": { "networkDetails": {
"message": "Network Details" "message": "Network Details"
}, },
"networkIsBusy": {
"message": "Network is busy. Gas prices are high and estimates are less accurate."
},
"networkName": { "networkName": {
"message": "Network Name" "message": "Network Name"
}, },
@ -1694,6 +1814,17 @@
"networkStatus": { "networkStatus": {
"message": "Network status" "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": { "networkURL": {
"message": "Network URL" "message": "Network URL"
}, },
@ -1729,10 +1860,10 @@
"message": "New Contract" "message": "New Contract"
}, },
"newNFTsDetected": { "newNFTsDetected": {
"message": "New NFTs detected" "message": "New! NFT detection"
}, },
"newNFTsDetectedInfo": { "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": { "newNetworkAdded": {
"message": "“$1” was successfully added!" "message": "“$1” was successfully added!"
@ -1749,6 +1880,9 @@
"newTransactionFee": { "newTransactionFee": {
"message": "New Transaction Fee" "message": "New Transaction Fee"
}, },
"newValues": {
"message": "new values"
},
"next": { "next": {
"message": "Next" "message": "Next"
}, },
@ -1774,6 +1908,9 @@
"noAlreadyHaveSeed": { "noAlreadyHaveSeed": {
"message": "No, I already have a Secret Recovery Phrase" "message": "No, I already have a Secret Recovery Phrase"
}, },
"noConversionDateAvailable": {
"message": "No Currency Conversion Date Available"
},
"noConversionRateAvailable": { "noConversionRateAvailable": {
"message": "No Conversion Rate Available" "message": "No Conversion Rate Available"
}, },
@ -2034,15 +2171,9 @@
"message": "You have (1) pending transaction.", "message": "You have (1) pending transaction.",
"description": "$1 is count of pending transactions" "description": "$1 is count of pending transactions"
}, },
"permissionCheckedIconDescription": {
"message": "You have approved this permission"
},
"permissionRequest": { "permissionRequest": {
"message": "Permission request" "message": "Permission request"
}, },
"permissionUncheckedIconDescription": {
"message": "You have not approved this permission"
},
"permissions": { "permissions": {
"message": "Permissions" "message": "Permissions"
}, },
@ -2067,6 +2198,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." "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": { "priorityFee": {
"message": "Priority fee"
},
"priorityFeeProperCase": {
"message": "Priority Fee" "message": "Priority Fee"
}, },
"privacyMsg": { "privacyMsg": {
@ -2172,6 +2306,12 @@
"removeAccountDescription": { "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. " "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"
},
"replace": {
"message": "replace"
},
"requestsAwaitingAcknowledgement": { "requestsAwaitingAcknowledgement": {
"message": "requests waiting to be acknowledged" "message": "requests waiting to be acknowledged"
}, },
@ -2342,7 +2482,7 @@
"message": "Select a higher gas fee to accelerate the processing of your transaction.*" "message": "Select a higher gas fee to accelerate the processing of your transaction.*"
}, },
"selectAccounts": { "selectAccounts": {
"message": "Select account(s)" "message": "Select the account(s) to use on this site"
}, },
"selectAll": { "selectAll": {
"message": "Select all" "message": "Select all"
@ -2360,7 +2500,7 @@
"message": "Select HD Path" "message": "Select HD Path"
}, },
"selectNFTPrivacyPreference": { "selectNFTPrivacyPreference": {
"message": "Select NFT privacy preference" "message": "Turn on NFT detection in Settings"
}, },
"selectPathHelp": { "selectPathHelp": {
"message": "If you don't see the accounts you expect, try switching the HD path." "message": "If you don't see the accounts you expect, try switching the HD path."
@ -2387,6 +2527,9 @@
"sendTokens": { "sendTokens": {
"message": "Send Tokens" "message": "Send Tokens"
}, },
"sendingDisabled": {
"message": "Sending of ERC-1155 NFT assets is not yet supported."
},
"sendingNativeAsset": { "sendingNativeAsset": {
"message": "Sending $1", "message": "Sending $1",
"description": "$1 represents the native currency symbol for the current network (e.g. ETH or BNB)" "description": "$1 represents the native currency symbol for the current network (e.g. ETH or BNB)"
@ -2490,6 +2633,9 @@
"somethingWentWrong": { "somethingWentWrong": {
"message": "Oops! Something went wrong." "message": "Oops! Something went wrong."
}, },
"source": {
"message": "Source"
},
"speedUp": { "speedUp": {
"message": "Speed Up" "message": "Speed Up"
}, },
@ -2530,6 +2676,9 @@
"stable": { "stable": {
"message": "Stable" "message": "Stable"
}, },
"stableLowercase": {
"message": "stable"
},
"stateLogError": { "stateLogError": {
"message": "Error in retrieving state logs." "message": "Error in retrieving state logs."
}, },
@ -2813,6 +2962,12 @@
"swapSourceInfo": { "swapSourceInfo": {
"message": "We search multiple liquidity sources (exchanges, aggregators and professional market makers) to find the best rates and lowest network fees." "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": { "swapSwapFrom": {
"message": "Swap from" "message": "Swap from"
}, },
@ -2951,6 +3106,9 @@
"syncWithThreeBoxDisabled": { "syncWithThreeBoxDisabled": {
"message": "3Box has been disabled due to an error during the initial sync" "message": "3Box has been disabled due to an error during the initial sync"
}, },
"tenPercentIncreased": {
"message": "10% increase"
},
"terms": { "terms": {
"message": "Terms of Use" "message": "Terms of Use"
}, },
@ -2995,6 +3153,9 @@
"tokenDetectionAnnouncement": { "tokenDetectionAnnouncement": {
"message": "New! Improved token detection is available on Ethereum Mainnet as an experimental feature. $1" "message": "New! Improved token detection is available on Ethereum Mainnet as an experimental feature. $1"
}, },
"tokenId": {
"message": "Token ID"
},
"tokenSymbol": { "tokenSymbol": {
"message": "Token Symbol" "message": "Token Symbol"
}, },
@ -3040,9 +3201,6 @@
"transactionDetailGasHeading": { "transactionDetailGasHeading": {
"message": "Estimated gas fee" "message": "Estimated gas fee"
}, },
"transactionDetailGasHeadingV2": {
"message": "Gas"
},
"transactionDetailGasInfoV2": { "transactionDetailGasInfoV2": {
"message": "estimated" "message": "estimated"
}, },
@ -3223,9 +3381,6 @@
"usedByClients": { "usedByClients": {
"message": "Used by a variety of different clients" "message": "Used by a variety of different clients"
}, },
"userAccepts": {
"message": "I accept"
},
"userName": { "userName": {
"message": "Username" "message": "Username"
}, },
@ -3267,6 +3422,9 @@
"message": "View $1 on Etherscan", "message": "View $1 on Etherscan",
"description": "$1 is the action type. e.g (Account, Transaction, Swap)" "description": "$1 is the action type. e.g (Account, Transaction, Swap)"
}, },
"viewOnOpensea": {
"message": "View on Opensea"
},
"viewinExplorer": { "viewinExplorer": {
"message": "View $1 in Explorer", "message": "View $1 in Explorer",
"description": "$1 is the action type. e.g (Account, Transaction, Swap)" "description": "$1 is the action type. e.g (Account, Transaction, Swap)"

@ -1351,12 +1351,6 @@
"pending": { "pending": {
"message": "Pendiente" "message": "Pendiente"
}, },
"permissionCheckedIconDescription": {
"message": "Aprobó este permiso"
},
"permissionUncheckedIconDescription": {
"message": "No aprobó este permiso"
},
"permissions": { "permissions": {
"message": "Permisos" "message": "Permisos"
}, },

@ -1351,12 +1351,6 @@
"pending": { "pending": {
"message": "Pendiente" "message": "Pendiente"
}, },
"permissionCheckedIconDescription": {
"message": "Aprobó este permiso"
},
"permissionUncheckedIconDescription": {
"message": "No aprobó este permiso"
},
"permissions": { "permissions": {
"message": "Permisos" "message": "Permisos"
}, },

@ -1351,12 +1351,6 @@
"pending": { "pending": {
"message": "लित" "message": "लित"
}, },
"permissionCheckedIconDescription": {
"message": "आपन इस अनमति अनित कर दि"
},
"permissionUncheckedIconDescription": {
"message": "आपन इस अनमति अनित नहि"
},
"permissions": { "permissions": {
"message": "अनमति" "message": "अनमति"
}, },

@ -1351,12 +1351,6 @@
"pending": { "pending": {
"message": "Tunda" "message": "Tunda"
}, },
"permissionCheckedIconDescription": {
"message": "Anda telah menyetujui izin ini"
},
"permissionUncheckedIconDescription": {
"message": "Anda belum menyetujui izin ini"
},
"permissions": { "permissions": {
"message": "Izin" "message": "Izin"
}, },

@ -1110,12 +1110,6 @@
"pending": { "pending": {
"message": "in corso" "message": "in corso"
}, },
"permissionCheckedIconDescription": {
"message": "Hai approvato questo permesso"
},
"permissionUncheckedIconDescription": {
"message": "Non hai approvato questo permesso"
},
"permissions": { "permissions": {
"message": "Permessi" "message": "Permessi"
}, },

@ -1351,12 +1351,6 @@
"pending": { "pending": {
"message": "処理" "message": "処理"
}, },
"permissionCheckedIconDescription": {
"message": "この許可の承認が完了しました。"
},
"permissionUncheckedIconDescription": {
"message": "この許可の承認が完了していません。"
},
"permissions": { "permissions": {
"message": "許可" "message": "許可"
}, },

@ -1351,12 +1351,6 @@
"pending": { "pending": {
"message": "보류 중" "message": "보류 중"
}, },
"permissionCheckedIconDescription": {
"message": "이 권한을 승인했습니다."
},
"permissionUncheckedIconDescription": {
"message": "이 권한을 승인하지 않았습니다."
},
"permissions": { "permissions": {
"message": "권한" "message": "권한"
}, },

@ -1351,12 +1351,6 @@
"pending": { "pending": {
"message": "Nakabinbin" "message": "Nakabinbin"
}, },
"permissionCheckedIconDescription": {
"message": "Inaprubahan mo ang pahintulot na ito"
},
"permissionUncheckedIconDescription": {
"message": "Hindi mo inaprubahan ang pahintulot na ito"
},
"permissions": { "permissions": {
"message": "Mga Pahintulot" "message": "Mga Pahintulot"
}, },

@ -1351,12 +1351,6 @@
"pending": { "pending": {
"message": "Pendente" "message": "Pendente"
}, },
"permissionCheckedIconDescription": {
"message": "Você aprovou esta permissão"
},
"permissionUncheckedIconDescription": {
"message": "Você não aprovou esta permissão"
},
"permissions": { "permissions": {
"message": "Permissões" "message": "Permissões"
}, },

@ -1351,12 +1351,6 @@
"pending": { "pending": {
"message": "В ожидании" "message": "В ожидании"
}, },
"permissionCheckedIconDescription": {
"message": "Вы одобрили это разрешение"
},
"permissionUncheckedIconDescription": {
"message": "Вы не одобрили это разрешение"
},
"permissions": { "permissions": {
"message": "Разрешения" "message": "Разрешения"
}, },

@ -1101,12 +1101,6 @@
"pending": { "pending": {
"message": "Nakabinbin" "message": "Nakabinbin"
}, },
"permissionCheckedIconDescription": {
"message": "Inaprubahan mo ang pahintulot na ito"
},
"permissionUncheckedIconDescription": {
"message": "Hindi mo inaprubahan ang pahintulot na ito"
},
"permissions": { "permissions": {
"message": "Mga Pahintulot" "message": "Mga Pahintulot"
}, },

@ -1351,12 +1351,6 @@
"pending": { "pending": {
"message": "Đang chờ xử lý" "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": { "permissions": {
"message": "Quyền" "message": "Quyền"
}, },

@ -1152,12 +1152,6 @@
"pending": { "pending": {
"message": "待处理" "message": "待处理"
}, },
"permissionCheckedIconDescription": {
"message": "您已同意该权限"
},
"permissionUncheckedIconDescription": {
"message": "您还未同意该权限"
},
"permissions": { "permissions": {
"message": "权限" "message": "权限"
}, },

@ -0,0 +1,4 @@
{
"description": "THIS IS THE METAMASK EXTENSION BETA, INTENDED FOR BETA TESTING",
"name": "MetaMask BETA"
}

@ -34,14 +34,14 @@
<path d="M256.683 108.955L251.485 105.202L259.802 97.5905L253.46 92.5859L261.777 86.2258L256.267 82.0553L265 39.5158L251.901 0L167.587 31.5918H97.4127L13.0993 0L0 39.5158L8.8368 82.0553L3.22283 86.2258L11.5398 92.5859L5.19812 97.5905L13.5151 105.202L8.31699 108.955L20.2727 123.031L2.18321 179.542L18.9211 237.199L77.8678 220.934L113.111 203.731L110.231 227.044L112.903 224.583L152.097 224.479L154.696 226.773L151.889 203.731L187.132 220.934L246.079 237.199L262.921 179.542L244.727 123.031L256.683 108.955Z" fill="url(#paint13_linear)" fill-opacity="0.1" style="mix-blend-mode:color-dodge"/> <path d="M256.683 108.955L251.485 105.202L259.802 97.5905L253.46 92.5859L261.777 86.2258L256.267 82.0553L265 39.5158L251.901 0L167.587 31.5918H97.4127L13.0993 0L0 39.5158L8.8368 82.0553L3.22283 86.2258L11.5398 92.5859L5.19812 97.5905L13.5151 105.202L8.31699 108.955L20.2727 123.031L2.18321 179.542L18.9211 237.199L77.8678 220.934L113.111 203.731L110.231 227.044L112.903 224.583L152.097 224.479L154.696 226.773L151.889 203.731L187.132 220.934L246.079 237.199L262.921 179.542L244.727 123.031L256.683 108.955Z" fill="url(#paint13_linear)" fill-opacity="0.1" style="mix-blend-mode:color-dodge"/>
<path d="M256.683 108.955L251.485 105.202L259.802 97.5905L253.46 92.5859L261.777 86.2258L256.267 82.0553L265 39.5158L251.901 0L167.587 31.5918H97.4127L13.0993 0L0 39.5158L8.8368 82.0553L3.22283 86.2258L11.5398 92.5859L5.19812 97.5905L13.5151 105.202L8.31699 108.955L20.2727 123.031L2.18321 179.542L18.9211 237.199L77.8678 220.934L113.111 203.731L117.997 200.186H125.275H139.829H147.107L151.889 203.731L187.132 220.934L246.079 237.199L262.921 179.542L244.727 123.031L256.683 108.955Z" fill="url(#paint14_radial)" style="mix-blend-mode:overlay"/> <path d="M256.683 108.955L251.485 105.202L259.802 97.5905L253.46 92.5859L261.777 86.2258L256.267 82.0553L265 39.5158L251.901 0L167.587 31.5918H97.4127L13.0993 0L0 39.5158L8.8368 82.0553L3.22283 86.2258L11.5398 92.5859L5.19812 97.5905L13.5151 105.202L8.31699 108.955L20.2727 123.031L2.18321 179.542L18.9211 237.199L77.8678 220.934L113.111 203.731L117.997 200.186H125.275H139.829H147.107L151.889 203.731L187.132 220.934L246.079 237.199L262.921 179.542L244.727 123.031L256.683 108.955Z" fill="url(#paint14_radial)" style="mix-blend-mode:overlay"/>
</g> </g>
<path d="M1166.17 120.732C1159.42 116.127 1151.72 112.742 1144.35 108.759C1139.75 106.295 1134.84 103.855 1130.83 100.47C1124.08 94.944 1125.3 83.5933 1132.67 78.6647C1143.11 71.9189 1160.02 75.603 1161.86 89.7167C1161.86 90.0154 1162.16 90.339 1162.48 90.339H1178.15C1178.44 90.339 1178.77 90.0403 1178.77 89.7167C1177.85 79.8844 1174.16 71.9189 1167.41 66.6916C1160.94 61.763 1153.27 59 1145.27 59C1104.11 59 1100.42 102.611 1122.54 116.426C1125 117.969 1146.82 129.021 1154.49 133.626C1162.16 138.53 1164.62 147.143 1161.24 153.888C1158.17 160.335 1150.18 164.318 1142.48 164.019C1133.89 163.721 1127.12 158.792 1124.98 151.424C1124.68 150.204 1124.36 147.74 1124.36 146.52C1124.36 146.222 1124.06 145.898 1123.73 145.898H1106.85C1106.55 145.898 1106.23 146.197 1106.23 146.52C1106.23 158.792 1109.29 165.563 1117.58 171.711C1125.25 177.536 1133.87 180 1142.78 180C1165.82 180 1177.8 166.807 1180.26 153.291C1182.13 140.372 1178.14 128.399 1166.17 120.732Z" fill="#24292E"/> <path d="M1166.17 120.732C1159.42 116.127 1151.72 112.742 1144.35 108.759C1139.75 106.295 1134.84 103.855 1130.83 100.47C1124.08 94.944 1125.3 83.5933 1132.67 78.6647C1143.11 71.9189 1160.02 75.603 1161.86 89.7167C1161.86 90.0154 1162.16 90.339 1162.48 90.339H1178.15C1178.44 90.339 1178.77 90.0403 1178.77 89.7167C1177.85 79.8844 1174.16 71.9189 1167.41 66.6916C1160.94 61.763 1153.27 59 1145.27 59C1104.11 59 1100.42 102.611 1122.54 116.426C1125 117.969 1146.82 129.021 1154.49 133.626C1162.16 138.53 1164.62 147.143 1161.24 153.888C1158.17 160.335 1150.18 164.318 1142.48 164.019C1133.89 163.721 1127.12 158.792 1124.98 151.424C1124.68 150.204 1124.36 147.74 1124.36 146.52C1124.36 146.222 1124.06 145.898 1123.73 145.898H1106.85C1106.55 145.898 1106.23 146.197 1106.23 146.52C1106.23 158.792 1109.29 165.563 1117.58 171.711C1125.25 177.536 1133.87 180 1142.78 180C1165.82 180 1177.8 166.807 1180.26 153.291C1182.13 140.372 1178.14 128.399 1166.17 120.732Z" fill="white"/>
<path d="M433.192 61.4634H425.522H417.229C416.931 61.4634 416.607 61.7621 416.607 61.7621L402.786 107.514C402.487 108.136 401.865 108.136 401.566 107.514L388.044 61.7621C388.044 61.4634 387.745 61.4634 387.421 61.4634H379.129H371.758H361.623C361.299 61.4634 361 61.7621 361 62.0608V178.754C361 179.053 361.299 179.377 361.623 179.377H378.506C378.805 179.377 379.129 179.078 379.129 178.754V90.0145C379.129 89.3922 380.05 89.0935 380.349 89.7158L394.469 135.766L395.39 138.828C395.39 139.126 395.689 139.126 396.013 139.126H408.912C409.211 139.126 409.535 138.828 409.535 138.828L410.456 135.766L424.576 89.7158C424.576 89.0935 425.497 89.4171 425.497 90.0145V178.754C425.497 179.053 425.796 179.377 426.12 179.377H443.003C443.302 179.377 443.626 179.078 443.626 178.754V62.0608C443.626 61.7621 443.327 61.4385 443.003 61.4385L433.192 61.4634Z" fill="#24292E"/> <path d="M433.192 61.4634H425.522H417.229C416.931 61.4634 416.607 61.7621 416.607 61.7621L402.786 107.514C402.487 108.136 401.865 108.136 401.566 107.514L388.044 61.7621C388.044 61.4634 387.745 61.4634 387.421 61.4634H379.129H371.758H361.623C361.299 61.4634 361 61.7621 361 62.0608V178.754C361 179.053 361.299 179.377 361.623 179.377H378.506C378.805 179.377 379.129 179.078 379.129 178.754V90.0145C379.129 89.3922 380.05 89.0935 380.349 89.7158L394.469 135.766L395.39 138.828C395.39 139.126 395.689 139.126 396.013 139.126H408.912C409.211 139.126 409.535 138.828 409.535 138.828L410.456 135.766L424.576 89.7158C424.576 89.0935 425.497 89.4171 425.497 90.0145V178.754C425.497 179.053 425.796 179.377 426.12 179.377H443.003C443.302 179.377 443.626 179.078 443.626 178.754V62.0608C443.626 61.7621 443.327 61.4385 443.003 61.4385L433.192 61.4634Z" fill="white"/>
<path d="M907.506 61.4634C907.207 61.4634 906.883 61.7621 906.883 61.7621L893.063 107.514C892.764 108.136 892.141 108.136 891.842 107.514L878.022 61.7621C878.022 61.4634 877.723 61.4634 877.399 61.4634H851.6C851.301 61.4634 850.978 61.7621 850.978 62.0857V178.779C850.978 179.078 851.276 179.402 851.6 179.402H868.484C868.783 179.402 869.106 179.103 869.106 178.779V90.0145C869.106 89.3922 870.028 89.0935 870.327 89.7158L884.446 135.766L885.368 138.828C885.368 139.126 885.667 139.126 885.99 139.126H898.89C899.189 139.126 899.512 138.828 899.512 138.828L900.434 135.766L914.553 89.7158C914.852 89.0935 915.773 89.0935 915.773 90.0145V178.754C915.773 179.053 916.072 179.377 916.396 179.377H933.28C933.579 179.377 933.902 179.078 933.902 178.754V62.0608C933.902 61.7621 933.604 61.4385 933.28 61.4385L907.506 61.4634Z" fill="#24292E"/> <path d="M907.506 61.4634C907.207 61.4634 906.883 61.7621 906.883 61.7621L893.063 107.514C892.764 108.136 892.141 108.136 891.842 107.514L878.022 61.7621C878.022 61.4634 877.723 61.4634 877.399 61.4634H851.6C851.301 61.4634 850.978 61.7621 850.978 62.0857V178.779C850.978 179.078 851.276 179.402 851.6 179.402H868.484C868.783 179.402 869.106 179.103 869.106 178.779V90.0145C869.106 89.3922 870.028 89.0935 870.327 89.7158L884.446 135.766L885.368 138.828C885.368 139.126 885.667 139.126 885.99 139.126H898.89C899.189 139.126 899.512 138.828 899.512 138.828L900.434 135.766L914.553 89.7158C914.852 89.0935 915.773 89.0935 915.773 90.0145V178.754C915.773 179.053 916.072 179.377 916.396 179.377H933.28C933.579 179.377 933.902 179.078 933.902 178.754V62.0608C933.902 61.7621 933.604 61.4385 933.28 61.4385L907.506 61.4634Z" fill="white"/>
<path d="M690.01 61.4648H658.359H641.475H610.148C609.849 61.4648 609.525 61.7635 609.525 62.0871V76.5245C609.525 76.8232 609.824 77.1468 610.148 77.1468H640.877V178.482C640.877 178.781 641.176 179.104 641.5 179.104H658.384C658.683 179.104 659.006 178.806 659.006 178.482V77.1219H689.711C690.01 77.1219 690.333 76.8232 690.333 76.4996V62.0623C690.632 61.7635 690.309 61.4648 690.01 61.4648Z" fill="#24292E"/> <path d="M690.01 61.4648H658.359H641.475H610.148C609.849 61.4648 609.525 61.7635 609.525 62.0871V76.5245C609.525 76.8232 609.824 77.1468 610.148 77.1468H640.877V178.482C640.877 178.781 641.176 179.104 641.5 179.104H658.384C658.683 179.104 659.006 178.806 659.006 178.482V77.1219H689.711C690.01 77.1219 690.333 76.8232 690.333 76.4996V62.0623C690.632 61.7635 690.309 61.4648 690.01 61.4648Z" fill="white"/>
<path d="M789.545 179.377H804.91C805.208 179.377 805.532 179.078 805.532 178.456L773.582 61.4637C773.582 61.165 773.284 61.165 772.96 61.165H767.133H756.699H751.17C750.872 61.165 750.548 61.4637 750.548 61.4637L718.897 178.456C718.897 178.755 719.196 179.377 719.52 179.377H734.884C735.183 179.377 735.507 179.078 735.507 179.078L744.721 145.001C744.721 144.703 745.02 144.703 745.343 144.703H779.435C779.733 144.703 780.057 145.001 780.057 145.001L789.271 179.078C788.922 179.054 789.246 179.377 789.545 179.377ZM749.004 127.776L761.281 82.3232C761.58 81.7009 762.202 81.7009 762.501 82.3232L774.778 127.776C774.778 128.075 774.479 128.697 774.155 128.697H749.577C749.303 128.398 749.004 128.1 749.004 127.776Z" fill="#24292E"/> <path d="M789.545 179.377H804.91C805.208 179.377 805.532 179.078 805.532 178.456L773.582 61.4637C773.582 61.165 773.284 61.165 772.96 61.165H767.133H756.699H751.17C750.872 61.165 750.548 61.4637 750.548 61.4637L718.897 178.456C718.897 178.755 719.196 179.377 719.52 179.377H734.884C735.183 179.377 735.507 179.078 735.507 179.078L744.721 145.001C744.721 144.703 745.02 144.703 745.343 144.703H779.435C779.733 144.703 780.057 145.001 780.057 145.001L789.271 179.078C788.922 179.054 789.246 179.377 789.545 179.377ZM749.004 127.776L761.281 82.3232C761.58 81.7009 762.202 81.7009 762.501 82.3232L774.778 127.776C774.778 128.075 774.479 128.697 774.155 128.697H749.577C749.303 128.398 749.004 128.1 749.004 127.776Z" fill="white"/>
<path d="M1051.59 179.377H1066.96C1067.26 179.377 1067.58 179.078 1067.58 178.456L1035.63 61.4637C1035.63 61.165 1035.33 61.165 1035.01 61.165H1029.18H1018.75H1012.92C1012.62 61.165 1012.3 61.4637 1012.3 61.4637L980.646 178.456C980.646 178.755 980.944 179.377 981.268 179.377H996.633C996.932 179.377 997.255 179.078 997.255 179.078L1006.47 145.001C1006.47 144.703 1006.77 144.703 1007.09 144.703H1041.18C1041.48 144.703 1041.81 145.001 1041.81 145.001L1051.02 179.078C1050.97 179.054 1051.27 179.377 1051.59 179.377ZM1011.03 127.776L1023.3 82.3232C1023.6 81.7009 1024.22 81.7009 1024.52 82.3232L1036.8 127.776C1036.8 128.075 1036.5 128.697 1036.18 128.697H1011.6C1011.35 128.398 1011.03 128.1 1011.03 127.776Z" fill="#24292E"/> <path d="M1051.59 179.377H1066.96C1067.26 179.377 1067.58 179.078 1067.58 178.456L1035.63 61.4637C1035.63 61.165 1035.33 61.165 1035.01 61.165H1029.18H1018.75H1012.92C1012.62 61.165 1012.3 61.4637 1012.3 61.4637L980.646 178.456C980.646 178.755 980.944 179.377 981.268 179.377H996.633C996.932 179.377 997.255 179.078 997.255 179.078L1006.47 145.001C1006.47 144.703 1006.77 144.703 1007.09 144.703H1041.18C1041.48 144.703 1041.81 145.001 1041.81 145.001L1051.02 179.078C1050.97 179.054 1051.27 179.377 1051.59 179.377ZM1011.03 127.776L1023.3 82.3232C1023.6 81.7009 1024.22 81.7009 1024.52 82.3232L1036.8 127.776C1036.8 128.075 1036.5 128.697 1036.18 128.697H1011.6C1011.35 128.398 1011.03 128.1 1011.03 127.776Z" fill="white"/>
<path d="M512.132 162.176V125.934C512.132 125.635 512.431 125.311 512.755 125.311H557.604C557.903 125.311 558.227 125.013 558.227 124.689V110.252C558.227 109.953 557.928 109.629 557.604 109.629H512.755C512.456 109.629 512.132 109.331 512.132 109.007V77.7427C512.132 77.444 512.431 77.1204 512.755 77.1204H563.755C564.054 77.1204 564.377 76.8217 564.377 76.4981V62.0608C564.377 61.7621 564.079 61.4385 563.755 61.4385H512.132H494.626C494.327 61.4385 494.003 61.7372 494.003 62.0608V77.0955V109.331V124.988V162.45V178.406C494.003 178.705 494.302 179.028 494.626 179.028H512.132H566.195C566.494 179.028 566.818 178.73 566.818 178.406V163.048C566.818 162.749 566.519 162.425 566.195 162.425H512.755C512.456 162.799 512.132 162.475 512.132 162.176Z" fill="#24292E"/> <path d="M512.132 162.176V125.934C512.132 125.635 512.431 125.311 512.755 125.311H557.604C557.903 125.311 558.227 125.013 558.227 124.689V110.252C558.227 109.953 557.928 109.629 557.604 109.629H512.755C512.456 109.629 512.132 109.331 512.132 109.007V77.7427C512.132 77.444 512.431 77.1204 512.755 77.1204H563.755C564.054 77.1204 564.377 76.8217 564.377 76.4981V62.0608C564.377 61.7621 564.079 61.4385 563.755 61.4385H512.132H494.626C494.327 61.4385 494.003 61.7372 494.003 62.0608V77.0955V109.331V124.988V162.45V178.406C494.003 178.705 494.302 179.028 494.626 179.028H512.132H566.195C566.494 179.028 566.818 178.73 566.818 178.406V163.048C566.818 162.749 566.519 162.425 566.195 162.425H512.755C512.456 162.799 512.132 162.475 512.132 162.176Z" fill="white"/>
<path d="M1320.39 178.132L1262.02 117.645C1261.72 117.346 1261.72 117.022 1262.02 116.724L1314.54 62.3844C1314.83 62.0857 1314.54 61.4634 1314.24 61.4634H1292.72C1292.42 61.4634 1292.42 61.4634 1292.42 61.7621L1247.87 108.136C1247.57 108.435 1246.95 108.136 1246.95 107.837V62.0608C1246.95 61.7621 1246.65 61.4385 1246.33 61.4385H1229.44C1229.15 61.4385 1228.82 61.7372 1228.82 62.0608V178.754C1228.82 179.053 1229.12 179.377 1229.44 179.377H1246.33C1246.63 179.377 1246.95 179.078 1246.95 178.754V127.178C1246.95 126.556 1247.57 126.257 1247.87 126.88L1298.25 179.078L1298.55 179.377H1320.06C1320.69 179.377 1320.99 178.754 1320.39 178.132Z" fill="#24292E"/> <path d="M1320.39 178.132L1262.02 117.645C1261.72 117.346 1261.72 117.022 1262.02 116.724L1314.54 62.3844C1314.83 62.0857 1314.54 61.4634 1314.24 61.4634H1292.72C1292.42 61.4634 1292.42 61.4634 1292.42 61.7621L1247.87 108.136C1247.57 108.435 1246.95 108.136 1246.95 107.837V62.0608C1246.95 61.7621 1246.65 61.4385 1246.33 61.4385H1229.44C1229.15 61.4385 1228.82 61.7372 1228.82 62.0608V178.754C1228.82 179.053 1229.12 179.377 1229.44 179.377H1246.33C1246.63 179.377 1246.95 179.078 1246.95 178.754V127.178C1246.95 126.556 1247.57 126.257 1247.87 126.88L1298.25 179.078L1298.55 179.377H1320.06C1320.69 179.377 1320.99 178.754 1320.39 178.132Z" fill="white"/>
<rect x="1338" y="27" width="194" height="84" rx="12" fill="#24292E"/> <rect x="1338" y="27" width="194" height="84" rx="12" fill="#24292E"/>
<path d="M1358 50.6376H1380.89V58.7139H1366.33V65.515H1377.7V73.5913H1366.33V88.3624H1358V50.6376Z" fill="white"/> <path d="M1358 50.6376H1380.89V58.7139H1366.33V65.515H1377.7V73.5913H1366.33V88.3624H1358V50.6376Z" fill="white"/>
<path d="M1386.55 50.6376H1394.87V80.2861H1410.28V88.3624H1386.55V50.6376Z" fill="white"/> <path d="M1386.55 50.6376H1394.87V80.2861H1410.28V88.3624H1386.55V50.6376Z" fill="white"/>

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

@ -34,14 +34,14 @@
<path d="M256.683 108.955L251.485 105.202L259.802 97.5905L253.46 92.5859L261.777 86.2258L256.267 82.0553L265 39.5158L251.901 0L167.587 31.5918H97.4127L13.0993 0L0 39.5158L8.8368 82.0553L3.22283 86.2258L11.5398 92.5859L5.19812 97.5905L13.5151 105.202L8.31699 108.955L20.2727 123.031L2.18321 179.542L18.9211 237.199L77.8678 220.934L113.111 203.731L110.231 227.044L112.903 224.583L152.097 224.479L154.696 226.773L151.889 203.731L187.132 220.934L246.079 237.199L262.921 179.542L244.727 123.031L256.683 108.955Z" fill="url(#paint13_linear)" fill-opacity="0.1" style="mix-blend-mode:color-dodge"/> <path d="M256.683 108.955L251.485 105.202L259.802 97.5905L253.46 92.5859L261.777 86.2258L256.267 82.0553L265 39.5158L251.901 0L167.587 31.5918H97.4127L13.0993 0L0 39.5158L8.8368 82.0553L3.22283 86.2258L11.5398 92.5859L5.19812 97.5905L13.5151 105.202L8.31699 108.955L20.2727 123.031L2.18321 179.542L18.9211 237.199L77.8678 220.934L113.111 203.731L110.231 227.044L112.903 224.583L152.097 224.479L154.696 226.773L151.889 203.731L187.132 220.934L246.079 237.199L262.921 179.542L244.727 123.031L256.683 108.955Z" fill="url(#paint13_linear)" fill-opacity="0.1" style="mix-blend-mode:color-dodge"/>
<path d="M256.683 108.955L251.485 105.202L259.802 97.5905L253.46 92.5859L261.777 86.2258L256.267 82.0553L265 39.5158L251.901 0L167.587 31.5918H97.4127L13.0993 0L0 39.5158L8.8368 82.0553L3.22283 86.2258L11.5398 92.5859L5.19812 97.5905L13.5151 105.202L8.31699 108.955L20.2727 123.031L2.18321 179.542L18.9211 237.199L77.8678 220.934L113.111 203.731L117.997 200.186H125.275H139.829H147.107L151.889 203.731L187.132 220.934L246.079 237.199L262.921 179.542L244.727 123.031L256.683 108.955Z" fill="url(#paint14_radial)" style="mix-blend-mode:overlay"/> <path d="M256.683 108.955L251.485 105.202L259.802 97.5905L253.46 92.5859L261.777 86.2258L256.267 82.0553L265 39.5158L251.901 0L167.587 31.5918H97.4127L13.0993 0L0 39.5158L8.8368 82.0553L3.22283 86.2258L11.5398 92.5859L5.19812 97.5905L13.5151 105.202L8.31699 108.955L20.2727 123.031L2.18321 179.542L18.9211 237.199L77.8678 220.934L113.111 203.731L117.997 200.186H125.275H139.829H147.107L151.889 203.731L187.132 220.934L246.079 237.199L262.921 179.542L244.727 123.031L256.683 108.955Z" fill="url(#paint14_radial)" style="mix-blend-mode:overlay"/>
</g> </g>
<path d="M1166.17 120.732C1159.42 116.127 1151.72 112.742 1144.35 108.759C1139.75 106.295 1134.84 103.855 1130.83 100.47C1124.08 94.944 1125.3 83.5933 1132.67 78.6647C1143.11 71.9189 1160.02 75.603 1161.86 89.7167C1161.86 90.0154 1162.16 90.339 1162.48 90.339H1178.15C1178.44 90.339 1178.77 90.0403 1178.77 89.7167C1177.85 79.8844 1174.16 71.9189 1167.41 66.6916C1160.94 61.763 1153.27 59 1145.27 59C1104.11 59 1100.42 102.611 1122.54 116.426C1125 117.969 1146.82 129.021 1154.49 133.626C1162.16 138.53 1164.62 147.143 1161.24 153.888C1158.17 160.335 1150.18 164.318 1142.48 164.019C1133.89 163.721 1127.12 158.792 1124.98 151.424C1124.68 150.204 1124.36 147.74 1124.36 146.52C1124.36 146.222 1124.06 145.898 1123.73 145.898H1106.85C1106.55 145.898 1106.23 146.197 1106.23 146.52C1106.23 158.792 1109.29 165.563 1117.58 171.711C1125.25 177.536 1133.87 180 1142.78 180C1165.82 180 1177.8 166.807 1180.26 153.291C1182.13 140.372 1178.14 128.399 1166.17 120.732Z" fill="white"/> <path d="M1166.17 120.732C1159.42 116.127 1151.72 112.742 1144.35 108.759C1139.75 106.295 1134.84 103.855 1130.83 100.47C1124.08 94.944 1125.3 83.5933 1132.67 78.6647C1143.11 71.9189 1160.02 75.603 1161.86 89.7167C1161.86 90.0154 1162.16 90.339 1162.48 90.339H1178.15C1178.44 90.339 1178.77 90.0403 1178.77 89.7167C1177.85 79.8844 1174.16 71.9189 1167.41 66.6916C1160.94 61.763 1153.27 59 1145.27 59C1104.11 59 1100.42 102.611 1122.54 116.426C1125 117.969 1146.82 129.021 1154.49 133.626C1162.16 138.53 1164.62 147.143 1161.24 153.888C1158.17 160.335 1150.18 164.318 1142.48 164.019C1133.89 163.721 1127.12 158.792 1124.98 151.424C1124.68 150.204 1124.36 147.74 1124.36 146.52C1124.36 146.222 1124.06 145.898 1123.73 145.898H1106.85C1106.55 145.898 1106.23 146.197 1106.23 146.52C1106.23 158.792 1109.29 165.563 1117.58 171.711C1125.25 177.536 1133.87 180 1142.78 180C1165.82 180 1177.8 166.807 1180.26 153.291C1182.13 140.372 1178.14 128.399 1166.17 120.732Z" fill="#24292E"/>
<path d="M433.192 61.4634H425.522H417.229C416.931 61.4634 416.607 61.7621 416.607 61.7621L402.786 107.514C402.487 108.136 401.865 108.136 401.566 107.514L388.044 61.7621C388.044 61.4634 387.745 61.4634 387.421 61.4634H379.129H371.758H361.623C361.299 61.4634 361 61.7621 361 62.0608V178.754C361 179.053 361.299 179.377 361.623 179.377H378.506C378.805 179.377 379.129 179.078 379.129 178.754V90.0145C379.129 89.3922 380.05 89.0935 380.349 89.7158L394.469 135.766L395.39 138.828C395.39 139.126 395.689 139.126 396.013 139.126H408.912C409.211 139.126 409.535 138.828 409.535 138.828L410.456 135.766L424.576 89.7158C424.576 89.0935 425.497 89.4171 425.497 90.0145V178.754C425.497 179.053 425.796 179.377 426.12 179.377H443.003C443.302 179.377 443.626 179.078 443.626 178.754V62.0608C443.626 61.7621 443.327 61.4385 443.003 61.4385L433.192 61.4634Z" fill="white"/> <path d="M433.192 61.4634H425.522H417.229C416.931 61.4634 416.607 61.7621 416.607 61.7621L402.786 107.514C402.487 108.136 401.865 108.136 401.566 107.514L388.044 61.7621C388.044 61.4634 387.745 61.4634 387.421 61.4634H379.129H371.758H361.623C361.299 61.4634 361 61.7621 361 62.0608V178.754C361 179.053 361.299 179.377 361.623 179.377H378.506C378.805 179.377 379.129 179.078 379.129 178.754V90.0145C379.129 89.3922 380.05 89.0935 380.349 89.7158L394.469 135.766L395.39 138.828C395.39 139.126 395.689 139.126 396.013 139.126H408.912C409.211 139.126 409.535 138.828 409.535 138.828L410.456 135.766L424.576 89.7158C424.576 89.0935 425.497 89.4171 425.497 90.0145V178.754C425.497 179.053 425.796 179.377 426.12 179.377H443.003C443.302 179.377 443.626 179.078 443.626 178.754V62.0608C443.626 61.7621 443.327 61.4385 443.003 61.4385L433.192 61.4634Z" fill="#24292E"/>
<path d="M907.506 61.4634C907.207 61.4634 906.883 61.7621 906.883 61.7621L893.063 107.514C892.764 108.136 892.141 108.136 891.842 107.514L878.022 61.7621C878.022 61.4634 877.723 61.4634 877.399 61.4634H851.6C851.301 61.4634 850.978 61.7621 850.978 62.0857V178.779C850.978 179.078 851.276 179.402 851.6 179.402H868.484C868.783 179.402 869.106 179.103 869.106 178.779V90.0145C869.106 89.3922 870.028 89.0935 870.327 89.7158L884.446 135.766L885.368 138.828C885.368 139.126 885.667 139.126 885.99 139.126H898.89C899.189 139.126 899.512 138.828 899.512 138.828L900.434 135.766L914.553 89.7158C914.852 89.0935 915.773 89.0935 915.773 90.0145V178.754C915.773 179.053 916.072 179.377 916.396 179.377H933.28C933.579 179.377 933.902 179.078 933.902 178.754V62.0608C933.902 61.7621 933.604 61.4385 933.28 61.4385L907.506 61.4634Z" fill="white"/> <path d="M907.506 61.4634C907.207 61.4634 906.883 61.7621 906.883 61.7621L893.063 107.514C892.764 108.136 892.141 108.136 891.842 107.514L878.022 61.7621C878.022 61.4634 877.723 61.4634 877.399 61.4634H851.6C851.301 61.4634 850.978 61.7621 850.978 62.0857V178.779C850.978 179.078 851.276 179.402 851.6 179.402H868.484C868.783 179.402 869.106 179.103 869.106 178.779V90.0145C869.106 89.3922 870.028 89.0935 870.327 89.7158L884.446 135.766L885.368 138.828C885.368 139.126 885.667 139.126 885.99 139.126H898.89C899.189 139.126 899.512 138.828 899.512 138.828L900.434 135.766L914.553 89.7158C914.852 89.0935 915.773 89.0935 915.773 90.0145V178.754C915.773 179.053 916.072 179.377 916.396 179.377H933.28C933.579 179.377 933.902 179.078 933.902 178.754V62.0608C933.902 61.7621 933.604 61.4385 933.28 61.4385L907.506 61.4634Z" fill="#24292E"/>
<path d="M690.01 61.4648H658.359H641.475H610.148C609.849 61.4648 609.525 61.7635 609.525 62.0871V76.5245C609.525 76.8232 609.824 77.1468 610.148 77.1468H640.877V178.482C640.877 178.781 641.176 179.104 641.5 179.104H658.384C658.683 179.104 659.006 178.806 659.006 178.482V77.1219H689.711C690.01 77.1219 690.333 76.8232 690.333 76.4996V62.0623C690.632 61.7635 690.309 61.4648 690.01 61.4648Z" fill="white"/> <path d="M690.01 61.4648H658.359H641.475H610.148C609.849 61.4648 609.525 61.7635 609.525 62.0871V76.5245C609.525 76.8232 609.824 77.1468 610.148 77.1468H640.877V178.482C640.877 178.781 641.176 179.104 641.5 179.104H658.384C658.683 179.104 659.006 178.806 659.006 178.482V77.1219H689.711C690.01 77.1219 690.333 76.8232 690.333 76.4996V62.0623C690.632 61.7635 690.309 61.4648 690.01 61.4648Z" fill="#24292E"/>
<path d="M789.545 179.377H804.91C805.208 179.377 805.532 179.078 805.532 178.456L773.582 61.4637C773.582 61.165 773.284 61.165 772.96 61.165H767.133H756.699H751.17C750.872 61.165 750.548 61.4637 750.548 61.4637L718.897 178.456C718.897 178.755 719.196 179.377 719.52 179.377H734.884C735.183 179.377 735.507 179.078 735.507 179.078L744.721 145.001C744.721 144.703 745.02 144.703 745.343 144.703H779.435C779.733 144.703 780.057 145.001 780.057 145.001L789.271 179.078C788.922 179.054 789.246 179.377 789.545 179.377ZM749.004 127.776L761.281 82.3232C761.58 81.7009 762.202 81.7009 762.501 82.3232L774.778 127.776C774.778 128.075 774.479 128.697 774.155 128.697H749.577C749.303 128.398 749.004 128.1 749.004 127.776Z" fill="white"/> <path d="M789.545 179.377H804.91C805.208 179.377 805.532 179.078 805.532 178.456L773.582 61.4637C773.582 61.165 773.284 61.165 772.96 61.165H767.133H756.699H751.17C750.872 61.165 750.548 61.4637 750.548 61.4637L718.897 178.456C718.897 178.755 719.196 179.377 719.52 179.377H734.884C735.183 179.377 735.507 179.078 735.507 179.078L744.721 145.001C744.721 144.703 745.02 144.703 745.343 144.703H779.435C779.733 144.703 780.057 145.001 780.057 145.001L789.271 179.078C788.922 179.054 789.246 179.377 789.545 179.377ZM749.004 127.776L761.281 82.3232C761.58 81.7009 762.202 81.7009 762.501 82.3232L774.778 127.776C774.778 128.075 774.479 128.697 774.155 128.697H749.577C749.303 128.398 749.004 128.1 749.004 127.776Z" fill="#24292E"/>
<path d="M1051.59 179.377H1066.96C1067.26 179.377 1067.58 179.078 1067.58 178.456L1035.63 61.4637C1035.63 61.165 1035.33 61.165 1035.01 61.165H1029.18H1018.75H1012.92C1012.62 61.165 1012.3 61.4637 1012.3 61.4637L980.646 178.456C980.646 178.755 980.944 179.377 981.268 179.377H996.633C996.932 179.377 997.255 179.078 997.255 179.078L1006.47 145.001C1006.47 144.703 1006.77 144.703 1007.09 144.703H1041.18C1041.48 144.703 1041.81 145.001 1041.81 145.001L1051.02 179.078C1050.97 179.054 1051.27 179.377 1051.59 179.377ZM1011.03 127.776L1023.3 82.3232C1023.6 81.7009 1024.22 81.7009 1024.52 82.3232L1036.8 127.776C1036.8 128.075 1036.5 128.697 1036.18 128.697H1011.6C1011.35 128.398 1011.03 128.1 1011.03 127.776Z" fill="white"/> <path d="M1051.59 179.377H1066.96C1067.26 179.377 1067.58 179.078 1067.58 178.456L1035.63 61.4637C1035.63 61.165 1035.33 61.165 1035.01 61.165H1029.18H1018.75H1012.92C1012.62 61.165 1012.3 61.4637 1012.3 61.4637L980.646 178.456C980.646 178.755 980.944 179.377 981.268 179.377H996.633C996.932 179.377 997.255 179.078 997.255 179.078L1006.47 145.001C1006.47 144.703 1006.77 144.703 1007.09 144.703H1041.18C1041.48 144.703 1041.81 145.001 1041.81 145.001L1051.02 179.078C1050.97 179.054 1051.27 179.377 1051.59 179.377ZM1011.03 127.776L1023.3 82.3232C1023.6 81.7009 1024.22 81.7009 1024.52 82.3232L1036.8 127.776C1036.8 128.075 1036.5 128.697 1036.18 128.697H1011.6C1011.35 128.398 1011.03 128.1 1011.03 127.776Z" fill="#24292E"/>
<path d="M512.132 162.176V125.934C512.132 125.635 512.431 125.311 512.755 125.311H557.604C557.903 125.311 558.227 125.013 558.227 124.689V110.252C558.227 109.953 557.928 109.629 557.604 109.629H512.755C512.456 109.629 512.132 109.331 512.132 109.007V77.7427C512.132 77.444 512.431 77.1204 512.755 77.1204H563.755C564.054 77.1204 564.377 76.8217 564.377 76.4981V62.0608C564.377 61.7621 564.079 61.4385 563.755 61.4385H512.132H494.626C494.327 61.4385 494.003 61.7372 494.003 62.0608V77.0955V109.331V124.988V162.45V178.406C494.003 178.705 494.302 179.028 494.626 179.028H512.132H566.195C566.494 179.028 566.818 178.73 566.818 178.406V163.048C566.818 162.749 566.519 162.425 566.195 162.425H512.755C512.456 162.799 512.132 162.475 512.132 162.176Z" fill="white"/> <path d="M512.132 162.176V125.934C512.132 125.635 512.431 125.311 512.755 125.311H557.604C557.903 125.311 558.227 125.013 558.227 124.689V110.252C558.227 109.953 557.928 109.629 557.604 109.629H512.755C512.456 109.629 512.132 109.331 512.132 109.007V77.7427C512.132 77.444 512.431 77.1204 512.755 77.1204H563.755C564.054 77.1204 564.377 76.8217 564.377 76.4981V62.0608C564.377 61.7621 564.079 61.4385 563.755 61.4385H512.132H494.626C494.327 61.4385 494.003 61.7372 494.003 62.0608V77.0955V109.331V124.988V162.45V178.406C494.003 178.705 494.302 179.028 494.626 179.028H512.132H566.195C566.494 179.028 566.818 178.73 566.818 178.406V163.048C566.818 162.749 566.519 162.425 566.195 162.425H512.755C512.456 162.799 512.132 162.475 512.132 162.176Z" fill="#24292E"/>
<path d="M1320.39 178.132L1262.02 117.645C1261.72 117.346 1261.72 117.022 1262.02 116.724L1314.54 62.3844C1314.83 62.0857 1314.54 61.4634 1314.24 61.4634H1292.72C1292.42 61.4634 1292.42 61.4634 1292.42 61.7621L1247.87 108.136C1247.57 108.435 1246.95 108.136 1246.95 107.837V62.0608C1246.95 61.7621 1246.65 61.4385 1246.33 61.4385H1229.44C1229.15 61.4385 1228.82 61.7372 1228.82 62.0608V178.754C1228.82 179.053 1229.12 179.377 1229.44 179.377H1246.33C1246.63 179.377 1246.95 179.078 1246.95 178.754V127.178C1246.95 126.556 1247.57 126.257 1247.87 126.88L1298.25 179.078L1298.55 179.377H1320.06C1320.69 179.377 1320.99 178.754 1320.39 178.132Z" fill="white"/> <path d="M1320.39 178.132L1262.02 117.645C1261.72 117.346 1261.72 117.022 1262.02 116.724L1314.54 62.3844C1314.83 62.0857 1314.54 61.4634 1314.24 61.4634H1292.72C1292.42 61.4634 1292.42 61.4634 1292.42 61.7621L1247.87 108.136C1247.57 108.435 1246.95 108.136 1246.95 107.837V62.0608C1246.95 61.7621 1246.65 61.4385 1246.33 61.4385H1229.44C1229.15 61.4385 1228.82 61.7372 1228.82 62.0608V178.754C1228.82 179.053 1229.12 179.377 1229.44 179.377H1246.33C1246.63 179.377 1246.95 179.078 1246.95 178.754V127.178C1246.95 126.556 1247.57 126.257 1247.87 126.88L1298.25 179.078L1298.55 179.377H1320.06C1320.69 179.377 1320.99 178.754 1320.39 178.132Z" fill="#24292E"/>
<rect x="1338" y="27" width="194" height="84" rx="12" fill="#24292E"/> <rect x="1338" y="27" width="194" height="84" rx="12" fill="#24292E"/>
<path d="M1358 50.6376H1380.89V58.7139H1366.33V65.515H1377.7V73.5913H1366.33V88.3624H1358V50.6376Z" fill="white"/> <path d="M1358 50.6376H1380.89V58.7139H1366.33V65.515H1377.7V73.5913H1366.33V88.3624H1358V50.6376Z" fill="white"/>
<path d="M1386.55 50.6376H1394.87V80.2861H1410.28V88.3624H1386.55V50.6376Z" fill="white"/> <path d="M1386.55 50.6376H1394.87V80.2861H1410.28V88.3624H1386.55V50.6376Z" fill="white"/>

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

@ -0,0 +1,4 @@
{
"description": "THIS IS THE CANARY DISTRIBUTION OF THE METAMASK EXTENSION, INTENDED FOR DEVELOPERS.",
"name": "MetaMask Flask DEVELOPMENT BUILD"
}

@ -0,0 +1 @@
<svg width="136" height="31" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M91.201 8.705h1.03l2.39 5.65 2.4-5.65h1.01l-3.02 7.1h-.78l-3.03-7.1Zm9.327 7.2a2.73 2.73 0 0 1-1.06-.2 2.431 2.431 0 0 1-1.35-1.37 2.91 2.91 0 0 1-.18-1.03c0-.367.063-.707.19-1.02.126-.32.303-.597.53-.83.233-.234.51-.417.83-.55.32-.134.67-.2 1.05-.2.326 0 .633.056.92.17.286.106.536.27.75.49.22.213.393.476.52.79.126.306.19.66.19 1.06v.13c0 .033-.004.076-.01.13h-4.1c.006.233.053.45.14.65.093.2.213.373.36.52.153.146.333.263.54.35.213.08.443.12.69.12.386 0 .703-.077.95-.23a1.82 1.82 0 0 0 .61-.64l.68.47a2.48 2.48 0 0 1-.91.87c-.374.213-.82.32-1.34.32Zm1.51-3.13a1.601 1.601 0 0 0-.19-.55c-.087-.16-.2-.297-.34-.41a1.431 1.431 0 0 0-.46-.26 1.645 1.645 0 0 0-.54-.09 1.73 1.73 0 0 0-1.04.35 1.5 1.5 0 0 0-.38.41c-.107.16-.18.343-.22.55h3.17Zm2.1-1.97h.86v.97c.047-.16.12-.304.22-.43a1.556 1.556 0 0 1 .74-.52c.147-.047.294-.07.44-.07.134 0 .264.013.39.04v.89a.78.78 0 0 0-.23-.06 1.342 1.342 0 0 0-.24-.02c-.16 0-.32.036-.48.11-.153.066-.293.17-.42.31-.12.14-.22.32-.3.54-.08.213-.12.466-.12.76v2.48h-.86v-5Zm4.032 7.09 1.09-2.35-2.19-4.74h.95l1.72 3.8 1.71-3.8h.96l-3.28 7.09h-.96Zm7.287-2.09v-7.5h.86v3.27c.173-.3.4-.52.68-.66s.583-.21.91-.21c.28 0 .536.046.77.14.233.093.43.23.59.41.166.173.296.386.39.64.093.246.14.523.14.83v3.08h-.85v-2.95c0-.42-.11-.75-.33-.99-.214-.247-.497-.37-.85-.37-.2 0-.39.04-.57.12-.174.08-.327.2-.46.36-.127.153-.23.343-.31.57-.074.226-.11.486-.11.78v2.48h-.86Zm6.328-6.34a.599.599 0 0 1-.62-.61c0-.167.06-.31.18-.43s.267-.18.44-.18a.602.602 0 0 1 .6.61c0 .173-.056.32-.17.44a.581.581 0 0 1-.43.17Zm-.44 1.34h.86v5h-.86v-5Zm4.671 4.19c.246 0 .473-.044.68-.13.213-.087.393-.207.54-.36.146-.16.26-.347.34-.56.086-.214.13-.447.13-.7 0-.247-.044-.477-.13-.69a1.588 1.588 0 0 0-.34-.56 1.542 1.542 0 0 0-.54-.36 1.637 1.637 0 0 0-.68-.14c-.254 0-.484.046-.69.14a1.548 1.548 0 0 0-.53.36c-.147.153-.264.34-.35.56-.08.213-.12.443-.12.69 0 .253.04.486.12.7.086.213.203.4.35.56.146.153.323.273.53.36.206.086.436.13.69.13Zm-.05 3c-.54 0-1.014-.1-1.42-.3-.407-.2-.717-.44-.93-.72l.6-.6c.2.253.443.456.73.61.293.153.633.23 1.02.23.213 0 .42-.034.62-.1.2-.067.376-.174.53-.32.16-.14.286-.32.38-.54.093-.214.14-.47.14-.77v-.57a2.032 2.032 0 0 1-.72.63c-.307.166-.647.25-1.02.25-.347 0-.67-.064-.97-.19-.3-.134-.56-.314-.78-.54a2.623 2.623 0 0 1-.52-.81 2.8 2.8 0 0 1-.18-1.01c0-.354.06-.684.18-.99.126-.314.3-.584.52-.81.22-.234.48-.414.78-.54.3-.134.623-.2.97-.2.373 0 .713.083 1.02.25.306.16.546.366.72.62v-.77h.86v4.71c0 .42-.067.783-.2 1.09-.127.313-.304.57-.53.77-.227.206-.494.36-.8.46-.307.106-.64.16-1 .16Zm4.149-2.19v-7.5h.86v3.27c.173-.3.4-.52.68-.66s.583-.21.91-.21c.28 0 .537.046.77.14.233.093.43.23.59.41.167.173.297.386.39.64.093.246.14.523.14.83v3.08h-.85v-2.95c0-.42-.11-.75-.33-.99-.213-.247-.497-.37-.85-.37-.2 0-.39.04-.57.12-.173.08-.327.2-.46.36-.127.153-.23.343-.31.57-.073.226-.11.486-.11.78v2.48h-.86Z" fill="#F66A0A"/><path opacity=".3" d="M19.506 22.805c-10.763 7.42-19.416 8-19.416 8h110.25s-8.653-.58-19.417-8c-10.763-7.42-23.363-22-35.708-22-12.345 0-24.945 14.58-35.709 22Z" fill="#037DD6"/><mask id="a" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="111" height="31"><path d="M19.506 22.672c-10.763 7.42-19.416 8-19.416 8h110.25s-8.653-.58-19.417-8c-10.763-7.42-23.363-22-35.708-22-12.345 0-24.945 14.58-35.709 22Z" fill="#EAF6FF"/></mask><g mask="url(#a)"><path fill="#F66A0A" stroke="#fff" stroke-width="2" d="M91.986-5.143h20.706v39.25H91.986z"/></g></svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

@ -0,0 +1 @@
<svg width="125" height="31" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M14.233 8.738h.93v6.22h3.43v.88h-4.36v-7.1Zm7.412 7.2c-.38 0-.73-.067-1.05-.2a2.679 2.679 0 0 1-.83-.56 2.569 2.569 0 0 1-.55-.82c-.126-.32-.19-.66-.19-1.02s.064-.697.19-1.01a2.54 2.54 0 0 1 .55-.83c.234-.233.51-.417.83-.55.32-.14.67-.21 1.05-.21.374 0 .72.07 1.04.21.32.133.597.317.83.55.234.233.414.51.54.83.134.313.2.65.2 1.01s-.066.7-.2 1.02a2.44 2.44 0 0 1-.54.82 2.68 2.68 0 0 1-.83.56c-.32.133-.666.2-1.04.2Zm0-.8c.26 0 .497-.047.71-.14.214-.093.394-.22.54-.38.154-.167.27-.357.35-.57.087-.22.13-.457.13-.71 0-.247-.043-.48-.13-.7-.08-.22-.196-.41-.35-.57a1.52 1.52 0 0 0-.54-.39 1.754 1.754 0 0 0-.71-.14c-.26 0-.496.047-.71.14a1.619 1.619 0 0 0-.55.39c-.153.16-.273.35-.36.57-.08.22-.12.453-.12.7 0 .253.04.49.12.71.087.213.207.403.36.57.154.16.337.287.55.38.214.093.45.14.71.14Zm6.825-2.89-1.23 3.59h-.76l-1.7-5h.9l1.21 3.65 1.25-3.65h.66l1.25 3.65 1.21-3.65h.91l-1.7 5h-.76l-1.24-3.59Z" fill="#F66A0A"/><path opacity=".3" d="M33.96 22.838c-10.764 7.42-19.417 8-19.417 8h110.25s-8.653-.58-19.416-8c-10.764-7.42-23.364-22-35.709-22-12.345 0-24.945 14.58-35.709 22Z" fill="#037DD6"/><mask id="a" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="14" y="0" width="111" height="31"><path d="M33.96 22.705c-10.764 7.42-19.417 8-19.417 8h110.25s-8.653-.58-19.416-8c-10.764-7.42-23.364-22-35.709-22-12.345 0-24.945 14.58-35.709 22Z" fill="#EAF6FF"/></mask><g mask="url(#a)"><path fill="#F66A0A" stroke="#fff" stroke-width="2" d="M12.793 16.838h20.706v17.303H12.793z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@ -0,0 +1 @@
<svg width="111" height="49" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="m38 4.334 2.63 3.54 2.65-3.54h.84v7.1h-.92v-5.52l-2.56 3.44-2.56-3.44v5.52h-.93v-7.1H38Zm9.99 7.2a2.73 2.73 0 0 1-1.06-.2 2.431 2.431 0 0 1-1.35-1.37 2.91 2.91 0 0 1-.18-1.03c0-.367.063-.707.19-1.02.126-.32.303-.597.53-.83.233-.234.51-.417.83-.55.32-.134.67-.2 1.05-.2.326 0 .633.056.92.17.286.106.536.27.75.49.22.213.393.476.52.79.126.306.19.66.19 1.06v.13c0 .033-.004.076-.01.13h-4.1c.006.233.053.45.14.65.093.2.213.373.36.52.153.146.333.263.54.35.213.08.443.12.69.12.386 0 .703-.077.95-.23.253-.16.456-.374.61-.64l.68.47c-.227.366-.53.656-.91.87-.374.213-.82.32-1.34.32Zm1.51-3.13a1.579 1.579 0 0 0-.19-.55c-.087-.16-.2-.297-.34-.41a1.419 1.419 0 0 0-.46-.26 1.639 1.639 0 0 0-.54-.09 1.729 1.729 0 0 0-1.04.35 1.5 1.5 0 0 0-.38.41c-.107.16-.18.343-.22.55h3.17Zm4.16 3.13c-.353 0-.68-.067-.98-.2a2.57 2.57 0 0 1-.77-.56 2.73 2.73 0 0 1-.52-.83 2.8 2.8 0 0 1-.18-1.01c0-.36.06-.697.18-1.01.127-.314.3-.587.52-.82a2.359 2.359 0 0 1 1.75-.77c.38 0 .727.086 1.04.26.314.166.554.37.72.61v-3.27h.86v7.5h-.86v-.77a2.08 2.08 0 0 1-.72.62 2.18 2.18 0 0 1-1.04.25Zm.13-.79a1.607 1.607 0 0 0 1.22-.52c.154-.167.27-.36.35-.58.087-.22.13-.457.13-.71 0-.254-.043-.49-.13-.71-.08-.22-.196-.41-.35-.57a1.544 1.544 0 0 0-.53-.39 1.658 1.658 0 0 0-.69-.14c-.253 0-.486.046-.7.14a1.648 1.648 0 0 0-.54.39 1.82 1.82 0 0 0-.35.57c-.08.22-.12.456-.12.71 0 .253.04.49.12.71.087.22.204.413.35.58.154.16.334.286.54.38.214.093.447.14.7.14Zm4.55-5.65a.599.599 0 0 1-.62-.61c0-.167.06-.31.18-.43s.266-.18.44-.18c.172 0 .316.06.43.18.112.12.17.263.17.43 0 .173-.058.32-.17.44a.583.583 0 0 1-.43.17Zm-.44 1.34h.86v5h-.86v-5Zm4.26 5.1c-.274 0-.524-.047-.75-.14-.227-.1-.42-.237-.58-.41a1.917 1.917 0 0 1-.38-.63 2.617 2.617 0 0 1-.13-.85v-3.07h.86v2.94c0 .42.1.753.3 1 .206.246.483.37.83.37.193 0 .373-.04.54-.12.173-.087.32-.207.44-.36.126-.16.226-.354.3-.58.073-.227.11-.484.11-.77v-2.48h.86v5h-.86v-.77a1.654 1.654 0 0 1-.66.66c-.267.14-.56.21-.88.21Zm10.46-3.04c0-.407-.083-.737-.25-.99-.166-.254-.413-.38-.74-.38-.4 0-.726.156-.98.47-.246.313-.376.74-.39 1.28v2.56h-.86v-2.94c0-.407-.083-.737-.25-.99-.16-.254-.403-.38-.73-.38-.406 0-.74.163-1 .49-.253.326-.38.773-.38 1.34v2.48h-.86v-5h.86v.77a1.63 1.63 0 0 1 .61-.63c.26-.16.56-.24.9-.24.374 0 .69.096.95.29.267.186.464.446.59.78.134-.327.344-.587.63-.78.294-.194.63-.29 1.01-.29.274 0 .517.05.73.15.22.093.404.23.55.41.154.173.27.386.35.64.08.246.12.523.12.83v3.07h-.86v-2.94Z" fill="#037DD6"/><path opacity=".3" d="M19.506 40.566c-10.763 7.42-19.416 8-19.416 8h110.25s-8.653-.58-19.417-8c-10.763-7.42-23.363-22-35.708-22-12.345 0-24.945 14.58-35.709 22Z" fill="#037DD6"/><mask id="a" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="18" width="111" height="31"><path d="M19.506 40.434c-10.763 7.42-19.416 8-19.416 8h110.25s-8.653-.58-19.417-8c-10.763-7.42-23.363-22-35.708-22-12.345 0-24.945 14.58-35.709 22Z" fill="#EAF6FF"/></mask><g mask="url(#a)"><path fill="#037DD6" stroke="#fff" stroke-width="2" d="M36.047 12.619H73.39v39.25H36.047z"/></g></svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

@ -0,0 +1,3 @@
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.66555 3.36981C8.38934 3.36981 8.19599 3.59078 8.18218 3.85319L8.26504 7.11251L4.30138 3.14884C4.10803 2.95549 3.83181 2.95549 3.63846 3.14884L3.19652 3.59078C3.01698 3.77032 3.00317 4.06035 3.19652 4.2537L7.16019 8.21736L3.92849 8.1345C3.65227 8.1345 3.44511 8.34166 3.4313 8.60406V9.21173C3.44511 9.47413 3.65227 9.68129 3.90087 9.68129H9.28703C9.53562 9.68129 9.74278 9.47413 9.74278 9.22554V3.83938C9.74278 3.59078 9.53562 3.38362 9.27322 3.36981H8.66555Z" fill="#D73A49"/>
</svg>

After

Width:  |  Height:  |  Size: 592 B

@ -0,0 +1,3 @@
<svg width="10" height="9" viewBox="0 0 10 9" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.12476 0.974243C3.92944 1.16956 3.94897 1.46252 4.12476 1.65784L6.48804 3.90393L0.882568 3.90393C0.609131 3.90393 0.413818 4.09924 0.413818 4.37268L0.413818 4.99768C0.413818 5.25159 0.609131 5.46643 0.882568 5.46643L6.48804 5.46643L4.14429 7.69299C3.94897 7.88831 3.94897 8.18127 4.12476 8.37659L4.55444 8.80627C4.74976 8.98206 5.04272 8.98206 5.21851 8.80627L9.0271 4.99768C9.20288 4.8219 9.20288 4.52893 9.0271 4.35315L5.21851 0.544555C5.04272 0.368774 4.74976 0.368774 4.55444 0.544555L4.12476 0.974243Z" fill="#BBC0C5"/>
</svg>

After

Width:  |  Height:  |  Size: 637 B

@ -1 +0,0 @@
<svg width="13" height="13" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M9.296 8.42c0-.276-.22-.47-.483-.483l-3.26.083 3.964-3.964a.451.451 0 0 0 0-.663l-.442-.442a.463.463 0 0 0-.662 0L4.449 6.915l.083-3.232a.49.49 0 0 0-.47-.497h-.607a.484.484 0 0 0-.47.47v5.386a.46.46 0 0 0 .456.456h5.386a.484.484 0 0 0 .47-.47V8.42Z" fill="#D73A49"/></svg>

Before

Width:  |  Height:  |  Size: 358 B

Before

Width:  |  Height:  |  Size: 347 B

After

Width:  |  Height:  |  Size: 347 B

@ -11,6 +11,7 @@ import { storeAsStream, storeTransformStream } from '@metamask/obs-store';
import PortStream from 'extension-port-stream'; import PortStream from 'extension-port-stream';
import { captureException } from '@sentry/browser'; import { captureException } from '@sentry/browser';
import { ethErrors } from 'eth-rpc-errors';
import { import {
ENVIRONMENT_TYPE_POPUP, ENVIRONMENT_TYPE_POPUP,
ENVIRONMENT_TYPE_NOTIFICATION, ENVIRONMENT_TYPE_NOTIFICATION,
@ -56,7 +57,7 @@ const openMetamaskTabsIDs = {};
const requestAccountTabIds = {}; const requestAccountTabIds = {};
// state persistence // state persistence
const inTest = process.env.IN_TEST === 'true'; const inTest = process.env.IN_TEST;
const localStore = inTest ? new ReadOnlyNetworkStore() : new LocalStore(); const localStore = inTest ? new ReadOnlyNetworkStore() : new LocalStore();
let versionedData; let versionedData;
@ -73,6 +74,7 @@ initialize().catch(log.error);
/** /**
* The data emitted from the MetaMaskController.store EventEmitter, also used to initialize the MetaMaskController. Available in UI on React state as state.metamask. * The data emitted from the MetaMaskController.store EventEmitter, also used to initialize the MetaMaskController. Available in UI on React state as state.metamask.
*
* @typedef MetaMaskState * @typedef MetaMaskState
* @property {boolean} isInitialized - Whether the first vault has been created. * @property {boolean} isInitialized - Whether the first vault has been created.
* @property {boolean} isUnlocked - Whether the vault is currently decrypted and accounts are available for selection. * @property {boolean} isUnlocked - Whether the vault is currently decrypted and accounts are available for selection.
@ -118,11 +120,12 @@ initialize().catch(log.error);
/** /**
* @typedef VersionedData * @typedef VersionedData
* @property {MetaMaskState} data - The data emitted from MetaMask controller, or used to initialize it. * @property {MetaMaskState} data - The data emitted from MetaMask controller, or used to initialize it.
* @property {Number} version - The latest migration version that has been run. * @property {number} version - The latest migration version that has been run.
*/ */
/** /**
* Initializes the MetaMask controller, and sets up all platform configuration. * Initializes the MetaMask controller, and sets up all platform configuration.
*
* @returns {Promise} Setup complete. * @returns {Promise} Setup complete.
*/ */
async function initialize() { async function initialize() {
@ -139,6 +142,7 @@ async function initialize() {
/** /**
* Loads any stored data, prioritizing the latest storage strategy. * Loads any stored data, prioritizing the latest storage strategy.
* Migrates that data schema in case it was last loaded on an older version. * Migrates that data schema in case it was last loaded on an older version.
*
* @returns {Promise<MetaMaskState>} Last data emitted from previous instance of MetaMask. * @returns {Promise<MetaMaskState>} Last data emitted from previous instance of MetaMask.
*/ */
async function loadStateFromPersistence() { async function loadStateFromPersistence() {
@ -217,6 +221,7 @@ function setupController(initState, initLangCode) {
initLangCode, initLangCode,
// platform specific api // platform specific api
platform, platform,
notificationManager,
extension, extension,
getRequestAccountTabIds: () => { getRequestAccountTabIds: () => {
return requestAccountTabIds; return requestAccountTabIds;
@ -249,6 +254,7 @@ function setupController(initState, initLangCode) {
/** /**
* Assigns the given state to the versioned object (with metadata), and returns that. * Assigns the given state to the versioned object (with metadata), and returns that.
*
* @param {Object} state - The state object as emitted by the MetaMaskController. * @param {Object} state - The state object as emitted by the MetaMaskController.
* @returns {VersionedData} The state object wrapped in an object that includes a metadata key. * @returns {VersionedData} The state object wrapped in an object that includes a metadata key.
*/ */
@ -325,6 +331,7 @@ function setupController(initState, initLangCode) {
/** /**
* A runtime.Port object, as provided by the browser: * A runtime.Port object, as provided by the browser:
*
* @see https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/runtime/Port * @see https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/runtime/Port
* @typedef Port * @typedef Port
* @type Object * @type Object
@ -333,6 +340,7 @@ function setupController(initState, initLangCode) {
/** /**
* Connects a Port to the MetaMask controller via a multiplexed duplex stream. * Connects a Port to the MetaMask controller via a multiplexed duplex stream.
* This method identifies trusted (MetaMask) interfaces, and connects them differently from untrusted (web pages). * This method identifies trusted (MetaMask) interfaces, and connects them differently from untrusted (web pages).
*
* @param {Port} remotePort - The port provided by a new context. * @param {Port} remotePort - The port provided by a new context.
*/ */
function connectRemote(remotePort) { function connectRemote(remotePort) {
@ -454,6 +462,15 @@ function setupController(initState, initLangCode) {
*/ */
function updateBadge() { function updateBadge() {
let label = ''; let label = '';
const count = getUnapprovedTransactionCount();
if (count) {
label = String(count);
}
extension.browserAction.setBadgeText({ text: label });
extension.browserAction.setBadgeBackgroundColor({ color: '#037DD6' });
}
function getUnapprovedTransactionCount() {
const unapprovedTxCount = controller.txController.getUnapprovedTxCount(); const unapprovedTxCount = controller.txController.getUnapprovedTxCount();
const { unapprovedMsgCount } = controller.messageManager; const { unapprovedMsgCount } = controller.messageManager;
const { unapprovedPersonalMsgCount } = controller.personalMessageManager; const { unapprovedPersonalMsgCount } = controller.personalMessageManager;
@ -465,7 +482,7 @@ function setupController(initState, initLangCode) {
const pendingApprovalCount = controller.approvalController.getTotalApprovalCount(); const pendingApprovalCount = controller.approvalController.getTotalApprovalCount();
const waitingForUnlockCount = const waitingForUnlockCount =
controller.appStateController.waitingForUnlock.length; controller.appStateController.waitingForUnlock.length;
const count = return (
unapprovedTxCount + unapprovedTxCount +
unapprovedMsgCount + unapprovedMsgCount +
unapprovedPersonalMsgCount + unapprovedPersonalMsgCount +
@ -473,17 +490,19 @@ function setupController(initState, initLangCode) {
unapprovedEncryptionPublicKeyMsgCount + unapprovedEncryptionPublicKeyMsgCount +
unapprovedTypedMessagesCount + unapprovedTypedMessagesCount +
pendingApprovalCount + pendingApprovalCount +
waitingForUnlockCount; waitingForUnlockCount
if (count) { );
label = String(count);
}
extension.browserAction.setBadgeText({ text: label });
extension.browserAction.setBadgeBackgroundColor({ color: '#037DD6' });
} }
notificationManager.on( notificationManager.on(
NOTIFICATION_MANAGER_EVENTS.POPUP_CLOSED, NOTIFICATION_MANAGER_EVENTS.POPUP_CLOSED,
rejectUnapprovedNotifications, ({ automaticallyClosed }) => {
if (!automaticallyClosed) {
rejectUnapprovedNotifications();
} else if (getUnapprovedTransactionCount() > 0) {
triggerUi();
}
},
); );
function rejectUnapprovedNotifications() { function rejectUnapprovedNotifications() {
@ -533,12 +552,9 @@ function setupController(initState, initLangCode) {
), ),
); );
// We're specifcally avoid using approvalController directly for better // Finally, reject all approvals managed by the ApprovalController
// Error support during rejection controller.approvalController.clear(
Object.keys( ethErrors.provider.userRejectedRequest(),
controller.permissionsController.approvals.state.pendingApprovals,
).forEach((approvalId) =>
controller.permissionsController.rejectPermissionsRequest(approvalId),
); );
updateBadge(); updateBadge();

@ -35,7 +35,6 @@ const defaultState = {
*/ */
export default class AlertController { export default class AlertController {
/** /**
* @constructor
* @param {AlertControllerOptions} [opts] - Controller configuration parameters * @param {AlertControllerOptions} [opts] - Controller configuration parameters
*/ */
constructor(opts = {}) { constructor(opts = {}) {
@ -73,6 +72,7 @@ export default class AlertController {
/** /**
* Sets the "switch to connected" alert as shown for the given origin * Sets the "switch to connected" alert as shown for the given origin
*
* @param {string} origin - The origin the alert has been shown for * @param {string} origin - The origin the alert has been shown for
*/ */
setUnconnectedAccountAlertShown(origin) { setUnconnectedAccountAlertShown(origin) {

@ -5,7 +5,6 @@ import { MINUTE } from '../../../shared/constants/time';
export default class AppStateController extends EventEmitter { export default class AppStateController extends EventEmitter {
/** /**
* @constructor
* @param {Object} opts * @param {Object} opts
*/ */
constructor(opts = {}) { constructor(opts = {}) {
@ -31,6 +30,7 @@ export default class AppStateController extends EventEmitter {
fullScreenGasPollTokens: [], fullScreenGasPollTokens: [],
recoveryPhraseReminderHasBeenShown: false, recoveryPhraseReminderHasBeenShown: false,
recoveryPhraseReminderLastShown: new Date().getTime(), recoveryPhraseReminderLastShown: new Date().getTime(),
collectiblesDetectionNoticeDismissed: false,
showTestnetMessageInDropdown: true, showTestnetMessageInDropdown: true,
trezorModel: null, trezorModel: null,
...initState, ...initState,
@ -109,6 +109,7 @@ export default class AppStateController extends EventEmitter {
/** /**
* Sets the default home tab * Sets the default home tab
*
* @param {string} [defaultHomeActiveTabName] - the tab name * @param {string} [defaultHomeActiveTabName] - the tab name
*/ */
setDefaultHomeActiveTabName(defaultHomeActiveTabName) { setDefaultHomeActiveTabName(defaultHomeActiveTabName) {
@ -127,8 +128,7 @@ export default class AppStateController extends EventEmitter {
} }
/** /**
* Record that the user has been shown the recovery phrase reminder * Record that the user has been shown the recovery phrase reminder.
* @returns {void}
*/ */
setRecoveryPhraseReminderHasBeenShown() { setRecoveryPhraseReminderHasBeenShown() {
this.store.updateState({ this.store.updateState({
@ -138,8 +138,8 @@ export default class AppStateController extends EventEmitter {
/** /**
* Record the timestamp of the last time the user has seen the recovery phrase reminder * Record the timestamp of the last time the user has seen the recovery phrase reminder
* @param {number} lastShown - timestamp when user was last shown the reminder *
* @returns {void} * @param {number} lastShown - timestamp when user was last shown the reminder.
*/ */
setRecoveryPhraseReminderLastShown(lastShown) { setRecoveryPhraseReminderLastShown(lastShown) {
this.store.updateState({ this.store.updateState({
@ -148,8 +148,7 @@ export default class AppStateController extends EventEmitter {
} }
/** /**
* Sets the last active time to the current time * Sets the last active time to the current time.
* @returns {void}
*/ */
setLastActiveTime() { setLastActiveTime() {
this._resetTimer(); this._resetTimer();
@ -157,9 +156,9 @@ export default class AppStateController extends EventEmitter {
/** /**
* Sets the inactive timeout for the app * Sets the inactive timeout for the app
* @param {number} timeoutMinutes - the inactive timeout in minutes *
* @returns {void}
* @private * @private
* @param {number} timeoutMinutes - The inactive timeout in minutes.
*/ */
_setInactiveTimeout(timeoutMinutes) { _setInactiveTimeout(timeoutMinutes) {
this.store.updateState({ this.store.updateState({
@ -175,7 +174,6 @@ export default class AppStateController extends EventEmitter {
* If the {@code timeoutMinutes} state is falsy (i.e., zero) then a new * If the {@code timeoutMinutes} state is falsy (i.e., zero) then a new
* timer will not be created. * timer will not be created.
* *
* @returns {void}
* @private * @private
*/ */
_resetTimer() { _resetTimer() {
@ -197,7 +195,9 @@ export default class AppStateController extends EventEmitter {
/** /**
* Sets the current browser and OS environment * Sets the current browser and OS environment
* @returns {void} *
* @param os
* @param browser
*/ */
setBrowserEnvironment(os, browser) { setBrowserEnvironment(os, browser) {
this.store.updateState({ browserEnvironment: { os, browser } }); this.store.updateState({ browserEnvironment: { os, browser } });
@ -205,7 +205,9 @@ export default class AppStateController extends EventEmitter {
/** /**
* Adds a pollingToken for a given environmentType * Adds a pollingToken for a given environmentType
* @returns {void} *
* @param pollingToken
* @param pollingTokenType
*/ */
addPollingToken(pollingToken, pollingTokenType) { addPollingToken(pollingToken, pollingTokenType) {
const prevState = this.store.getState()[pollingTokenType]; const prevState = this.store.getState()[pollingTokenType];
@ -216,7 +218,9 @@ export default class AppStateController extends EventEmitter {
/** /**
* removes a pollingToken for a given environmentType * removes a pollingToken for a given environmentType
* @returns {void} *
* @param pollingToken
* @param pollingTokenType
*/ */
removePollingToken(pollingToken, pollingTokenType) { removePollingToken(pollingToken, pollingTokenType) {
const prevState = this.store.getState()[pollingTokenType]; const prevState = this.store.getState()[pollingTokenType];
@ -227,7 +231,6 @@ export default class AppStateController extends EventEmitter {
/** /**
* clears all pollingTokens * clears all pollingTokens
* @returns {void}
*/ */
clearPollingTokens() { clearPollingTokens() {
this.store.updateState({ this.store.updateState({
@ -239,7 +242,8 @@ export default class AppStateController extends EventEmitter {
/** /**
* Sets whether the testnet dismissal link should be shown in the network dropdown * Sets whether the testnet dismissal link should be shown in the network dropdown
* @returns {void} *
* @param showTestnetMessageInDropdown
*/ */
setShowTestnetMessageInDropdown(showTestnetMessageInDropdown) { setShowTestnetMessageInDropdown(showTestnetMessageInDropdown) {
this.store.updateState({ showTestnetMessageInDropdown }); this.store.updateState({ showTestnetMessageInDropdown });
@ -247,9 +251,23 @@ export default class AppStateController extends EventEmitter {
/** /**
* Sets a property indicating the model of the user's Trezor hardware wallet * Sets a property indicating the model of the user's Trezor hardware wallet
* @returns {void} *
* @param trezorModel - The Trezor model.
*/ */
setTrezorModel(trezorModel) { setTrezorModel(trezorModel) {
this.store.updateState({ trezorModel }); this.store.updateState({ trezorModel });
} }
/**
* A setter for the `collectiblesDetectionNoticeDismissed` property
*
* @param collectiblesDetectionNoticeDismissed
*/
setCollectiblesDetectionNoticeDismissed(
collectiblesDetectionNoticeDismissed,
) {
this.store.updateState({
collectiblesDetectionNoticeDismissed,
});
}
} }

@ -34,6 +34,7 @@ export default class CachedBalancesController {
* if balances in the passed accounts are truthy. * if balances in the passed accounts are truthy.
* *
* @param {Object} obj - The the recently updated accounts object for the current chain * @param {Object} obj - The the recently updated accounts object for the current chain
* @param obj.accounts
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async updateCachedBalances({ accounts }) { async updateCachedBalances({ accounts }) {
@ -80,7 +81,6 @@ export default class CachedBalancesController {
* selections. * selections.
* *
* @private * @private
*
*/ */
_registerUpdates() { _registerUpdates() {
const update = this.updateCachedBalances.bind(this); const update = this.updateCachedBalances.bind(this);

@ -18,6 +18,12 @@ export default class DetectTokensController {
* Creates a DetectTokensController * Creates a DetectTokensController
* *
* @param {Object} [config] - Options to configure controller * @param {Object} [config] - Options to configure controller
* @param config.interval
* @param config.preferences
* @param config.network
* @param config.keyringMemStore
* @param config.tokenList
* @param config.tokensController
*/ */
constructor({ constructor({
interval = DEFAULT_INTERVAL, interval = DEFAULT_INTERVAL,
@ -152,7 +158,7 @@ export default class DetectTokensController {
/* eslint-disable accessor-pairs */ /* eslint-disable accessor-pairs */
/** /**
* @type {Number} * @type {number}
*/ */
set interval(interval) { set interval(interval) {
this._handle && clearInterval(this._handle); this._handle && clearInterval(this._handle);
@ -177,6 +183,7 @@ export default class DetectTokensController {
/** /**
* In setter when isUnlocked is updated to true, detectNewTokens and restart polling * In setter when isUnlocked is updated to true, detectNewTokens and restart polling
*
* @type {Object} * @type {Object}
*/ */
set keyringMemStore(keyringMemStore) { set keyringMemStore(keyringMemStore) {
@ -206,6 +213,7 @@ export default class DetectTokensController {
/** /**
* Internal isActive state * Internal isActive state
*
* @type {Object} * @type {Object}
*/ */
get isActive() { get isActive() {

@ -157,10 +157,10 @@ export default class IncomingTransactionsController {
* from, fetches the transactions and then saves them and the next block * from, fetches the transactions and then saves them and the next block
* number to begin fetching from in state. Block numbers and transactions are * number to begin fetching from in state. Block numbers and transactions are
* stored per chainId. * stored per chainId.
*
* @private * @private
* @param {string} address - address to lookup transactions for * @param {string} address - address to lookup transactions for
* @param {number} [newBlockNumberDec] - block number to begin fetching from * @param {number} [newBlockNumberDec] - block number to begin fetching from
* @returns {void}
*/ */
async _update(address, newBlockNumberDec) { async _update(address, newBlockNumberDec) {
const chainId = this.getCurrentChainId(); const chainId = this.getCurrentChainId();
@ -259,6 +259,7 @@ export default class IncomingTransactionsController {
/** /**
* Transmutes a EtherscanTransaction into a TransactionMeta * Transmutes a EtherscanTransaction into a TransactionMeta
*
* @param {EtherscanTransaction} etherscanTransaction - the transaction to normalize * @param {EtherscanTransaction} etherscanTransaction - the transaction to normalize
* @param {string} chainId - The chainId of the current network * @param {string} chainId - The chainId of the current network
* @returns {TransactionMeta} * @returns {TransactionMeta}
@ -307,13 +308,12 @@ export default class IncomingTransactionsController {
* is called with and invokes the comparator with both the cached, previous, * is called with and invokes the comparator with both the cached, previous,
* value and the current value. If specified, the initialValue will be passed * value and the current value. If specified, the initialValue will be passed
* in as the previous value on the first invocation of the returned method. * in as the previous value on the first invocation of the returned method.
* @template A *
* @params {A=} type of compared value * @template A - The type of the compared value.
* @param {(prevValue: A, nextValue: A) => void} comparator - method to compare * @param {(prevValue: A, nextValue: A) => void} comparator - A method to compare
* previous and next values. * the previous and next values.
* @param {A} [initialValue] - initial value to supply to prevValue * @param {A} [initialValue] - The initial value to supply to prevValue
* on first call of the method. * on first call of the method.
* @returns {void}
*/ */
function previousValueComparator(comparator, initialValue) { function previousValueComparator(comparator, initialValue) {
let first = true; let first = true;

@ -103,11 +103,12 @@ function getMockBlockTracker() {
/** /**
* Returns a transaction object matching the expected format returned * Returns a transaction object matching the expected format returned
* by the Etherscan API * by the Etherscan API
*
* @param {Object} [params] - options bag * @param {Object} [params] - options bag
* @param {string} [params.toAddress] - The hex-prefixed address of the recipient * @param {string} [params.toAddress] - The hex-prefixed address of the recipient
* @param {number} [params.blockNumber] - The block number for the transaction * @param {number} [params.blockNumber] - The block number for the transaction
* @param {boolean} [params.useEIP1559] - Use EIP-1559 gas fields * @param {boolean} [params.useEIP1559] - Use EIP-1559 gas fields
* @param * @param params.hash
* @returns {EtherscanTransaction} * @returns {EtherscanTransaction}
*/ */
const getFakeEtherscanTransaction = ({ const getFakeEtherscanTransaction = ({

@ -40,19 +40,21 @@ const exceptionsToFilter = {
export default class MetaMetricsController { export default class MetaMetricsController {
/** /**
* @param {Object} segment - an instance of analytics-node for tracking * @param {object} options
* @param {Object} options.segment - an instance of analytics-node for tracking
* events that conform to the new MetaMetrics tracking plan. * events that conform to the new MetaMetrics tracking plan.
* @param {Object} preferencesStore - The preferences controller store, used * @param {Object} options.preferencesStore - The preferences controller store, used
* to access and subscribe to preferences that will be attached to events * to access and subscribe to preferences that will be attached to events
* @param {function} onNetworkDidChange - Used to attach a listener to the * @param {Function} options.onNetworkDidChange - Used to attach a listener to the
* networkDidChange event emitted by the networkController * networkDidChange event emitted by the networkController
* @param {function} getCurrentChainId - Gets the current chain id from the * @param {Function} options.getCurrentChainId - Gets the current chain id from the
* network controller * network controller
* @param {function} getNetworkIdentifier - Gets the current network * @param {Function} options.getNetworkIdentifier - Gets the current network
* identifier from the network controller * identifier from the network controller
* @param {string} version - The version of the extension * @param {string} options.version - The version of the extension
* @param {string} environment - The environment the extension is running in * @param {string} options.environment - The environment the extension is running in
* @param {MetaMetricsControllerState} initState - State to initialized with * @param {MetaMetricsControllerState} options.initState - State to initialized with
* @param options.captureException
*/ */
constructor({ constructor({
segment, segment,
@ -132,6 +134,7 @@ export default class MetaMetricsController {
/** /**
* Build the context object to attach to page and track events. * Build the context object to attach to page and track events.
*
* @private * @private
* @param {Pick<MetaMetricsContext, 'referrer'>} [referrer] - dapp origin that initialized * @param {Pick<MetaMetricsContext, 'referrer'>} [referrer] - dapp origin that initialized
* the notification window. * the notification window.
@ -154,11 +157,12 @@ export default class MetaMetricsController {
/** /**
* Build's the event payload, processing all fields into a format that can be * Build's the event payload, processing all fields into a format that can be
* fed to Segment's track method * fed to Segment's track method
*
* @private * @private
* @param { * @param {
* Omit<MetaMetricsEventPayload, 'sensitiveProperties'> * Omit<MetaMetricsEventPayload, 'sensitiveProperties'>
* } rawPayload - raw payload provided to trackEvent * } rawPayload - raw payload provided to trackEvent
* @returns {SegmentEventPayload} - formatted event payload for segment * @returns {SegmentEventPayload} formatted event payload for segment
*/ */
_buildEventPayload(rawPayload) { _buildEventPayload(rawPayload) {
const { const {
@ -199,6 +203,7 @@ export default class MetaMetricsController {
* Perform validation on the payload and update the id type to use before * Perform validation on the payload and update the id type to use before
* sending to Segment. Also examines the options to route and handle the * sending to Segment. Also examines the options to route and handle the
* event appropriately. * event appropriately.
*
* @private * @private
* @param {SegmentEventPayload} payload - properties to attach to event * @param {SegmentEventPayload} payload - properties to attach to event
* @param {MetaMetricsEventOptions} [options] - options for routing and * @param {MetaMetricsEventOptions} [options] - options for routing and
@ -273,6 +278,7 @@ export default class MetaMetricsController {
/** /**
* track a page view with Segment * track a page view with Segment
*
* @param {MetaMetricsPagePayload} payload - details of the page viewed * @param {MetaMetricsPagePayload} payload - details of the page viewed
* @param {MetaMetricsPageOptions} [options] - options for handling the page * @param {MetaMetricsPageOptions} [options] - options for handling the page
* view * view
@ -311,6 +317,7 @@ export default class MetaMetricsController {
/** /**
* submits a metametrics event, not waiting for it to complete or allowing its error to bubble up * submits a metametrics event, not waiting for it to complete or allowing its error to bubble up
*
* @param {MetaMetricsEventPayload} payload - details of the event * @param {MetaMetricsEventPayload} payload - details of the event
* @param {MetaMetricsEventOptions} [options] - options for handling/routing the event * @param {MetaMetricsEventOptions} [options] - options for handling/routing the event
*/ */
@ -327,6 +334,7 @@ export default class MetaMetricsController {
* routing the event to the appropriate segment source. Will split events * routing the event to the appropriate segment source. Will split events
* with sensitiveProperties into two events, tracking the sensitiveProperties * with sensitiveProperties into two events, tracking the sensitiveProperties
* with the anonymousId only. * with the anonymousId only.
*
* @param {MetaMetricsEventPayload} payload - details of the event * @param {MetaMetricsEventPayload} payload - details of the event
* @param {MetaMetricsEventOptions} [options] - options for handling/routing the event * @param {MetaMetricsEventOptions} [options] - options for handling/routing the event
* @returns {Promise<void>} * @returns {Promise<void>}
@ -375,6 +383,7 @@ export default class MetaMetricsController {
/** /**
* validates a metametrics event * validates a metametrics event
*
* @param {MetaMetricsEventPayload} payload - details of the event * @param {MetaMetricsEventPayload} payload - details of the event
*/ */
validatePayload(payload) { validatePayload(payload) {

@ -10,7 +10,7 @@ import {
import { PollingBlockTracker } from 'eth-block-tracker'; import { PollingBlockTracker } from 'eth-block-tracker';
import { SECOND } from '../../../../shared/constants/time'; 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 blockTrackerOpts = inTest ? { pollingInterval: SECOND } : {};
const getTestMiddlewares = () => { const getTestMiddlewares = () => {
return inTest ? [createEstimateGasDelayTestMiddleware()] : []; return inTest ? [createEstimateGasDelayTestMiddleware()] : [];

@ -1,10 +1,9 @@
import { strict as assert } from 'assert';
import sinon from 'sinon'; import sinon from 'sinon';
import { getNetworkDisplayName } from './util'; import { getNetworkDisplayName } from './util';
import NetworkController, { NETWORK_EVENTS } from './network'; import NetworkController, { NETWORK_EVENTS } from './network';
describe('NetworkController', function () { describe('NetworkController', () => {
describe('controller', function () { describe('controller', () => {
let networkController; let networkController;
let getLatestBlockStub; let getLatestBlockStub;
let setProviderTypeAndWait; let setProviderTypeAndWait;
@ -13,7 +12,7 @@ describe('NetworkController', function () {
getAccounts: noop, getAccounts: noop,
}; };
beforeEach(function () { beforeEach(() => {
networkController = new NetworkController(); networkController = new NetworkController();
getLatestBlockStub = sinon getLatestBlockStub = sinon
.stub(networkController, 'getLatestBlock') .stub(networkController, 'getLatestBlock')
@ -28,118 +27,108 @@ describe('NetworkController', function () {
}); });
}); });
afterEach(function () { afterEach(() => {
getLatestBlockStub.reset(); getLatestBlockStub.reset();
}); });
describe('#provider', function () { describe('#provider', () => {
it('provider should be updatable without reassignment', function () { it('provider should be updatable without reassignment', () => {
networkController.initializeProvider(networkControllerProviderConfig); networkController.initializeProvider(networkControllerProviderConfig);
const providerProxy = networkController.getProviderAndBlockTracker() const providerProxy = networkController.getProviderAndBlockTracker()
.provider; .provider;
assert.equal(providerProxy.test, undefined); expect(providerProxy.test).toBeUndefined();
providerProxy.setTarget({ test: true }); providerProxy.setTarget({ test: true });
assert.equal(providerProxy.test, true); expect(providerProxy.test).toStrictEqual(true);
}); });
}); });
describe('#getNetworkState', function () { describe('#getNetworkState', () => {
it('should return "loading" when new', function () { it('should return "loading" when new', () => {
const networkState = networkController.getNetworkState(); const networkState = networkController.getNetworkState();
assert.equal(networkState, 'loading', 'network is loading'); expect(networkState).toStrictEqual('loading');
}); });
}); });
describe('#setNetworkState', function () { describe('#setNetworkState', () => {
it('should update the network', function () { it('should update the network', () => {
networkController.setNetworkState('1'); networkController.setNetworkState('1');
const networkState = networkController.getNetworkState(); const networkState = networkController.getNetworkState();
assert.equal(networkState, '1', 'network is 1'); expect(networkState).toStrictEqual('1');
}); });
}); });
describe('#setProviderType', function () { describe('#setProviderType', () => {
it('should update provider.type', function () { it('should update provider.type', () => {
networkController.initializeProvider(networkControllerProviderConfig); networkController.initializeProvider(networkControllerProviderConfig);
networkController.setProviderType('mainnet'); networkController.setProviderType('mainnet');
const { type } = networkController.getProviderConfig(); 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); networkController.initializeProvider(networkControllerProviderConfig);
const spy = sinon.spy(networkController, 'setNetworkState'); const spy = sinon.spy(networkController, 'setNetworkState');
networkController.setProviderType('mainnet'); networkController.setProviderType('mainnet');
assert.equal( expect(spy.callCount).toStrictEqual(1);
spy.callCount, expect(spy.calledOnceWithExactly('loading')).toStrictEqual(true);
1,
'should have called setNetworkState 2 times',
);
assert.ok(
spy.calledOnceWithExactly('loading'),
'should have called with "loading" first',
);
}); });
}); });
describe('#getEIP1559Compatibility', function () { describe('#getEIP1559Compatibility', () => {
it('should return false when baseFeePerGas is not in the block header', async function () { it('should return false when baseFeePerGas is not in the block header', async () => {
networkController.initializeProvider(networkControllerProviderConfig); networkController.initializeProvider(networkControllerProviderConfig);
const supportsEIP1559 = await networkController.getEIP1559Compatibility(); 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); networkController.initializeProvider(networkControllerProviderConfig);
getLatestBlockStub.callsFake(() => getLatestBlockStub.callsFake(() =>
Promise.resolve({ baseFeePerGas: '0xa ' }), Promise.resolve({ baseFeePerGas: '0xa ' }),
); );
const supportsEIP1559 = await networkController.getEIP1559Compatibility(); 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); networkController.initializeProvider(networkControllerProviderConfig);
getLatestBlockStub.callsFake(() => getLatestBlockStub.callsFake(() =>
Promise.resolve({ baseFeePerGas: '0xa ' }), Promise.resolve({ baseFeePerGas: '0xa ' }),
); );
await networkController.getEIP1559Compatibility(); await networkController.getEIP1559Compatibility();
const supportsEIP1559 = await networkController.getEIP1559Compatibility(); const supportsEIP1559 = await networkController.getEIP1559Compatibility();
assert.equal(getLatestBlockStub.calledOnce, true); expect(getLatestBlockStub.calledOnce).toStrictEqual(true);
assert.equal(supportsEIP1559, 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.initializeProvider(networkControllerProviderConfig);
networkController.consoleThis = true; networkController.consoleThis = true;
getLatestBlockStub.callsFake(() => getLatestBlockStub.callsFake(() =>
Promise.resolve({ baseFeePerGas: '0xa ' }), Promise.resolve({ baseFeePerGas: '0xa ' }),
); );
await networkController.getEIP1559Compatibility(); await networkController.getEIP1559Compatibility();
assert.equal( expect(
networkController.networkDetails.getState().EIPS[1559], networkController.networkDetails.getState().EIPS[1559],
true, ).toStrictEqual(true);
);
getLatestBlockStub.callsFake(() => Promise.resolve({})); getLatestBlockStub.callsFake(() => Promise.resolve({}));
await setProviderTypeAndWait('mainnet'); await setProviderTypeAndWait('mainnet');
assert.equal( expect(
networkController.networkDetails.getState().EIPS[1559], networkController.networkDetails.getState().EIPS[1559],
undefined, ).toBeUndefined();
);
await networkController.getEIP1559Compatibility(); await networkController.getEIP1559Compatibility();
assert.equal( expect(
networkController.networkDetails.getState().EIPS[1559], networkController.networkDetails.getState().EIPS[1559],
false, ).toStrictEqual(false);
); expect(getLatestBlockStub.calledTwice).toStrictEqual(true);
assert.equal(getLatestBlockStub.calledTwice, true);
}); });
}); });
}); });
describe('utils', function () { describe('utils', () => {
it('getNetworkDisplayName should return the correct network name', function () { it('getNetworkDisplayName should return the correct network name', () => {
const tests = [ const tests = [
{ {
input: '3', input: '3',
@ -188,7 +177,7 @@ describe('NetworkController', function () {
]; ];
tests.forEach(({ input, expected }) => tests.forEach(({ input, expected }) =>
assert.equal(getNetworkDisplayName(input), expected), expect(getNetworkDisplayName(input)).toStrictEqual(expected),
); );
}); });
}); });

@ -33,7 +33,7 @@ const env = process.env.METAMASK_ENV;
const fetchWithTimeout = getFetchWithTimeout(SECOND * 30); const fetchWithTimeout = getFetchWithTimeout(SECOND * 30);
let defaultProviderConfigOpts; let defaultProviderConfigOpts;
if (process.env.IN_TEST === 'true') { if (process.env.IN_TEST) {
defaultProviderConfigOpts = { defaultProviderConfigOpts = {
type: NETWORK_TYPE_RPC, type: NETWORK_TYPE_RPC,
rpcUrl: 'http://localhost:8545', rpcUrl: 'http://localhost:8545',
@ -110,8 +110,7 @@ export default class NetworkController extends EventEmitter {
* Sets the Infura project ID * Sets the Infura project ID
* *
* @param {string} projectId - The Infura project ID * @param {string} projectId - The Infura project ID
* @throws {Error} if the project ID is not a valid string * @throws {Error} If the project ID is not a valid string.
* @return {void}
*/ */
setInfuraProjectId(projectId) { setInfuraProjectId(projectId) {
if (!projectId || typeof projectId !== 'string') { if (!projectId || typeof projectId !== 'string') {
@ -137,6 +136,7 @@ export default class NetworkController extends EventEmitter {
/** /**
* Method to return the latest block for the current network * Method to return the latest block for the current network
*
* @returns {Object} Block header * @returns {Object} Block header
*/ */
getLatestBlock() { getLatestBlock() {
@ -158,6 +158,7 @@ export default class NetworkController extends EventEmitter {
/** /**
* Method to check if the block header contains fields that indicate EIP 1559 * Method to check if the block header contains fields that indicate EIP 1559
* support (baseFeePerGas). * support (baseFeePerGas).
*
* @returns {Promise<boolean>} true if current network supports EIP 1559 * @returns {Promise<boolean>} true if current network supports EIP 1559
*/ */
async getEIP1559Compatibility() { async getEIP1559Compatibility() {
@ -189,6 +190,7 @@ export default class NetworkController extends EventEmitter {
/** /**
* Set EIP support indication in the networkDetails store * Set EIP support indication in the networkDetails store
*
* @param {number} EIPNumber - The number of the EIP to mark support for * @param {number} EIPNumber - The number of the EIP to mark support for
* @param {boolean} isSupported - True if the EIP is supported * @param {boolean} isSupported - True if the EIP is supported
*/ */
@ -310,6 +312,8 @@ export default class NetworkController extends EventEmitter {
/** /**
* Sets the provider config and switches the network. * Sets the provider config and switches the network.
*
* @param config
*/ */
setProviderConfig(config) { setProviderConfig(config) {
this.previousProviderStore.updateState(this.getProviderConfig()); this.previousProviderStore.updateState(this.getProviderConfig());

@ -1,4 +1,3 @@
import { strict as assert } from 'assert';
import { GAS_LIMITS } from '../../../../shared/constants/gas'; import { GAS_LIMITS } from '../../../../shared/constants/gas';
import { TRANSACTION_ENVELOPE_TYPES } from '../../../../shared/constants/transaction'; import { TRANSACTION_ENVELOPE_TYPES } from '../../../../shared/constants/transaction';
import { txMetaStub } from '../../../../test/stub/tx-meta-stub'; import { txMetaStub } from '../../../../test/stub/tx-meta-stub';
@ -7,25 +6,35 @@ import {
createPendingTxMiddleware, createPendingTxMiddleware,
} from './middleware/pending'; } from './middleware/pending';
describe('PendingNonceMiddleware', function () { describe('PendingNonceMiddleware', () => {
describe('#createPendingNonceMiddleware', function () { describe('#createPendingNonceMiddleware', () => {
const getPendingNonce = async () => '0x2'; const getPendingNonce = async () => '0x2';
const address = '0xF231D46dD78806E1DD93442cf33C7671f8538748'; const address = '0xF231D46dD78806E1DD93442cf33C7671f8538748';
const pendingNonceMiddleware = createPendingNonceMiddleware({ const pendingNonceMiddleware = createPendingNonceMiddleware({
getPendingNonce, 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 req = { method: 'eth_getBlockByNumber' };
const res = {}; 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 req = { method: 'eth_getTransactionCount', params: [address] };
const res = {}; 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 = { const req = {
method: 'eth_getTransactionCount', method: 'eth_getTransactionCount',
params: [address, 'pending'], params: [address, 'pending'],
@ -35,17 +44,16 @@ describe('PendingNonceMiddleware', function () {
req, req,
res, res,
() => { () => {
done(new Error('should not have called next')); return new Error('should not have called next');
}, },
() => { () => {
assert(res.result === '0x2'); expect(res.result).toStrictEqual('0x2');
done();
}, },
); );
}); });
}); });
describe('#createPendingTxMiddleware', function () { describe('#createPendingTxMiddleware', () => {
let returnUndefined = true; let returnUndefined = true;
const getPendingTransactionByHash = () => const getPendingTransactionByHash = () =>
returnUndefined ? undefined : txMetaStub; returnUndefined ? undefined : txMetaStub;
@ -72,19 +80,24 @@ describe('PendingNonceMiddleware', function () {
r: '0x5f973e540f2d3c2f06d3725a626b75247593cb36477187ae07ecfe0a4db3cf57', r: '0x5f973e540f2d3c2f06d3725a626b75247593cb36477187ae07ecfe0a4db3cf57',
s: '0x0259b52ee8c58baaa385fb05c3f96116e58de89bcc165cb3bfdfc708672fed8a', 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 req = { method: 'eth_getBlockByNumber' };
const res = {}; 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 req = { method: 'eth_getTransactionByHash', params: [address] };
const res = {}; 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; returnUndefined = false;
const req = { const req = {
method: 'eth_getTransactionByHash', method: 'eth_getTransactionByHash',
@ -95,15 +108,10 @@ describe('PendingNonceMiddleware', function () {
req, req,
res, res,
() => { () => {
done(new Error('should not have called next')); return new Error('should not have called next');
}, },
() => { () => {
assert.deepStrictEqual( expect(res.result).toStrictEqual(spec);
res.result,
spec,
new Error('result does not match the spec object'),
);
done();
}, },
); );
}); });

@ -1,4 +1,3 @@
import { strict as assert } from 'assert';
import { import {
TRANSACTION_STATUSES, TRANSACTION_STATUSES,
TRANSACTION_TYPES, TRANSACTION_TYPES,
@ -7,9 +6,9 @@ import {
import { formatTxMetaForRpcResult } from './util'; import { formatTxMetaForRpcResult } from './util';
describe('network utils', function () { describe('network utils', () => {
describe('formatTxMetaForRpcResult', function () { describe('formatTxMetaForRpcResult', () => {
it('should correctly format the tx meta object (EIP-1559)', function () { it('should correctly format the tx meta object (EIP-1559)', () => {
const txMeta = { const txMeta = {
id: 1, id: 1,
status: TRANSACTION_STATUSES.UNAPPROVED, status: TRANSACTION_STATUSES.UNAPPROVED,
@ -54,10 +53,10 @@ describe('network utils', function () {
value: '0x0', value: '0x0',
}; };
const result = formatTxMetaForRpcResult(txMeta); 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 = { const txMeta = {
id: 1, id: 1,
status: TRANSACTION_STATUSES.UNAPPROVED, status: TRANSACTION_STATUSES.UNAPPROVED,
@ -99,7 +98,7 @@ describe('network utils', function () {
value: '0x0', value: '0x0',
}; };
const result = formatTxMetaForRpcResult(txMeta); const result = formatTxMetaForRpcResult(txMeta);
assert.deepEqual(result, expectedResult); expect(result).toStrictEqual(expectedResult);
}); });
}); });
}); });

@ -3,8 +3,8 @@ import log from 'loglevel';
/** /**
* @typedef {Object} InitState * @typedef {Object} InitState
* @property {Boolean} seedPhraseBackedUp Indicates whether the user has completed the seed phrase backup challenge * @property {boolean} seedPhraseBackedUp Indicates whether the user has completed the seed phrase backup challenge
* @property {Boolean} completedOnboarding Indicates whether the user has completed the onboarding flow * @property {boolean} completedOnboarding Indicates whether the user has completed the onboarding flow
*/ */
/** /**
@ -20,7 +20,7 @@ export default class OnboardingController {
/** /**
* Creates a new controller instance * Creates a new controller instance
* *
* @param {OnboardingOptions} [opts] Controller configuration parameters * @param {OnboardingOptions} [opts] - Controller configuration parameters
*/ */
constructor(opts = {}) { constructor(opts = {}) {
const initialTransientState = { const initialTransientState = {
@ -57,7 +57,6 @@ export default class OnboardingController {
* Setter for the `firstTimeFlowType` property * Setter for the `firstTimeFlowType` property
* *
* @param {string} type - Indicates the type of first time flow - create or import - the user wishes to follow * @param {string} type - Indicates the type of first time flow - create or import - the user wishes to follow
*
*/ */
setFirstTimeFlowType(type) { setFirstTimeFlowType(type) {
this.store.updateState({ firstTimeFlowType: type }); this.store.updateState({ firstTimeFlowType: type });

@ -0,0 +1,74 @@
import nanoid from 'nanoid';
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 = nanoid();
permissionController.requestPermissions(
{ origin },
{
eth_accounts: {},
},
{ id },
);
return id;
},
};
}

@ -0,0 +1,187 @@
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 (_, __, { id }) => {
return [null, { id }];
}),
};
const id = await getPermissionBackgroundApiMethods(
permissionController,
).requestAccountsPermissionWithId('foo.com');
expect(permissionController.requestPermissions).toHaveBeenCalledTimes(1);
expect(permissionController.requestPermissions).toHaveBeenCalledWith(
{ origin: 'foo.com' },
{ eth_accounts: {} },
{ id: expect.any(String) },
);
expect(id.length > 0).toBe(true);
expect(id).toStrictEqual(
permissionController.requestPermissions.mock.calls[0][2].id,
);
});
});
});

@ -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 };
}

@ -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,
});
});
});
});
});

@ -1,20 +1,5 @@
export const APPROVAL_TYPE = 'wallet_requestPermissions';
export const WALLET_PREFIX = 'wallet_'; 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 = { export const NOTIFICATION_NAMES = {
accountsChanged: 'metamask_accountsChanged', accountsChanged: 'metamask_accountsChanged',
unlockStateChanged: 'metamask_unlockStateChanged', unlockStateChanged: 'metamask_unlockStateChanged',
@ -31,64 +16,7 @@ export const LOG_METHOD_TYPES = {
internal: 'internal', internal: 'internal',
}; };
/**
* The permission activity log size limit.
*/
export const LOG_LIMIT = 100; 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',
];

@ -1,718 +1,6 @@
import nanoid from 'nanoid'; export * from './caveat-mutators';
import { JsonRpcEngine } from 'json-rpc-engine'; export * from './background-api';
import { ObservableStore } from '@metamask/obs-store'; export * from './enums';
import log from 'loglevel'; export * from './permission-log';
import { CapabilitiesController as RpcCap } from 'rpc-cap'; export * from './specifications';
import { ethErrors } from 'eth-rpc-errors'; export * from './selectors';
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<string>} 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<IOcapLdCapability[]>} 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<string>} 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<string>} 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,
);
}
}

@ -1,11 +1,10 @@
import { ObservableStore } from '@metamask/obs-store';
import stringify from 'fast-safe-stringify'; import stringify from 'fast-safe-stringify';
import { CAVEAT_NAMES } from '../../../../shared/constants/permissions'; import { CaveatTypes } from '../../../../shared/constants/permissions';
import { import {
HISTORY_STORE_KEY,
LOG_IGNORE_METHODS, LOG_IGNORE_METHODS,
LOG_LIMIT, LOG_LIMIT,
LOG_METHOD_TYPES, LOG_METHOD_TYPES,
LOG_STORE_KEY,
WALLET_PREFIX, WALLET_PREFIX,
} from './enums'; } from './enums';
@ -13,51 +12,59 @@ import {
* Controller with middleware for logging requests and responses to restricted * Controller with middleware for logging requests and responses to restricted
* and permissions-related methods. * and permissions-related methods.
*/ */
export default class PermissionsLogController { export class PermissionLogController {
constructor({ restrictedMethods, store }) { /**
* @param {{ restrictedMethods: Set<string>, initState: Record<string, unknown> }} options - Options bag.
*/
constructor({ restrictedMethods, initState }) {
this.restrictedMethods = restrictedMethods; 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<Object>} The activity log. * @returns {Array<Object>} The activity log.
*/ */
getActivityLog() { 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<Object>} logs - The new activity log array. * @param {Array<Object>} logs - The new activity log array.
*/ */
updateActivityLog(logs) { 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. * @returns {Object} The permissions history log.
*/ */
getHistory() { 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. * @param {Object} history - The new permissions history log object.
*/ */
updateHistory(history) { updateHistory(history) {
this.store.updateState({ [HISTORY_STORE_KEY]: history }); this.store.updateState({ permissionHistory: history });
} }
/** /**
* Updates the exposed account history for the given origin. * Updates the exposed account history for the given origin.
* Sets the 'last seen' time to Date.now() for the given accounts. * 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. * Returns if the accounts array is empty.
* *
* @param {string} origin - The origin that the accounts are exposed to. * @param {string} origin - The origin that the accounts are exposed to.
@ -96,7 +103,7 @@ export default class PermissionsLogController {
// we only log certain methods // we only log certain methods
if ( if (
!LOG_IGNORE_METHODS.includes(method) && !LOG_IGNORE_METHODS.includes(method) &&
(isInternal || this.restrictedMethods.includes(method)) (isInternal || this.restrictedMethods.has(method))
) { ) {
activityEntry = this.logRequest(req, isInternal); activityEntry = this.logRequest(req, isInternal);
@ -341,7 +348,7 @@ export default class PermissionsLogController {
const accounts = new Set(); const accounts = new Set();
for (const caveat of perm.caveats) { for (const caveat of perm.caveats) {
if ( if (
caveat.name === CAVEAT_NAMES.exposedAccounts && caveat.type === CaveatTypes.restrictReturnedAccounts &&
Array.isArray(caveat.value) Array.isArray(caveat.value)
) { ) {
for (const value of caveat.value) { for (const value of caveat.value) {

@ -1,23 +1,15 @@
import { strict as assert } from 'assert';
import { ObservableStore } from '@metamask/obs-store';
import nanoid from 'nanoid'; import nanoid from 'nanoid';
import { useFakeTimers } from 'sinon'; import { useFakeTimers } from 'sinon';
import stringify from 'fast-safe-stringify';
import { import { constants, getters, noop } from '../../../../test/mocks/permissions';
constants, import { PermissionLogController } from './permission-log';
getters,
noop,
} from '../../../../test/mocks/permission-controller';
import { validateActivityEntry } from '../../../../test/helpers/permission-controller-helpers';
import PermissionsLogController from './permissionsLog';
import { LOG_LIMIT, LOG_METHOD_TYPES } from './enums'; import { LOG_LIMIT, LOG_METHOD_TYPES } from './enums';
const { PERMS, RPC_REQUESTS } = getters; const { PERMS, RPC_REQUESTS } = getters;
const { const {
ACCOUNTS, ACCOUNTS,
EXPECTED_HISTORIES, EXPECTED_HISTORIES,
DOMAINS, SUBJECTS,
PERM_NAMES, PERM_NAMES,
REQUEST_IDS, REQUEST_IDS,
RESTRICTED_METHODS, RESTRICTED_METHODS,
@ -25,10 +17,10 @@ const {
let clock; let clock;
const initPermLog = () => { const initPermLog = (initState = {}) => {
return new PermissionsLogController({ return new PermissionLogController({
store: new ObservableStore(),
restrictedMethods: RESTRICTED_METHODS, restrictedMethods: RESTRICTED_METHODS,
initState,
}); });
}; };
@ -59,21 +51,21 @@ const getSavedMockNext = (arr) => (handler) => {
arr.push(handler); arr.push(handler);
}; };
describe('permissions log', function () { describe('PermissionLogController', () => {
describe('activity log', function () { describe('restricted method activity log', () => {
let permLog, logMiddleware; let permLog, logMiddleware;
beforeEach(function () { beforeEach(() => {
permLog = initPermLog(); permLog = initPermLog();
logMiddleware = initMiddleware(permLog); logMiddleware = initMiddleware(permLog);
}); });
it('records activity for restricted methods', function () { it('records activity for restricted methods', () => {
let log, req, res; let log, req, res;
// test_method, success // test_method, success
req = RPC_REQUESTS.test_method(DOMAINS.a.origin); req = RPC_REQUESTS.test_method(SUBJECTS.a.origin);
req.id = REQUEST_IDS.a; req.id = REQUEST_IDS.a;
res = { foo: 'bar' }; res = { foo: 'bar' };
@ -82,7 +74,7 @@ describe('permissions log', function () {
log = permLog.getActivityLog(); log = permLog.getActivityLog();
const entry1 = log[0]; const entry1 = log[0];
assert.equal(log.length, 1, 'log should have single entry'); expect(log).toHaveLength(1);
validateActivityEntry( validateActivityEntry(
entry1, entry1,
{ ...req }, { ...req },
@ -93,7 +85,7 @@ describe('permissions log', function () {
// eth_accounts, failure // eth_accounts, failure
req = RPC_REQUESTS.eth_accounts(DOMAINS.b.origin); req = RPC_REQUESTS.eth_accounts(SUBJECTS.b.origin);
req.id = REQUEST_IDS.b; req.id = REQUEST_IDS.b;
res = { error: new Error('Unauthorized.') }; res = { error: new Error('Unauthorized.') };
@ -102,7 +94,7 @@ describe('permissions log', function () {
log = permLog.getActivityLog(); log = permLog.getActivityLog();
const entry2 = log[1]; const entry2 = log[1];
assert.equal(log.length, 2, 'log should have 2 entries'); expect(log).toHaveLength(2);
validateActivityEntry( validateActivityEntry(
entry2, entry2,
{ ...req }, { ...req },
@ -113,7 +105,7 @@ describe('permissions log', function () {
// eth_requestAccounts, success // eth_requestAccounts, success
req = RPC_REQUESTS.eth_requestAccounts(DOMAINS.c.origin); req = RPC_REQUESTS.eth_requestAccounts(SUBJECTS.c.origin);
req.id = REQUEST_IDS.c; req.id = REQUEST_IDS.c;
res = { result: ACCOUNTS.c.permitted }; res = { result: ACCOUNTS.c.permitted };
@ -122,7 +114,7 @@ describe('permissions log', function () {
log = permLog.getActivityLog(); log = permLog.getActivityLog();
const entry3 = log[2]; const entry3 = log[2];
assert.equal(log.length, 3, 'log should have 3 entries'); expect(log).toHaveLength(3);
validateActivityEntry( validateActivityEntry(
entry3, entry3,
{ ...req }, { ...req },
@ -133,7 +125,7 @@ describe('permissions log', function () {
// test_method, no response // 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; req.id = REQUEST_IDS.a;
res = null; res = null;
@ -142,7 +134,7 @@ describe('permissions log', function () {
log = permLog.getActivityLog(); log = permLog.getActivityLog();
const entry4 = log[3]; const entry4 = log[3];
assert.equal(log.length, 4, 'log should have 4 entries'); expect(log).toHaveLength(4);
validateActivityEntry( validateActivityEntry(
entry4, entry4,
{ ...req }, { ...req },
@ -152,14 +144,13 @@ describe('permissions log', function () {
); );
// validate final state // validate final state
expect(entry1).toStrictEqual(log[0]);
assert.equal(entry1, log[0], 'first log entry should remain'); expect(entry2).toStrictEqual(log[1]);
assert.equal(entry2, log[1], 'second log entry should remain'); expect(entry3).toStrictEqual(log[2]);
assert.equal(entry3, log[2], 'third log entry should remain'); expect(entry4).toStrictEqual(log[3]);
assert.equal(entry4, log[3], 'fourth log entry should remain');
}); });
it('handles responses added out of order', function () { it('handles responses added out of order', () => {
let log; let log;
const handlerArray = []; const handlerArray = [];
@ -168,7 +159,7 @@ describe('permissions log', function () {
const id2 = nanoid(); const id2 = nanoid();
const id3 = 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 // get make requests
req.id = id1; req.id = id1;
@ -185,19 +176,15 @@ describe('permissions log', function () {
// verify log state // verify log state
log = permLog.getActivityLog(); log = permLog.getActivityLog();
assert.equal(log.length, 3, 'log should have 3 entries'); expect(log).toHaveLength(3);
const entry1 = log[0]; const entry1 = log[0];
const entry2 = log[1]; const entry2 = log[1];
const entry3 = log[2]; const entry3 = log[2];
assert.ok(
entry1.id === id1 && // all entries should be in correct order, without responses
entry1.response === null && expect(entry1).toMatchObject({ id: id1, response: null });
entry2.id === id2 && expect(entry2).toMatchObject({ id: id2, response: null });
entry2.response === null && expect(entry3).toMatchObject({ id: id3, response: null });
entry3.id === id3 &&
entry3.response === null,
'all entries should be in correct order and without responses',
);
// call response handlers // call response handlers
for (const i of [1, 2, 0]) { for (const i of [1, 2, 0]) {
@ -206,7 +193,7 @@ describe('permissions log', function () {
// verify log state again // verify log state again
log = permLog.getActivityLog(); log = permLog.getActivityLog();
assert.equal(log.length, 3, 'log should have 3 entries'); expect(log).toHaveLength(3);
// verify all entries // verify all entries
log = permLog.getActivityLog(); log = permLog.getActivityLog();
@ -236,8 +223,8 @@ describe('permissions log', function () {
); );
}); });
it('handles a lack of response', function () { it('handles a lack of response', () => {
let req = RPC_REQUESTS.test_method(DOMAINS.a.origin); let req = RPC_REQUESTS.test_method(SUBJECTS.a.origin);
req.id = REQUEST_IDS.a; req.id = REQUEST_IDS.a;
let res = { foo: 'bar' }; let res = { foo: 'bar' };
@ -247,7 +234,7 @@ describe('permissions log', function () {
let log = permLog.getActivityLog(); let log = permLog.getActivityLog();
const entry1 = log[0]; const entry1 = log[0];
assert.equal(log.length, 1, 'log should have single entry'); expect(log).toHaveLength(1);
validateActivityEntry( validateActivityEntry(
entry1, entry1,
{ ...req }, { ...req },
@ -257,7 +244,7 @@ describe('permissions log', function () {
); );
// next request should be handled as normal // 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; req.id = REQUEST_IDS.b;
res = { result: ACCOUNTS.b.permitted }; res = { result: ACCOUNTS.b.permitted };
@ -265,7 +252,7 @@ describe('permissions log', function () {
log = permLog.getActivityLog(); log = permLog.getActivityLog();
const entry2 = log[1]; const entry2 = log[1];
assert.equal(log.length, 2, 'log should have 2 entries'); expect(log).toHaveLength(2);
validateActivityEntry( validateActivityEntry(
entry2, entry2,
{ ...req }, { ...req },
@ -275,32 +262,32 @@ describe('permissions log', function () {
); );
// validate final state // validate final state
assert.equal(entry1, log[0], 'first log entry remains'); expect(entry1).toStrictEqual(log[0]);
assert.equal(entry2, log[1], 'second log entry remains'); expect(entry2).toStrictEqual(log[1]);
}); });
it('ignores expected methods', function () { it('ignores expected methods', () => {
let log = permLog.getActivityLog(); let log = permLog.getActivityLog();
assert.equal(log.length, 0, 'log should be empty'); expect(log).toHaveLength(0);
const res = { foo: 'bar' }; const res = { foo: 'bar' };
const req1 = RPC_REQUESTS.metamask_sendDomainMetadata( const req1 = RPC_REQUESTS.metamask_sendDomainMetadata(
DOMAINS.c.origin, SUBJECTS.c.origin,
'foobar', 'foobar',
); );
const req2 = RPC_REQUESTS.custom(DOMAINS.b.origin, 'eth_getBlockNumber'); const req2 = RPC_REQUESTS.custom(SUBJECTS.b.origin, 'eth_getBlockNumber');
const req3 = RPC_REQUESTS.custom(DOMAINS.b.origin, 'net_version'); const req3 = RPC_REQUESTS.custom(SUBJECTS.b.origin, 'net_version');
logMiddleware(req1, res); logMiddleware(req1, res);
logMiddleware(req2, res); logMiddleware(req2, res);
logMiddleware(req3, res); logMiddleware(req3, res);
log = permLog.getActivityLog(); log = permLog.getActivityLog();
assert.equal(log.length, 0, 'log should still be empty'); expect(log).toHaveLength(0);
}); });
it('enforces log limit', function () { it('enforces log limit', () => {
const req = RPC_REQUESTS.test_method(DOMAINS.a.origin); const req = RPC_REQUESTS.test_method(SUBJECTS.a.origin);
const res = { foo: 'bar' }; const res = { foo: 'bar' };
// max out log // max out log
@ -312,11 +299,7 @@ describe('permissions log', function () {
// check last entry valid // check last entry valid
let log = permLog.getActivityLog(); let log = permLog.getActivityLog();
assert.equal( expect(log).toHaveLength(LOG_LIMIT);
log.length,
LOG_LIMIT,
'log should have LOG_LIMIT num entries',
);
validateActivityEntry( validateActivityEntry(
log[LOG_LIMIT - 1], log[LOG_LIMIT - 1],
@ -335,11 +318,7 @@ describe('permissions log', function () {
// check log length // check log length
log = permLog.getActivityLog(); log = permLog.getActivityLog();
assert.equal( expect(log).toHaveLength(LOG_LIMIT);
log.length,
LOG_LIMIT,
'log should have LOG_LIMIT num entries',
);
// check first and last entries // check first and last entries
validateActivityEntry( validateActivityEntry(
@ -360,24 +339,22 @@ describe('permissions log', function () {
}); });
}); });
describe('permissions history', function () { describe('permission history log', () => {
let permLog, logMiddleware; let permLog, logMiddleware;
beforeEach(function () { beforeEach(() => {
permLog = initPermLog(); permLog = initPermLog();
logMiddleware = initMiddleware(permLog); logMiddleware = initMiddleware(permLog);
initClock(); initClock();
}); });
afterEach(function () { afterEach(() => {
tearDownClock(); tearDownClock();
}); });
it('only updates history on responses', function () { it('only updates history on responses', () => {
let permHistory;
const req = RPC_REQUESTS.requestPermission( const req = RPC_REQUESTS.requestPermission(
DOMAINS.a.origin, SUBJECTS.a.origin,
PERM_NAMES.test_method, PERM_NAMES.test_method,
); );
const res = { result: [PERMS.granted.test_method()] }; const res = { result: [PERMS.granted.test_method()] };
@ -385,27 +362,19 @@ describe('permissions log', function () {
// noop => no response // noop => no response
logMiddleware({ ...req }, { ...res }, noop); logMiddleware({ ...req }, { ...res }, noop);
permHistory = permLog.getHistory(); expect(permLog.getHistory()).toStrictEqual({});
assert.deepEqual(permHistory, {}, 'history should not have been updated');
// response => records granted permissions // response => records granted permissions
logMiddleware({ ...req }, { ...res }); logMiddleware({ ...req }, { ...res });
permHistory = permLog.getHistory(); const permHistory = permLog.getHistory();
assert.equal( expect(Object.keys(permHistory)).toHaveLength(1);
Object.keys(permHistory).length, expect(permHistory[SUBJECTS.a.origin]).toBeDefined();
1,
'history should have single origin',
);
assert.ok(
Boolean(permHistory[DOMAINS.a.origin]),
'history should have expected origin',
);
}); });
it('ignores malformed permissions requests', function () { it('ignores malformed permissions requests', () => {
const req = RPC_REQUESTS.requestPermission( const req = RPC_REQUESTS.requestPermission(
DOMAINS.a.origin, SUBJECTS.a.origin,
PERM_NAMES.test_method, PERM_NAMES.test_method,
); );
delete req.params; delete req.params;
@ -414,18 +383,12 @@ describe('permissions log', function () {
// no params => no response // no params => no response
logMiddleware({ ...req }, { ...res }); logMiddleware({ ...req }, { ...res });
assert.deepEqual( expect(permLog.getHistory()).toStrictEqual({});
permLog.getHistory(),
{},
'history should not have been updated',
);
}); });
it('records and updates account history as expected', async function () { it('records and updates account history as expected', async () => {
let permHistory;
const req = RPC_REQUESTS.requestPermission( const req = RPC_REQUESTS.requestPermission(
DOMAINS.a.origin, SUBJECTS.a.origin,
PERM_NAMES.eth_accounts, PERM_NAMES.eth_accounts,
); );
const res = { const res = {
@ -434,15 +397,7 @@ describe('permissions log', function () {
logMiddleware({ ...req }, { ...res }); logMiddleware({ ...req }, { ...res });
// validate history expect(permLog.getHistory()).toStrictEqual(EXPECTED_HISTORIES.case1[0]);
permHistory = permLog.getHistory();
assert.deepEqual(
permHistory,
EXPECTED_HISTORIES.case1[0],
'should have correct history',
);
// mock permission requested again, with another approved account // mock permission requested again, with another approved account
@ -452,18 +407,12 @@ describe('permissions log', function () {
logMiddleware({ ...req }, { ...res }); logMiddleware({ ...req }, { ...res });
permHistory = permLog.getHistory(); expect(permLog.getHistory()).toStrictEqual(EXPECTED_HISTORIES.case1[1]);
assert.deepEqual(
permHistory,
EXPECTED_HISTORIES.case1[1],
'should have correct history',
);
}); });
it('handles eth_accounts response without caveats', async function () { it('handles eth_accounts response without caveats', async () => {
const req = RPC_REQUESTS.requestPermission( const req = RPC_REQUESTS.requestPermission(
DOMAINS.a.origin, SUBJECTS.a.origin,
PERM_NAMES.eth_accounts, PERM_NAMES.eth_accounts,
); );
const res = { const res = {
@ -473,18 +422,12 @@ describe('permissions log', function () {
logMiddleware({ ...req }, { ...res }); logMiddleware({ ...req }, { ...res });
// validate history expect(permLog.getHistory()).toStrictEqual(EXPECTED_HISTORIES.case2[0]);
assert.deepEqual(
permLog.getHistory(),
EXPECTED_HISTORIES.case2[0],
'should have expected history',
);
}); });
it('handles extra caveats for eth_accounts', async function () { it('handles extra caveats for eth_accounts', async () => {
const req = RPC_REQUESTS.requestPermission( const req = RPC_REQUESTS.requestPermission(
DOMAINS.a.origin, SUBJECTS.a.origin,
PERM_NAMES.eth_accounts, PERM_NAMES.eth_accounts,
); );
const res = { const res = {
@ -494,20 +437,14 @@ describe('permissions log', function () {
logMiddleware({ ...req }, { ...res }); logMiddleware({ ...req }, { ...res });
// validate history expect(permLog.getHistory()).toStrictEqual(EXPECTED_HISTORIES.case1[0]);
assert.deepEqual(
permLog.getHistory(),
EXPECTED_HISTORIES.case1[0],
'should have correct history',
);
}); });
// wallet_requestPermissions returns all permissions approved for the // wallet_requestPermissions returns all permissions approved for the
// requesting origin, including old ones // 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( const req = RPC_REQUESTS.requestPermission(
DOMAINS.a.origin, SUBJECTS.a.origin,
PERM_NAMES.eth_accounts, PERM_NAMES.eth_accounts,
); );
const res = { const res = {
@ -519,18 +456,12 @@ describe('permissions log', function () {
logMiddleware({ ...req }, { ...res }); logMiddleware({ ...req }, { ...res });
// validate history expect(permLog.getHistory()).toStrictEqual(EXPECTED_HISTORIES.case1[0]);
assert.deepEqual(
permLog.getHistory(),
EXPECTED_HISTORIES.case1[0],
'should have correct history',
);
}); });
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( let req = RPC_REQUESTS.requestPermission(
DOMAINS.a.origin, SUBJECTS.a.origin,
PERM_NAMES.test_method, PERM_NAMES.test_method,
); );
let res = { let res = {
@ -539,20 +470,14 @@ describe('permissions log', function () {
logMiddleware({ ...req }, { ...res }); logMiddleware({ ...req }, { ...res });
// validate history expect(permLog.getHistory()).toStrictEqual(EXPECTED_HISTORIES.case4[0]);
assert.deepEqual(
permLog.getHistory(),
EXPECTED_HISTORIES.case4[0],
'should have correct history',
);
// new permission requested, but not approved // new permission requested, but not approved
clock.tick(1); clock.tick(1);
req = RPC_REQUESTS.requestPermission( req = RPC_REQUESTS.requestPermission(
DOMAINS.a.origin, SUBJECTS.a.origin,
PERM_NAMES.eth_accounts, PERM_NAMES.eth_accounts,
); );
res = { res = {
@ -561,18 +486,11 @@ describe('permissions log', function () {
logMiddleware({ ...req }, { ...res }); logMiddleware({ ...req }, { ...res });
// validate history // history should be unmodified
expect(permLog.getHistory()).toStrictEqual(EXPECTED_HISTORIES.case4[0]);
assert.deepEqual(
permLog.getHistory(),
EXPECTED_HISTORIES.case4[0],
'should have same history as before',
);
}); });
it('records and updates history for multiple origins, regardless of response order', async function () { it('records and updates history for multiple origins, regardless of response order', async () => {
let permHistory;
// make first round of requests // make first round of requests
const round1 = []; const round1 = [];
@ -581,7 +499,7 @@ describe('permissions log', function () {
// first origin // first origin
round1.push({ round1.push({
req: RPC_REQUESTS.requestPermission( req: RPC_REQUESTS.requestPermission(
DOMAINS.a.origin, SUBJECTS.a.origin,
PERM_NAMES.test_method, PERM_NAMES.test_method,
), ),
res: { res: {
@ -592,7 +510,7 @@ describe('permissions log', function () {
// second origin // second origin
round1.push({ round1.push({
req: RPC_REQUESTS.requestPermission( req: RPC_REQUESTS.requestPermission(
DOMAINS.b.origin, SUBJECTS.b.origin,
PERM_NAMES.eth_accounts, PERM_NAMES.eth_accounts,
), ),
res: { res: {
@ -602,7 +520,7 @@ describe('permissions log', function () {
// third origin // third origin
round1.push({ round1.push({
req: RPC_REQUESTS.requestPermissions(DOMAINS.c.origin, { req: RPC_REQUESTS.requestPermissions(SUBJECTS.c.origin, {
[PERM_NAMES.test_method]: {}, [PERM_NAMES.test_method]: {},
[PERM_NAMES.eth_accounts]: {}, [PERM_NAMES.eth_accounts]: {},
}), }),
@ -623,14 +541,7 @@ describe('permissions log', function () {
handlers1[i](noop); handlers1[i](noop);
} }
// validate history expect(permLog.getHistory()).toStrictEqual(EXPECTED_HISTORIES.case3[0]);
permHistory = permLog.getHistory();
assert.deepEqual(
permHistory,
EXPECTED_HISTORIES.case3[0],
'should have expected history',
);
// make next round of requests // make next round of requests
@ -642,7 +553,7 @@ describe('permissions log', function () {
// first origin // first origin
round2.push({ round2.push({
req: RPC_REQUESTS.requestPermission( req: RPC_REQUESTS.requestPermission(
DOMAINS.a.origin, SUBJECTS.a.origin,
PERM_NAMES.test_method, PERM_NAMES.test_method,
), ),
res: { res: {
@ -654,7 +565,7 @@ describe('permissions log', function () {
// third origin // third origin
round2.push({ round2.push({
req: RPC_REQUESTS.requestPermissions(DOMAINS.c.origin, { req: RPC_REQUESTS.requestPermissions(SUBJECTS.c.origin, {
[PERM_NAMES.eth_accounts]: {}, [PERM_NAMES.eth_accounts]: {},
}), }),
res: { res: {
@ -667,14 +578,90 @@ describe('permissions log', function () {
logMiddleware({ ...x.req }, { ...x.res }); logMiddleware({ ...x.req }, { ...x.res });
}); });
// validate history expect(permLog.getHistory()).toStrictEqual(EXPECTED_HISTORIES.case3[1]);
permHistory = permLog.getHistory(); });
});
assert.deepEqual( describe('updateAccountsHistory', () => {
permHistory, beforeEach(() => {
EXPECTED_HISTORIES.case3[1], initClock();
'should have expected history', });
);
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,
});
}
}

@ -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',
);
});
});
});

@ -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();
}
});
}

@ -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',
);
});
});
});

@ -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);
}
},
},
};
}

@ -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<string, Record<string, unknown>>} state - The
* PermissionController state.
* @returns {Record<string, unknown>} 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<string, string[]>} 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<string, string[]>} newAccountsMap - The new origin:accounts[] map.
* @param {Map<string, string[]>} [previousAccountsMap] - The previous origin:accounts[] map.
* @returns {Map<string, string[]>} 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;
};

@ -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));
});
});
});

@ -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<string, Identity>,
* }} 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<string[]>,
* getIdentities: () => Record<string, Identity>,
* }} 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<string, Identity>} 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',
]);

@ -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);
});
});
});

@ -3,7 +3,10 @@ import { ObservableStore } from '@metamask/obs-store';
import { normalize as normalizeAddress } from 'eth-sig-util'; import { normalize as normalizeAddress } from 'eth-sig-util';
import { ethers } from 'ethers'; import { ethers } from 'ethers';
import log from 'loglevel'; 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 { isPrefixedFormattedHexString } from '../../../shared/modules/network.utils';
import { LEDGER_TRANSPORT_TYPES } from '../../../shared/constants/hardware-wallets'; import { LEDGER_TRANSPORT_TYPES } from '../../../shared/constants/hardware-wallets';
import { NETWORK_EVENTS } from './network'; import { NETWORK_EVENTS } from './network';
@ -24,7 +27,6 @@ export default class PreferencesController {
* @property {Object} store.knownMethodData Contains all data methods known by the user * @property {Object} store.knownMethodData Contains all data methods known by the user
* @property {string} store.currentLocale The preferred language locale key * @property {string} store.currentLocale The preferred language locale key
* @property {string} store.selectedAddress A hex string that matches the currently selected address in the app * @property {string} store.selectedAddress A hex string that matches the currently selected address in the app
*
*/ */
constructor(opts = {}) { constructor(opts = {}) {
const initState = { const initState = {
@ -61,7 +63,7 @@ export default class PreferencesController {
hideZeroBalanceTokens: false, hideZeroBalanceTokens: false,
}, },
// ENS decentralized website resolution // ENS decentralized website resolution
ipfsGateway: 'dweb.link', ipfsGateway: IPFS_DEFAULT_GATEWAY_URL,
infuraBlocked: null, infuraBlocked: null,
ledgerTransportType: window.navigator.hid ledgerTransportType: window.navigator.hid
? LEDGER_TRANSPORT_TYPES.WEBHID ? LEDGER_TRANSPORT_TYPES.WEBHID
@ -86,6 +88,7 @@ export default class PreferencesController {
/** /**
* Sets the {@code forgottenPassword} state property * Sets the {@code forgottenPassword} state property
*
* @param {boolean} forgottenPassword - whether or not the user has forgotten their password * @param {boolean} forgottenPassword - whether or not the user has forgotten their password
*/ */
setPasswordForgotten(forgottenPassword) { setPasswordForgotten(forgottenPassword) {
@ -96,7 +99,6 @@ export default class PreferencesController {
* Setter for the `useBlockie` property * Setter for the `useBlockie` property
* *
* @param {boolean} val - Whether or not the user prefers blockie indicators * @param {boolean} val - Whether or not the user prefers blockie indicators
*
*/ */
setUseBlockie(val) { setUseBlockie(val) {
this.store.updateState({ useBlockie: val }); this.store.updateState({ useBlockie: val });
@ -106,7 +108,6 @@ export default class PreferencesController {
* Setter for the `useNonceField` property * Setter for the `useNonceField` property
* *
* @param {boolean} val - Whether or not the user prefers to set nonce * @param {boolean} val - Whether or not the user prefers to set nonce
*
*/ */
setUseNonceField(val) { setUseNonceField(val) {
this.store.updateState({ useNonceField: val }); this.store.updateState({ useNonceField: val });
@ -116,7 +117,6 @@ export default class PreferencesController {
* Setter for the `usePhishDetect` property * Setter for the `usePhishDetect` property
* *
* @param {boolean} val - Whether or not the user prefers phishing domain protection * @param {boolean} val - Whether or not the user prefers phishing domain protection
*
*/ */
setUsePhishDetect(val) { setUsePhishDetect(val) {
this.store.updateState({ usePhishDetect: val }); this.store.updateState({ usePhishDetect: val });
@ -126,7 +126,6 @@ export default class PreferencesController {
* Setter for the `useTokenDetection` property * Setter for the `useTokenDetection` property
* *
* @param {boolean} val - Whether or not the user prefers to use the static token list or dynamic token list from the API * @param {boolean} val - Whether or not the user prefers to use the static token list or dynamic token list from the API
*
*/ */
setUseTokenDetection(val) { setUseTokenDetection(val) {
this.store.updateState({ useTokenDetection: val }); this.store.updateState({ useTokenDetection: val });
@ -136,7 +135,6 @@ export default class PreferencesController {
* Setter for the `useCollectibleDetection` property * Setter for the `useCollectibleDetection` property
* *
* @param {boolean} val - Whether or not the user prefers to autodetect collectibles. * @param {boolean} val - Whether or not the user prefers to autodetect collectibles.
*
*/ */
setUseCollectibleDetection(val) { setUseCollectibleDetection(val) {
const { openSeaEnabled } = this.store.getState(); const { openSeaEnabled } = this.store.getState();
@ -152,7 +150,6 @@ export default class PreferencesController {
* Setter for the `openSeaEnabled` property * Setter for the `openSeaEnabled` property
* *
* @param {boolean} val - Whether or not the user prefers to use the OpenSea API for collectibles data. * @param {boolean} val - Whether or not the user prefers to use the OpenSea API for collectibles data.
*
*/ */
setOpenSeaEnabled(val) { setOpenSeaEnabled(val) {
this.store.updateState({ openSeaEnabled: val }); this.store.updateState({ openSeaEnabled: val });
@ -165,7 +162,6 @@ export default class PreferencesController {
* Setter for the `advancedGasFee` property * Setter for the `advancedGasFee` property
* *
* @param {object} val - holds the maxBaseFee and PriorityFee that the user set as default advanced settings. * @param {object} val - holds the maxBaseFee and PriorityFee that the user set as default advanced settings.
*
*/ */
setAdvancedGasFee(val) { setAdvancedGasFee(val) {
this.store.updateState({ advancedGasFee: val }); this.store.updateState({ advancedGasFee: val });
@ -187,7 +183,6 @@ export default class PreferencesController {
* Setter for the `currentLocale` property * Setter for the `currentLocale` property
* *
* @param {string} key - he preferred language locale key * @param {string} key - he preferred language locale key
*
*/ */
setCurrentLocale(key) { setCurrentLocale(key) {
const textDirection = ['ar', 'dv', 'fa', 'he', 'ku'].includes(key) const textDirection = ['ar', 'dv', 'fa', 'he', 'ku'].includes(key)
@ -205,7 +200,6 @@ export default class PreferencesController {
* not included in addresses array * not included in addresses array
* *
* @param {string[]} addresses - An array of hex addresses * @param {string[]} addresses - An array of hex addresses
*
*/ */
setAddresses(addresses) { setAddresses(addresses) {
const oldIdentities = this.store.getState().identities; const oldIdentities = this.store.getState().identities;
@ -247,7 +241,6 @@ export default class PreferencesController {
* Adds addresses to the identities object without removing identities * Adds addresses to the identities object without removing identities
* *
* @param {string[]} addresses - An array of hex addresses * @param {string[]} addresses - An array of hex addresses
*
*/ */
addAddresses(addresses) { addAddresses(addresses) {
const { identities } = this.store.getState(); const { identities } = this.store.getState();
@ -312,7 +305,6 @@ export default class PreferencesController {
* Setter for the `selectedAddress` property * Setter for the `selectedAddress` property
* *
* @param {string} _address - A new hex address for an account * @param {string} _address - A new hex address for an account
*
*/ */
setSelectedAddress(_address) { setSelectedAddress(_address) {
const address = normalizeAddress(_address); const address = normalizeAddress(_address);
@ -331,7 +323,6 @@ export default class PreferencesController {
* Getter for the `selectedAddress` property * Getter for the `selectedAddress` property
* *
* @returns {string} The hex address for the currently selected account * @returns {string} The hex address for the currently selected account
*
*/ */
getSelectedAddress() { getSelectedAddress() {
return this.store.getState().selectedAddress; return this.store.getState().selectedAddress;
@ -339,6 +330,7 @@ export default class PreferencesController {
/** /**
* Sets a custom label for an account * Sets a custom label for an account
*
* @param {string} account - the account to set a label for * @param {string} account - the account to set a label for
* @param {string} label - the custom label for the account * @param {string} label - the custom label for the account
* @returns {Promise<string>} * @returns {Promise<string>}
@ -366,7 +358,6 @@ export default class PreferencesController {
* @param {string} [newRpcDetails.ticker] - Optional ticker symbol of the selected network. * @param {string} [newRpcDetails.ticker] - Optional ticker symbol of the selected network.
* @param {string} [newRpcDetails.nickname] - Optional nickname of the selected network. * @param {string} [newRpcDetails.nickname] - Optional nickname of the selected network.
* @param {Object} [newRpcDetails.rpcPrefs] - Optional RPC preferences, such as the block explorer URL * @param {Object} [newRpcDetails.rpcPrefs] - Optional RPC preferences, such as the block explorer URL
*
*/ */
async updateRpc(newRpcDetails) { async updateRpc(newRpcDetails) {
const rpcList = this.getFrequentRpcListDetail(); const rpcList = this.getFrequentRpcListDetail();
@ -442,7 +433,6 @@ export default class PreferencesController {
* @param {string} [ticker] - Ticker symbol of the selected network. * @param {string} [ticker] - Ticker symbol of the selected network.
* @param {string} [nickname] - Nickname of the selected network. * @param {string} [nickname] - Nickname of the selected network.
* @param {Object} [rpcPrefs] - Optional RPC preferences, such as the block explorer URL * @param {Object} [rpcPrefs] - Optional RPC preferences, such as the block explorer URL
*
*/ */
addToFrequentRpcList( addToFrequentRpcList(
rpcUrl, rpcUrl,
@ -472,8 +462,7 @@ export default class PreferencesController {
* Removes custom RPC url from state. * Removes custom RPC url from state.
* *
* @param {string} url - The RPC url to remove from frequentRpcList. * @param {string} url - The RPC url to remove from frequentRpcList.
* @returns {Promise<array>} Promise resolving to updated frequentRpcList. * @returns {Promise<Array>} Promise resolving to updated frequentRpcList.
*
*/ */
removeFromFrequentRpcList(url) { removeFromFrequentRpcList(url) {
const rpcList = this.getFrequentRpcListDetail(); const rpcList = this.getFrequentRpcListDetail();
@ -490,8 +479,7 @@ export default class PreferencesController {
/** /**
* Getter for the `frequentRpcListDetail` property. * Getter for the `frequentRpcListDetail` property.
* *
* @returns {array<array>} An array of rpc urls. * @returns {Array<Array>} An array of rpc urls.
*
*/ */
getFrequentRpcListDetail() { getFrequentRpcListDetail() {
return this.store.getState().frequentRpcListDetail; return this.store.getState().frequentRpcListDetail;
@ -503,7 +491,6 @@ export default class PreferencesController {
* @param {string} feature - A key that corresponds to a UI feature. * @param {string} feature - A key that corresponds to a UI feature.
* @param {boolean} activated - Indicates whether or not the UI feature should be displayed * @param {boolean} activated - Indicates whether or not the UI feature should be displayed
* @returns {Promise<object>} Promises a new object; the updated featureFlags object. * @returns {Promise<object>} Promises a new object; the updated featureFlags object.
*
*/ */
setFeatureFlag(feature, activated) { setFeatureFlag(feature, activated) {
const currentFeatureFlags = this.store.getState().featureFlags; const currentFeatureFlags = this.store.getState().featureFlags;
@ -520,6 +507,7 @@ export default class PreferencesController {
/** /**
* Updates the `preferences` property, which is an object. These are user-controlled features * Updates the `preferences` property, which is an object. These are user-controlled features
* found in the settings page. * found in the settings page.
*
* @param {string} preference - The preference to enable or disable. * @param {string} preference - The preference to enable or disable.
* @param {boolean} value - Indicates whether or not the preference should be enabled or disabled. * @param {boolean} value - Indicates whether or not the preference should be enabled or disabled.
* @returns {Promise<object>} Promises a new object; the updated preferences object. * @returns {Promise<object>} Promises a new object; the updated preferences object.
@ -537,6 +525,7 @@ export default class PreferencesController {
/** /**
* A getter for the `preferences` property * A getter for the `preferences` property
*
* @returns {Object} A key-boolean map of user-selected preferences. * @returns {Object} A key-boolean map of user-selected preferences.
*/ */
getPreferences() { getPreferences() {
@ -545,6 +534,7 @@ export default class PreferencesController {
/** /**
* A getter for the `ipfsGateway` property * A getter for the `ipfsGateway` property
*
* @returns {string} The current IPFS gateway domain * @returns {string} The current IPFS gateway domain
*/ */
getIpfsGateway() { getIpfsGateway() {
@ -553,6 +543,7 @@ export default class PreferencesController {
/** /**
* A setter for the `ipfsGateway` property * A setter for the `ipfsGateway` property
*
* @param {string} domain - The new IPFS gateway domain * @param {string} domain - The new IPFS gateway domain
* @returns {Promise<string>} A promise of the update IPFS gateway domain * @returns {Promise<string>} A promise of the update IPFS gateway domain
*/ */
@ -562,7 +553,8 @@ export default class PreferencesController {
} }
/** /**
* A setter for the `useWebHid` property * A setter for the `ledgerTransportType` property.
*
* @param {string} ledgerTransportType - Either 'ledgerLive', 'webhid' or 'u2f' * @param {string} ledgerTransportType - Either 'ledgerLive', 'webhid' or 'u2f'
* @returns {string} The transport type that was set. * @returns {string} The transport type that was set.
*/ */
@ -572,8 +564,9 @@ export default class PreferencesController {
} }
/** /**
* A getter for the `ledgerTransportType` property * A getter for the `ledgerTransportType` property.
* @returns {boolean} User preference of using WebHid to connect Ledger *
* @returns {string} The current preferred Ledger transport type.
*/ */
getLedgerTransportPreference() { getLedgerTransportPreference() {
return this.store.getState().ledgerTransportType; return this.store.getState().ledgerTransportType;
@ -581,8 +574,8 @@ export default class PreferencesController {
/** /**
* A setter for the user preference to dismiss the seed phrase backup reminder * A setter for the user preference to dismiss the seed phrase backup reminder
* @param {bool} dismissBackupReminder- User preference for dismissing the back up reminder *
* @returns {void} * @param {bool} dismissSeedBackUpReminder - User preference for dismissing the back up reminder.
*/ */
async setDismissSeedBackUpReminder(dismissSeedBackUpReminder) { async setDismissSeedBackUpReminder(dismissSeedBackUpReminder) {
await this.store.updateState({ await this.store.updateState({
@ -606,8 +599,8 @@ export default class PreferencesController {
/** /**
* *
* A setter for the `infuraBlocked` property * A setter for the `infuraBlocked` property
* @param {boolean} isBlocked - Bool indicating whether Infura is blocked
* *
* @param {boolean} isBlocked - Bool indicating whether Infura is blocked
*/ */
_setInfuraBlocked(isBlocked) { _setInfuraBlocked(isBlocked) {
const { infuraBlocked } = this.store.getState(); const { infuraBlocked } = this.store.getState();

@ -31,6 +31,7 @@ describe('preferences controller', function () {
.callsFake(() => ({ type: 'mainnet' })); .callsFake(() => ({ type: 'mainnet' }));
preferencesController = new PreferencesController({ preferencesController = new PreferencesController({
initLangCode: 'en_US',
migrateAddressBookState, migrateAddressBookState,
network, network,
provider, provider,
@ -41,6 +42,30 @@ describe('preferences controller', function () {
sinon.restore(); 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 () { describe('setAddresses', function () {
it('should keep a map of addresses to names and addresses in the store', function () { it('should keep a map of addresses to names and addresses in the store', function () {
preferencesController.setAddresses(['0xda22le', '0x7e57e2']); preferencesController.setAddresses(['0xda22le', '0x7e57e2']);

@ -845,7 +845,7 @@ export default class SwapsController {
/** /**
* Calculates the median overallValueOfQuote of a sample of quotes. * Calculates the median overallValueOfQuote of a sample of quotes.
* *
* @param {Array} quotes - A sample of quote objects with overallValueOfQuote, ethFee, metaMaskFeeInEth, and ethValueOfTokens properties * @param {Array} _quotes - A sample of quote objects with overallValueOfQuote, ethFee, metaMaskFeeInEth, and ethValueOfTokens properties
* @returns {Object} An object with the ethValueOfTokens, ethFee, and metaMaskFeeInEth of the quote with the median overallValueOfQuote * @returns {Object} An object with the ethValueOfTokens, ethFee, and metaMaskFeeInEth of the quote with the median overallValueOfQuote
*/ */
function getMedianEthValueQuote(_quotes) { function getMedianEthValueQuote(_quotes) {

@ -44,6 +44,7 @@ import {
} from '../../../../shared/constants/network'; } from '../../../../shared/constants/network';
import { isEIP1559Transaction } from '../../../../shared/modules/transaction.utils'; import { isEIP1559Transaction } from '../../../../shared/modules/transaction.utils';
import { readAddressAsContract } from '../../../../shared/modules/contract-utils'; import { readAddressAsContract } from '../../../../shared/modules/contract-utils';
import { isEqualCaseInsensitive } from '../../../../ui/helpers/utils/util';
import TransactionStateManager from './tx-state-manager'; import TransactionStateManager from './tx-state-manager';
import TxGasUtil from './tx-gas-utils'; import TxGasUtil from './tx-gas-utils';
import PendingTransactionTracker from './pending-tx-tracker'; import PendingTransactionTracker from './pending-tx-tracker';
@ -72,30 +73,30 @@ export const TRANSACTION_EVENTS = {
*/ */
/** /**
Transaction Controller is an aggregate of sub-controllers and trackers * Transaction Controller is an aggregate of sub-controllers and trackers
composing them in a way to be exposed to the metamask controller * composing them in a way to be exposed to the metamask controller
<br>- txStateManager *
responsible for the state of a transaction and * - `txStateManager
storing the transaction * responsible for the state of a transaction and
<br>- pendingTxTracker * storing the transaction
watching blocks for transactions to be include * - pendingTxTracker
and emitting confirmed events * watching blocks for transactions to be include
<br>- txGasUtil * and emitting confirmed events
gas calculations and safety buffering * - txGasUtil
<br>- nonceTracker * gas calculations and safety buffering
calculating nonces * - nonceTracker
* calculating nonces
@class *
@param {Object} opts * @param {Object} opts
@param {Object} opts.initState - initial transaction list default is an empty array * @param {Object} opts.initState - initial transaction list default is an empty array
@param {Object} opts.networkStore - an observable store for network number * @param {Object} opts.networkStore - an observable store for network number
@param {Object} opts.blockTracker - An instance of eth-blocktracker * @param {Object} opts.blockTracker - An instance of eth-blocktracker
@param {Object} opts.provider - A network provider. * @param {Object} opts.provider - A network provider.
@param {Function} opts.signTransaction - function the signs an @ethereumjs/tx * @param {Function} opts.signTransaction - function the signs an @ethereumjs/tx
@param {Object} opts.getPermittedAccounts - get accounts that an origin has permissions for * @param {Object} opts.getPermittedAccounts - get accounts that an origin has permissions for
@param {Function} opts.signTransaction - ethTx signer that returns a rawTx * @param {Function} opts.signTransaction - ethTx signer that returns a rawTx
@param {number} [opts.txHistoryLimit] - number *optional* for limiting how many transactions are in state * @param {number} [opts.txHistoryLimit] - number *optional* for limiting how many transactions are in state
@param {Object} opts.preferencesStore * @param {Object} opts.preferencesStore
*/ */
export default class TransactionController extends EventEmitter { export default class TransactionController extends EventEmitter {
@ -199,11 +200,13 @@ export default class TransactionController extends EventEmitter {
} }
/** /**
* @ethereumjs/tx uses @ethereumjs/common as a configuration tool for * `@ethereumjs/tx` uses `@ethereumjs/common` as a configuration tool for
* specifying which chain, network, hardfork and EIPs to support for * specifying which chain, network, hardfork and EIPs to support for
* a transaction. By referencing this configuration, and analyzing the fields * a transaction. By referencing this configuration, and analyzing the fields
* specified in txParams, @ethereumjs/tx is able to determine which EIP-2718 * specified in txParams, `@ethereumjs/tx` is able to determine which EIP-2718
* transaction type to use. * transaction type to use.
*
* @param fromAddress
* @returns {Common} common configuration object * @returns {Common} common configuration object
*/ */
async getCommonConfiguration(fromAddress) { async getCommonConfiguration(fromAddress) {
@ -251,8 +254,10 @@ export default class TransactionController extends EventEmitter {
} }
/** /**
Adds a tx to the txlist * Adds a tx to the txlist
@emits ${txMeta.id}:unapproved *
* @param txMeta
* @fires ${txMeta.id}:unapproved
*/ */
addTransaction(txMeta) { addTransaction(txMeta) {
this.txStateManager.addTransaction(txMeta); this.txStateManager.addTransaction(txMeta);
@ -261,8 +266,9 @@ export default class TransactionController extends EventEmitter {
} }
/** /**
Wipes the transactions for a given account * Wipes the transactions for a given account
@param {string} address - hex string of the from address for txs being removed *
* @param {string} address - hex string of the from address for txs being removed
*/ */
wipeTransactions(address) { wipeTransactions(address) {
this.txStateManager.wipeTransactions(address); this.txStateManager.wipeTransactions(address);
@ -327,6 +333,8 @@ export default class TransactionController extends EventEmitter {
* Validates and generates a txMeta with defaults and puts it in txStateManager * Validates and generates a txMeta with defaults and puts it in txStateManager
* store. * store.
* *
* @param txParams
* @param origin
* @returns {txMeta} * @returns {txMeta}
*/ */
async addUnapprovedTransaction(txParams, origin) { async addUnapprovedTransaction(txParams, origin) {
@ -337,10 +345,10 @@ export default class TransactionController extends EventEmitter {
txUtils.validateTxParams(normalizedTxParams, eip1559Compatibility); txUtils.validateTxParams(normalizedTxParams, eip1559Compatibility);
/** /**
`generateTxMeta` adds the default txMeta properties to the passed object. * `generateTxMeta` adds the default txMeta properties to the passed object.
These include the tx's `id`. As we use the id for determining order of * These include the tx's `id`. As we use the id for determining order of
txes in the tx-state-manager, it is necessary to call the asynchronous * txes in the tx-state-manager, it is necessary to call the asynchronous
method `this._determineTransactionType` after `generateTxMeta`. * method `this._determineTransactionType` after `generateTxMeta`.
*/ */
let txMeta = this.txStateManager.generateTxMeta({ let txMeta = this.txStateManager.generateTxMeta({
txParams: normalizedTxParams, txParams: normalizedTxParams,
@ -406,7 +414,9 @@ export default class TransactionController extends EventEmitter {
/** /**
* Adds the tx gas defaults: gas && gasPrice * Adds the tx gas defaults: gas && gasPrice
*
* @param {Object} txMeta - the txMeta object * @param {Object} txMeta - the txMeta object
* @param getCodeResponse
* @returns {Promise<object>} resolves with txMeta * @returns {Promise<object>} resolves with txMeta
*/ */
async addTxGasDefaults(txMeta, getCodeResponse) { async addTxGasDefaults(txMeta, getCodeResponse) {
@ -422,6 +432,7 @@ export default class TransactionController extends EventEmitter {
gasLimit: defaultGasLimit, gasLimit: defaultGasLimit,
simulationFails, simulationFails,
} = await this._getDefaultGasLimit(txMeta, getCodeResponse); } = await this._getDefaultGasLimit(txMeta, getCodeResponse);
const advancedGasFeeDefaultValues = this.getAdvancedGasFee();
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
txMeta = this.txStateManager.getTransaction(txMeta.id); txMeta = this.txStateManager.getTransaction(txMeta.id);
@ -430,13 +441,21 @@ export default class TransactionController extends EventEmitter {
} }
if (eip1559Compatibility) { if (eip1559Compatibility) {
// If the dapp has suggested a gas price, but no maxFeePerGas or maxPriorityFeePerGas if (process.env.EIP_1559_V2 && Boolean(advancedGasFeeDefaultValues)) {
// then we set maxFeePerGas and maxPriorityFeePerGas to the suggested gasPrice. txMeta.userFeeLevel = CUSTOM_GAS_ESTIMATE;
if ( txMeta.txParams.maxFeePerGas = decGWEIToHexWEI(
advancedGasFeeDefaultValues.maxBaseFee,
);
txMeta.txParams.maxPriorityFeePerGas = decGWEIToHexWEI(
advancedGasFeeDefaultValues.priorityFee,
);
} else if (
txMeta.txParams.gasPrice && txMeta.txParams.gasPrice &&
!txMeta.txParams.maxFeePerGas && !txMeta.txParams.maxFeePerGas &&
!txMeta.txParams.maxPriorityFeePerGas !txMeta.txParams.maxPriorityFeePerGas
) { ) {
// If the dapp has suggested a gas price, but no maxFeePerGas or maxPriorityFeePerGas
// then we set maxFeePerGas and maxPriorityFeePerGas to the suggested gasPrice.
txMeta.txParams.maxFeePerGas = txMeta.txParams.gasPrice; txMeta.txParams.maxFeePerGas = txMeta.txParams.gasPrice;
txMeta.txParams.maxPriorityFeePerGas = txMeta.txParams.gasPrice; txMeta.txParams.maxPriorityFeePerGas = txMeta.txParams.gasPrice;
if (process.env.EIP_1559_V2) { if (process.env.EIP_1559_V2) {
@ -525,7 +544,9 @@ export default class TransactionController extends EventEmitter {
/** /**
* Gets default gas fees, or returns `undefined` if gas fees are already set * Gets default gas fees, or returns `undefined` if gas fees are already set
*
* @param {Object} txMeta - The txMeta object * @param {Object} txMeta - The txMeta object
* @param eip1559Compatibility
* @returns {Promise<string|undefined>} The default gas price * @returns {Promise<string|undefined>} The default gas price
*/ */
async _getDefaultGasFees(txMeta, eip1559Compatibility) { async _getDefaultGasFees(txMeta, eip1559Compatibility) {
@ -583,6 +604,7 @@ export default class TransactionController extends EventEmitter {
/** /**
* Gets default gas limit, or debug information about why gas estimate failed. * Gets default gas limit, or debug information about why gas estimate failed.
*
* @param {Object} txMeta - The txMeta object * @param {Object} txMeta - The txMeta object
* @param {string} getCodeResponse - The transaction category code response, used for debugging purposes * @param {string} getCodeResponse - The transaction category code response, used for debugging purposes
* @returns {Promise<Object>} Object containing the default gas limit, or the simulation failure object * @returns {Promise<Object>} Object containing the default gas limit, or the simulation failure object
@ -638,6 +660,7 @@ export default class TransactionController extends EventEmitter {
* specified in customGasSettings, or falls back to incrementing by a percent * specified in customGasSettings, or falls back to incrementing by a percent
* which is defined by specifying a numerator. 11 is a 10% bump, 12 would be * which is defined by specifying a numerator. 11 is a 10% bump, 12 would be
* a 20% bump, and so on. * a 20% bump, and so on.
*
* @param {import( * @param {import(
* '../../../../shared/constants/transaction' * '../../../../shared/constants/transaction'
* ).TransactionMeta} originalTxMeta - Original transaction to use as base * ).TransactionMeta} originalTxMeta - Original transaction to use as base
@ -708,9 +731,12 @@ export default class TransactionController extends EventEmitter {
* Creates a new approved transaction to attempt to cancel a previously submitted transaction. The * Creates a new approved transaction to attempt to cancel a previously submitted transaction. The
* new transaction contains the same nonce as the previous, is a basic ETH transfer of 0x value to * new transaction contains the same nonce as the previous, is a basic ETH transfer of 0x value to
* the sender's address, and has a higher gasPrice than that of the previous transaction. * the sender's address, and has a higher gasPrice than that of the previous transaction.
*
* @param {number} originalTxId - the id of the txMeta that you want to attempt to cancel * @param {number} originalTxId - the id of the txMeta that you want to attempt to cancel
* @param {CustomGasSettings} [customGasSettings] - overrides to use for gas * @param {CustomGasSettings} [customGasSettings] - overrides to use for gas
* params instead of allowing this method to generate them * params instead of allowing this method to generate them
* @param options
* @param options.estimatedBaseFee
* @returns {txMeta} * @returns {txMeta}
*/ */
async createCancelTransaction( async createCancelTransaction(
@ -761,9 +787,12 @@ export default class TransactionController extends EventEmitter {
* new transaction contains the same nonce as the previous. By default, the new transaction will use * new transaction contains the same nonce as the previous. By default, the new transaction will use
* the same gas limit and a 10% higher gas price, though it is possible to set a custom value for * the same gas limit and a 10% higher gas price, though it is possible to set a custom value for
* each instead. * each instead.
*
* @param {number} originalTxId - the id of the txMeta that you want to speed up * @param {number} originalTxId - the id of the txMeta that you want to speed up
* @param {CustomGasSettings} [customGasSettings] - overrides to use for gas * @param {CustomGasSettings} [customGasSettings] - overrides to use for gas
* params instead of allowing this method to generate them * params instead of allowing this method to generate them
* @param options
* @param options.estimatedBaseFee
* @returns {txMeta} * @returns {txMeta}
*/ */
async createSpeedUpTransaction( async createSpeedUpTransaction(
@ -800,8 +829,9 @@ export default class TransactionController extends EventEmitter {
} }
/** /**
updates the txMeta in the txStateManager * updates the txMeta in the txStateManager
@param {Object} txMeta - the updated txMeta *
* @param {Object} txMeta - the updated txMeta
*/ */
async updateTransaction(txMeta) { async updateTransaction(txMeta) {
this.txStateManager.updateTransaction( this.txStateManager.updateTransaction(
@ -811,8 +841,9 @@ export default class TransactionController extends EventEmitter {
} }
/** /**
updates and approves the transaction * updates and approves the transaction
@param {Object} txMeta *
* @param {Object} txMeta
*/ */
async updateAndApproveTransaction(txMeta) { async updateAndApproveTransaction(txMeta) {
this.txStateManager.updateTransaction( this.txStateManager.updateTransaction(
@ -823,12 +854,13 @@ export default class TransactionController extends EventEmitter {
} }
/** /**
sets the tx status to approved * sets the tx status to approved
auto fills the nonce * auto fills the nonce
signs the transaction * signs the transaction
publishes the transaction * publishes the transaction
if any of these steps fails the tx status will be set to failed * if any of these steps fails the tx status will be set to failed
@param {number} txId - the tx's Id *
* @param {number} txId - the tx's Id
*/ */
async approveTransaction(txId) { async approveTransaction(txId) {
// TODO: Move this safety out of this function. // TODO: Move this safety out of this function.
@ -896,9 +928,10 @@ export default class TransactionController extends EventEmitter {
} }
/** /**
adds the chain id and signs the transaction and set the status to signed * adds the chain id and signs the transaction and set the status to signed
@param {number} txId - the tx's Id *
@returns {string} rawTx * @param {number} txId - the tx's Id
* @returns {string} rawTx
*/ */
async signTransaction(txId) { async signTransaction(txId) {
const txMeta = this.txStateManager.getTransaction(txId); const txMeta = this.txStateManager.getTransaction(txId);
@ -937,10 +970,11 @@ export default class TransactionController extends EventEmitter {
} }
/** /**
publishes the raw tx and sets the txMeta to submitted * publishes the raw tx and sets the txMeta to submitted
@param {number} txId - the tx's Id *
@param {string} rawTx - the hex string of the serialized signed transaction * @param {number} txId - the tx's Id
@returns {Promise<void>} * @param {string} rawTx - the hex string of the serialized signed transaction
* @returns {Promise<void>}
*/ */
async publishTransaction(txId, rawTx) { async publishTransaction(txId, rawTx) {
const txMeta = this.txStateManager.getTransaction(txId); const txMeta = this.txStateManager.getTransaction(txId);
@ -974,10 +1008,14 @@ export default class TransactionController extends EventEmitter {
/** /**
* Sets the status of the transaction to confirmed and sets the status of nonce duplicates as * Sets the status of the transaction to confirmed and sets the status of nonce duplicates as
* dropped if the txParams have data it will fetch the txReceipt * dropped if the txParams have data it will fetch the txReceipt
*
* @param {number} txId - The tx's ID * @param {number} txId - The tx's ID
* @param txReceipt
* @param baseFeePerGas
* @param blockTimestamp
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async confirmTransaction(txId, txReceipt, baseFeePerGas) { async confirmTransaction(txId, txReceipt, baseFeePerGas, blockTimestamp) {
// get the txReceipt before marking the transaction confirmed // get the txReceipt before marking the transaction confirmed
// to ensure the receipt is gotten before the ui revives the tx // to ensure the receipt is gotten before the ui revives the tx
const txMeta = this.txStateManager.getTransaction(txId); const txMeta = this.txStateManager.getTransaction(txId);
@ -1002,6 +1040,9 @@ export default class TransactionController extends EventEmitter {
if (baseFeePerGas) { if (baseFeePerGas) {
txMeta.baseFeePerGas = baseFeePerGas; txMeta.baseFeePerGas = baseFeePerGas;
} }
if (blockTimestamp) {
txMeta.blockTimestamp = blockTimestamp;
}
this.txStateManager.setTxStatusConfirmed(txId); this.txStateManager.setTxStatusConfirmed(txId);
this._markNonceDuplicatesDropped(txId); this._markNonceDuplicatesDropped(txId);
@ -1054,9 +1095,10 @@ export default class TransactionController extends EventEmitter {
} }
/** /**
Convenience method for the ui thats sets the transaction to rejected * Convenience method for the ui thats sets the transaction to rejected
@param {number} txId - the tx's Id *
@returns {Promise<void>} * @param {number} txId - the tx's Id
* @returns {Promise<void>}
*/ */
async cancelTransaction(txId) { async cancelTransaction(txId) {
const txMeta = this.txStateManager.getTransaction(txId); const txMeta = this.txStateManager.getTransaction(txId);
@ -1065,9 +1107,10 @@ export default class TransactionController extends EventEmitter {
} }
/** /**
Sets the txHas on the txMeta * Sets the txHas on the txMeta
@param {number} txId - the tx's Id *
@param {string} txHash - the hash for the txMeta * @param {number} txId - the tx's Id
* @param {string} txHash - the hash for the txMeta
*/ */
setTxHash(txId, txHash) { setTxHash(txId, txHash) {
// Add the tx hash to the persisted meta-tx object // Add the tx hash to the persisted meta-tx object
@ -1096,14 +1139,22 @@ export default class TransactionController extends EventEmitter {
Object.keys(this.txStateManager.getUnapprovedTxList()).length; Object.keys(this.txStateManager.getUnapprovedTxList()).length;
/** /**
@returns {number} number of transactions that have the status submitted * @returns {number} number of transactions that have the status submitted
@param {string} account - hex prefixed account * @param {string} account - hex prefixed account
*/ */
this.getPendingTxCount = (account) => this.getPendingTxCount = (account) =>
this.txStateManager.getPendingTransactions(account).length; this.txStateManager.getPendingTransactions(account).length;
/** see txStateManager */ /**
* see txStateManager
*
* @param opts
*/
this.getTransactions = (opts) => this.txStateManager.getTransactions(opts); this.getTransactions = (opts) => this.txStateManager.getTransactions(opts);
/** @returns {object} the saved default values for advancedGasFee */
this.getAdvancedGasFee = () =>
this.preferencesStore.getState().advancedGasFee;
} }
// called once on startup // called once on startup
@ -1115,9 +1166,9 @@ export default class TransactionController extends EventEmitter {
} }
/** /**
If transaction controller was rebooted with transactions that are uncompleted * If transaction controller was rebooted with transactions that are uncompleted
in steps of the transaction signing or user confirmation process it will either * in steps of the transaction signing or user confirmation process it will either
transition txMetas to a failed state or try to redo those tasks. * transition txMetas to a failed state or try to redo those tasks.
*/ */
_onBootCleanUp() { _onBootCleanUp() {
@ -1163,8 +1214,8 @@ export default class TransactionController extends EventEmitter {
} }
/** /**
is called in constructor applies the listeners for pendingTxTracker txStateManager * is called in constructor applies the listeners for pendingTxTracker txStateManager
and blockTracker * and blockTracker
*/ */
_setupListeners() { _setupListeners() {
this.txStateManager.on( this.txStateManager.on(
@ -1183,8 +1234,13 @@ export default class TransactionController extends EventEmitter {
}); });
this.pendingTxTracker.on( this.pendingTxTracker.on(
'tx:confirmed', 'tx:confirmed',
(txId, transactionReceipt, baseFeePerGas) => (txId, transactionReceipt, baseFeePerGas, blockTimestamp) =>
this.confirmTransaction(txId, transactionReceipt, baseFeePerGas), this.confirmTransaction(
txId,
transactionReceipt,
baseFeePerGas,
blockTimestamp,
),
); );
this.pendingTxTracker.on('tx:dropped', (txId) => { this.pendingTxTracker.on('tx:dropped', (txId) => {
this._dropTransaction(txId); this._dropTransaction(txId);
@ -1228,6 +1284,7 @@ export default class TransactionController extends EventEmitter {
* It will never return TRANSACTION_TYPE_CANCEL or TRANSACTION_TYPE_RETRY as these * It will never return TRANSACTION_TYPE_CANCEL or TRANSACTION_TYPE_RETRY as these
* represent specific events that we control from the extension and are added manually * represent specific events that we control from the extension and are added manually
* at transaction creation. * at transaction creation.
*
* @param {Object} txParams - Parameters for the transaction * @param {Object} txParams - Parameters for the transaction
* @returns {InferTransactionTypeResult} * @returns {InferTransactionTypeResult}
*/ */
@ -1244,7 +1301,7 @@ export default class TransactionController extends EventEmitter {
TRANSACTION_TYPES.TOKEN_METHOD_APPROVE, TRANSACTION_TYPES.TOKEN_METHOD_APPROVE,
TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER, TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER,
TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER_FROM, TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER_FROM,
].find((methodName) => methodName === name && name.toLowerCase()); ].find((methodName) => isEqualCaseInsensitive(methodName, name));
let result; let result;
if (data && tokenMethodName) { if (data && tokenMethodName) {
@ -1271,10 +1328,10 @@ export default class TransactionController extends EventEmitter {
} }
/** /**
Sets other txMeta statuses to dropped if the txMeta that has been confirmed has other transactions * Sets other txMeta statuses to dropped if the txMeta that has been confirmed has other transactions
in the list have the same nonce * in the list have the same nonce
*
@param {number} txId - the txId of the transaction that has been confirmed in a block * @param {number} txId - the txId of the transaction that has been confirmed in a block
*/ */
_markNonceDuplicatesDropped(txId) { _markNonceDuplicatesDropped(txId) {
// get the confirmed transactions nonce and from address // get the confirmed transactions nonce and from address
@ -1334,7 +1391,7 @@ export default class TransactionController extends EventEmitter {
} }
/** /**
Updates the memStore in transaction controller * Updates the memStore in transaction controller
*/ */
_updateMemstore() { _updateMemstore() {
const unapprovedTxs = this.txStateManager.getUnapprovedTxList(); const unapprovedTxs = this.txStateManager.getUnapprovedTxList();
@ -1396,6 +1453,7 @@ export default class TransactionController extends EventEmitter {
* Extracts relevant properties from a transaction meta * Extracts relevant properties from a transaction meta
* object and uses them to create and send metrics for various transaction * object and uses them to create and send metrics for various transaction
* events. * events.
*
* @param {Object} txMeta - the txMeta object * @param {Object} txMeta - the txMeta object
* @param {string} event - the name of the transaction event * @param {string} event - the name of the transaction event
* @param {Object} extraParams - optional props and values to include in sensitiveProperties * @param {Object} extraParams - optional props and values to include in sensitiveProperties

@ -2,9 +2,10 @@ import jsonDiffer from 'fast-json-patch';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
/** /**
converts non-initial history entries into diffs * converts non-initial history entries into diffs
@param {Array} longHistory *
@returns {Array} * @param {Array} longHistory
* @returns {Array}
*/ */
export function migrateFromSnapshotsToDiffs(longHistory) { export function migrateFromSnapshotsToDiffs(longHistory) {
return ( return (
@ -20,16 +21,17 @@ export function migrateFromSnapshotsToDiffs(longHistory) {
} }
/** /**
Generates an array of history objects sense the previous state. * Generates an array of history objects sense the previous state.
The object has the keys * The object has the keys
op (the operation performed), * op (the operation performed),
path (the key and if a nested object then each key will be separated with a `/`) * path (the key and if a nested object then each key will be separated with a `/`)
value * value
with the first entry having the note and a timestamp when the change took place * with the first entry having the note and a timestamp when the change took place
@param {Object} previousState - the previous state of the object *
@param {Object} newState - the update object * @param {Object} previousState - the previous state of the object
@param {string} [note] - a optional note for the state change * @param {Object} newState - the update object
@returns {Array} * @param {string} [note] - a optional note for the state change
* @returns {Array}
*/ */
export function generateHistoryEntry(previousState, newState, note) { export function generateHistoryEntry(previousState, newState, note) {
const entry = jsonDiffer.compare(previousState, newState); const entry = jsonDiffer.compare(previousState, newState);
@ -38,15 +40,16 @@ export function generateHistoryEntry(previousState, newState, note) {
if (note) { if (note) {
entry[0].note = note; entry[0].note = note;
} }
entry[0].timestamp = Date.now(); entry[0].timestamp = Date.now();
} }
return entry; return entry;
} }
/** /**
Recovers previous txMeta state obj * Recovers previous txMeta state obj
@returns {Object} *
* @param _shortHistory
* @returns {Object}
*/ */
export function replayHistory(_shortHistory) { export function replayHistory(_shortHistory) {
const shortHistory = cloneDeep(_shortHistory); const shortHistory = cloneDeep(_shortHistory);
@ -57,6 +60,7 @@ export function replayHistory(_shortHistory) {
/** /**
* Snapshot {@code txMeta} * Snapshot {@code txMeta}
*
* @param {Object} txMeta - the tx metadata object * @param {Object} txMeta - the tx metadata object
* @returns {Object} a deep clone without history * @returns {Object} a deep clone without history
*/ */

@ -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 result = generateHistoryEntry(prevState, nextState, note);
const after = new Date().getTime(); const timeAfter = new Date().getTime();
assert.ok(Array.isArray(result)); assert.ok(Array.isArray(result));
assert.equal(result.length, 3); 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].path, expectedEntry1.path);
assert.equal(result[0].value, expectedEntry1.value); assert.equal(result[0].value, expectedEntry1.value);
assert.equal(result[0].note, note); 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 = { const expectedEntry2 = {
op: 'replace', op: 'replace',

@ -31,6 +31,7 @@ export function normalizeAndValidateTxParams(txParams, lowerCase = true) {
/** /**
* Normalizes the given txParams * Normalizes the given txParams
*
* @param {Object} txParams - The transaction params * @param {Object} txParams - The transaction params
* @param {boolean} [lowerCase] - Whether to lowercase the 'to' address. * @param {boolean} [lowerCase] - Whether to lowercase the 'to' address.
* Default: true * Default: true
@ -50,10 +51,11 @@ export function normalizeTxParams(txParams, lowerCase = true) {
/** /**
* Given two fields, ensure that the second field is not included in txParams, * Given two fields, ensure that the second field is not included in txParams,
* and if it is throw an invalidParams error. * and if it is throw an invalidParams error.
*
* @param {Object} txParams - the transaction parameters object * @param {Object} txParams - the transaction parameters object
* @param {string} fieldBeingValidated - the current field being validated * @param {string} fieldBeingValidated - the current field being validated
* @param {string} mutuallyExclusiveField - the field to ensure is not provided * @param {string} mutuallyExclusiveField - the field to ensure is not provided
* @throws {ethErrors.rpc.invalidParams} - throws if mutuallyExclusiveField is * @throws {ethErrors.rpc.invalidParams} Throws if mutuallyExclusiveField is
* present in txParams. * present in txParams.
*/ */
function ensureMutuallyExclusiveFieldsNotProvided( function ensureMutuallyExclusiveFieldsNotProvided(
@ -71,9 +73,10 @@ function ensureMutuallyExclusiveFieldsNotProvided(
/** /**
* Ensures that the provided value for field is a string, throws an * Ensures that the provided value for field is a string, throws an
* invalidParams error if field is not a string. * invalidParams error if field is not a string.
*
* @param {Object} txParams - the transaction parameters object * @param {Object} txParams - the transaction parameters object
* @param {string} field - the current field being validated * @param {string} field - the current field being validated
* @throws {ethErrors.rpc.invalidParams} - throws if field is not a string * @throws {ethErrors.rpc.invalidParams} Throws if field is not a string
*/ */
function ensureFieldIsString(txParams, field) { function ensureFieldIsString(txParams, field) {
if (typeof txParams[field] !== 'string') { if (typeof txParams[field] !== 'string') {
@ -87,10 +90,11 @@ function ensureFieldIsString(txParams, field) {
* Ensures that the provided txParams has the proper 'type' specified for the * Ensures that the provided txParams has the proper 'type' specified for the
* given field, if it is provided. If types do not match throws an * given field, if it is provided. If types do not match throws an
* invalidParams error. * invalidParams error.
*
* @param {Object} txParams - the transaction parameters object * @param {Object} txParams - the transaction parameters object
* @param {'gasPrice' | 'maxFeePerGas' | 'maxPriorityFeePerGas'} field - the * @param {'gasPrice' | 'maxFeePerGas' | 'maxPriorityFeePerGas'} field - the
* current field being validated * current field being validated
* @throws {ethErrors.rpc.invalidParams} - throws if type does not match the * @throws {ethErrors.rpc.invalidParams} Throws if type does not match the
* expectations for provided field. * expectations for provided field.
*/ */
function ensureProperTransactionEnvelopeTypeProvided(txParams, field) { function ensureProperTransactionEnvelopeTypeProvided(txParams, field) {
@ -121,6 +125,7 @@ function ensureProperTransactionEnvelopeTypeProvided(txParams, field) {
/** /**
* Validates the given tx parameters * Validates the given tx parameters
*
* @param {Object} txParams - the tx params * @param {Object} txParams - the tx params
* @param {boolean} eip1559Compatibility - whether or not the current network supports EIP-1559 transactions * @param {boolean} eip1559Compatibility - whether or not the current network supports EIP-1559 transactions
* @throws {Error} if the tx params contains invalid fields * @throws {Error} if the tx params contains invalid fields
@ -221,6 +226,7 @@ export function validateTxParams(txParams, eip1559Compatibility = true) {
/** /**
* Validates the {@code from} field in the given tx params * Validates the {@code from} field in the given tx params
*
* @param {Object} txParams * @param {Object} txParams
* @throws {Error} if the from address isn't valid * @throws {Error} if the from address isn't valid
*/ */
@ -237,6 +243,7 @@ export function validateFrom(txParams) {
/** /**
* Validates the {@code to} field in the given tx params * Validates the {@code to} field in the given tx params
*
* @param {Object} txParams - the tx params * @param {Object} txParams - the tx params
* @returns {Object} the tx params * @returns {Object} the tx params
* @throws {Error} if the recipient is invalid OR there isn't tx data * @throws {Error} if the recipient is invalid OR there isn't tx data
@ -259,6 +266,7 @@ export function validateRecipient(txParams) {
/** /**
* Returns a list of final states * Returns a list of final states
*
* @returns {string[]} the states that can be considered final states * @returns {string[]} the states that can be considered final states
*/ */
export function getFinalStates() { export function getFinalStates() {

@ -4,21 +4,11 @@ import EthQuery from 'ethjs-query';
import { TRANSACTION_STATUSES } from '../../../../shared/constants/transaction'; import { TRANSACTION_STATUSES } from '../../../../shared/constants/transaction';
/** /**
* Event emitter utility class for tracking the transactions as they
Event emitter utility class for tracking the transactions as they<br> * go from a pending state to a confirmed (mined in a block) state.
go from a pending state to a confirmed (mined in a block) state<br> *
<br> * As well as continues broadcast while in the pending state.
As well as continues broadcast while in the pending state
<br>
@param {Object} config - non optional configuration object consists of:
@param {Object} config.provider - A network provider.
@param {Object} config.nonceTracker - see nonce tracker
@param {Function} config.getPendingTransactions - a function for getting an array of transactions,
@param {Function} config.publishTransaction - a async function for publishing raw transactions,
@class
*/ */
export default class PendingTransactionTracker extends EventEmitter { export default class PendingTransactionTracker extends EventEmitter {
/** /**
* We wait this many blocks before emitting a 'tx:dropped' event * We wait this many blocks before emitting a 'tx:dropped' event
@ -33,10 +23,21 @@ export default class PendingTransactionTracker extends EventEmitter {
* A map of transaction hashes to the number of blocks we've seen * A map of transaction hashes to the number of blocks we've seen
* since first considering it dropped * since first considering it dropped
* *
* @type {Map<String, number>} * @type {Map<string, number>}
*/ */
droppedBlocksBufferByHash = new Map(); droppedBlocksBufferByHash = new Map();
/**
* @param {Object} config - Configuration.
* @param {Function} config.approveTransaction - Approves a transaction.
* @param {Function} config.confirmTransaction - Set a transaction as confirmed.
* @param {Function} config.getCompletedTransactions - Returns completed transactions.
* @param {Function} config.getPendingTransactions - Returns an array of pending transactions,
* @param {Object} config.nonceTracker - see nonce tracker
* @param {Object} config.provider - A network provider.
* @param {Object} config.query - An EthQuery instance.
* @param {Function} config.publishTransaction - Publishes a raw transaction,
*/
constructor(config) { constructor(config) {
super(); super();
this.query = config.query || new EthQuery(config.provider); this.query = config.query || new EthQuery(config.provider);
@ -49,7 +50,7 @@ export default class PendingTransactionTracker extends EventEmitter {
} }
/** /**
checks the network for signed txs and releases the nonce global lock if it is * checks the network for signed txs and releases the nonce global lock if it is
*/ */
async updatePendingTxs() { async updatePendingTxs() {
// in order to keep the nonceTracker accurate we block it while updating pending transactions // in order to keep the nonceTracker accurate we block it while updating pending transactions
@ -70,8 +71,9 @@ export default class PendingTransactionTracker extends EventEmitter {
/** /**
* Resubmits each pending transaction * Resubmits each pending transaction
*
* @param {string} blockNumber - the latest block number in hex * @param {string} blockNumber - the latest block number in hex
* @emits tx:warning * @fires tx:warning
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async resubmitPendingTxs(blockNumber) { async resubmitPendingTxs(blockNumber) {
@ -119,8 +121,8 @@ export default class PendingTransactionTracker extends EventEmitter {
* @param {Object} txMeta - the transaction metadata * @param {Object} txMeta - the transaction metadata
* @param {string} latestBlockNumber - the latest block number in hex * @param {string} latestBlockNumber - the latest block number in hex
* @returns {Promise<string|undefined>} the tx hash if retried * @returns {Promise<string|undefined>} the tx hash if retried
* @emits tx:block-update * @fires tx:block-update
* @emits tx:retry * @fires tx:retry
* @private * @private
*/ */
async _resubmitTx(txMeta, latestBlockNumber) { async _resubmitTx(txMeta, latestBlockNumber) {
@ -156,14 +158,16 @@ export default class PendingTransactionTracker extends EventEmitter {
/** /**
* Query the network to see if the given {@code txMeta} has been included in a block * Query the network to see if the given {@code txMeta} has been included in a block
*
* @param {Object} txMeta - the transaction metadata * @param {Object} txMeta - the transaction metadata
* @returns {Promise<void>} * @returns {Promise<void>}
* @emits tx:confirmed * @fires tx:confirmed
* @emits tx:dropped * @fires tx:dropped
* @emits tx:failed * @fires tx:failed
* @emits tx:warning * @fires tx:warning
* @private * @private
*/ */
async _checkPendingTx(txMeta) { async _checkPendingTx(txMeta) {
const txHash = txMeta.hash; const txHash = txMeta.hash;
const txId = txMeta.id; const txId = txMeta.id;
@ -193,11 +197,21 @@ export default class PendingTransactionTracker extends EventEmitter {
try { try {
const transactionReceipt = await this.query.getTransactionReceipt(txHash); const transactionReceipt = await this.query.getTransactionReceipt(txHash);
if (transactionReceipt?.blockNumber) { if (transactionReceipt?.blockNumber) {
const { baseFeePerGas } = await this.query.getBlockByHash( const {
baseFeePerGas,
timestamp: blockTimestamp,
} = await this.query.getBlockByHash(
transactionReceipt?.blockHash, transactionReceipt?.blockHash,
false, false,
); );
this.emit('tx:confirmed', txId, transactionReceipt, baseFeePerGas);
this.emit(
'tx:confirmed',
txId,
transactionReceipt,
baseFeePerGas,
blockTimestamp,
);
return; return;
} }
} catch (err) { } catch (err) {
@ -249,6 +263,7 @@ export default class PendingTransactionTracker extends EventEmitter {
/** /**
* Checks whether the nonce in the given {@code txMeta} is correct against the local set of transactions * Checks whether the nonce in the given {@code txMeta} is correct against the local set of transactions
*
* @param {Object} txMeta - the transaction metadata * @param {Object} txMeta - the transaction metadata
* @returns {Promise<boolean>} * @returns {Promise<boolean>}
* @private * @private

@ -7,6 +7,7 @@ import { hexToBn, BnMultiplyByFraction, bnToHex } from '../../lib/util';
/** /**
* Result of gas analysis, including either a gas estimate for a successful analysis, or * Result of gas analysis, including either a gas estimate for a successful analysis, or
* debug information for a failed analysis. * debug information for a failed analysis.
*
* @typedef {Object} GasAnalysisResult * @typedef {Object} GasAnalysisResult
* @property {string} blockGasLimit - The gas limit of the block used for the analysis * @property {string} blockGasLimit - The gas limit of the block used for the analysis
* @property {string} estimatedGasHex - The estimated gas, in hexadecimal * @property {string} estimatedGasHex - The estimated gas, in hexadecimal
@ -14,10 +15,11 @@ import { hexToBn, BnMultiplyByFraction, bnToHex } from '../../lib/util';
*/ */
/** /**
tx-gas-utils are gas utility methods for Transaction manager * tx-gas-utils are gas utility methods for Transaction manager
its passed ethquery * its passed ethquery
and used to do things like calculate gas of a tx. * and used to do things like calculate gas of a tx.
@param {Object} provider - A network provider. *
* @param {Object} provider - A network provider.
*/ */
export default class TxGasUtil { export default class TxGasUtil {
@ -26,8 +28,8 @@ export default class TxGasUtil {
} }
/** /**
@param {Object} txMeta - the txMeta object * @param {Object} txMeta - the txMeta object
@returns {GasAnalysisResult} The result of the gas analysis * @returns {GasAnalysisResult} The result of the gas analysis
*/ */
async analyzeGasUsage(txMeta) { async analyzeGasUsage(txMeta) {
const block = await this.query.getBlockByNumber('latest', false); const block = await this.query.getBlockByNumber('latest', false);
@ -52,9 +54,10 @@ export default class TxGasUtil {
} }
/** /**
Estimates the tx's gas usage * Estimates the tx's gas usage
@param {Object} txMeta - the txMeta object *
@returns {string} the estimated gas limit as a hex string * @param {Object} txMeta - the txMeta object
* @returns {string} the estimated gas limit as a hex string
*/ */
async estimateTxGas(txMeta) { async estimateTxGas(txMeta) {
const txParams = cloneDeep(txMeta.txParams); const txParams = cloneDeep(txMeta.txParams);
@ -73,11 +76,12 @@ export default class TxGasUtil {
} }
/** /**
Adds a gas buffer with out exceeding the block gas limit * Adds a gas buffer with out exceeding the block gas limit
*
@param {string} initialGasLimitHex - the initial gas limit to add the buffer too * @param {string} initialGasLimitHex - the initial gas limit to add the buffer too
@param {string} blockGasLimitHex - the block gas limit * @param {string} blockGasLimitHex - the block gas limit
@returns {string} the buffered gas limit as a hex string * @param multiplier
* @returns {string} the buffered gas limit as a hex string
*/ */
addGasBuffer(initialGasLimitHex, blockGasLimitHex, multiplier = 1.5) { addGasBuffer(initialGasLimitHex, blockGasLimitHex, multiplier = 1.5) {
const initialGasLimitBn = hexToBn(initialGasLimitHex); const initialGasLimitBn = hexToBn(initialGasLimitHex);

@ -15,6 +15,7 @@ import { getFinalStates, normalizeAndValidateTxParams } from './lib/util';
/** /**
* TransactionStatuses reimported from the shared transaction constants file * TransactionStatuses reimported from the shared transaction constants file
*
* @typedef {import( * @typedef {import(
* '../../../../shared/constants/transaction' * '../../../../shared/constants/transaction'
* ).TransactionStatusString} TransactionStatusString * ).TransactionStatusString} TransactionStatusString
@ -40,13 +41,13 @@ import { getFinalStates, normalizeAndValidateTxParams } from './lib/util';
* TransactionStateManager is responsible for the state of a transaction and * TransactionStateManager is responsible for the state of a transaction and
* storing the transaction. It also has some convenience methods for finding * storing the transaction. It also has some convenience methods for finding
* subsets of transactions. * subsets of transactions.
*
* @param {Object} opts * @param {Object} opts
* @param {TransactionState} [opts.initState={ transactions: {} }] - initial * @param {TransactionState} [opts.initState={ transactions: {} }] - initial
* transactions list keyed by id * transactions list keyed by id
* @param {number} [opts.txHistoryLimit] - limit for how many finished * @param {number} [opts.txHistoryLimit] - limit for how many finished
* transactions can hang around in state * transactions can hang around in state
* @param {Function} opts.getNetwork - return network number * @param {Function} opts.getNetwork - return network number
* @class
*/ */
export default class TransactionStateManager extends EventEmitter { export default class TransactionStateManager extends EventEmitter {
constructor({ initState, txHistoryLimit, getNetwork, getCurrentChainId }) { constructor({ initState, txHistoryLimit, getNetwork, getCurrentChainId }) {
@ -196,6 +197,7 @@ export default class TransactionStateManager extends EventEmitter {
* is in its final state. * is in its final state.
* it will also add the key `history` to the txMeta with the snap shot of * it will also add the key `history` to the txMeta with the snap shot of
* the original object * the original object
*
* @param {TransactionMeta} txMeta - The TransactionMeta object to add. * @param {TransactionMeta} txMeta - The TransactionMeta object to add.
* @returns {TransactionMeta} The same TransactionMeta, but with validated * @returns {TransactionMeta} The same TransactionMeta, but with validated
* txParams and transaction history. * txParams and transaction history.
@ -274,6 +276,7 @@ export default class TransactionStateManager extends EventEmitter {
/** /**
* updates the txMeta in the list and adds a history entry * updates the txMeta in the list and adds a history entry
*
* @param {Object} txMeta - the txMeta to update * @param {Object} txMeta - the txMeta to update
* @param {string} [note] - a note about the update for history * @param {string} [note] - a note about the update for history
*/ */
@ -307,6 +310,7 @@ export default class TransactionStateManager extends EventEmitter {
* SearchCriteria can search in any key in TxParams or the base * SearchCriteria can search in any key in TxParams or the base
* TransactionMeta. This type represents any key on either of those two * TransactionMeta. This type represents any key on either of those two
* types. * types.
*
* @typedef {TxParams[keyof TxParams] | TransactionMeta[keyof TransactionMeta]} SearchableKeys * @typedef {TxParams[keyof TxParams] | TransactionMeta[keyof TransactionMeta]} SearchableKeys
*/ */
@ -314,6 +318,7 @@ export default class TransactionStateManager extends EventEmitter {
* Predicates can either be strict values, which is shorthand for using * Predicates can either be strict values, which is shorthand for using
* strict equality, or a method that receives he value of the specified key * strict equality, or a method that receives he value of the specified key
* and returns a boolean. * and returns a boolean.
*
* @typedef {(v: unknown) => boolean | unknown} FilterPredicate * @typedef {(v: unknown) => boolean | unknown} FilterPredicate
*/ */
@ -336,7 +341,7 @@ export default class TransactionStateManager extends EventEmitter {
* @param {TransactionMeta[]} [opts.initialList] - If provided the filtering * @param {TransactionMeta[]} [opts.initialList] - If provided the filtering
* will occur on the provided list. By default this will be the full list * will occur on the provided list. By default this will be the full list
* from state sorted by time ASC. * from state sorted by time ASC.
* @param {boolean} [opts.filterToCurrentNetwork=true] - Filter transaction * @param {boolean} [opts.filterToCurrentNetwork] - Filter transaction
* list to only those that occurred on the current chain or network. * list to only those that occurred on the current chain or network.
* Defaults to true. * Defaults to true.
* @param {number} [opts.limit] - limit the number of transactions returned * @param {number} [opts.limit] - limit the number of transactions returned
@ -576,27 +581,28 @@ export default class TransactionStateManager extends EventEmitter {
* Updates a transaction's status in state, and then emits events that are * Updates a transaction's status in state, and then emits events that are
* subscribed to elsewhere. See below for best guesses on where and how these * subscribed to elsewhere. See below for best guesses on where and how these
* events are received. * events are received.
*
* @param {number} txId - the TransactionMeta Id * @param {number} txId - the TransactionMeta Id
* @param {TransactionStatusString} status - the status to set on the * @param {TransactionStatusString} status - the status to set on the
* TransactionMeta * TransactionMeta
* @emits txMeta.id:txMeta.status - every time a transaction's status changes * @fires txMeta.id:txMeta.status - every time a transaction's status changes
* we emit the change passing along the id. This does not appear to be used * we emit the change passing along the id. This does not appear to be used
* outside of this file, which only listens to this to unsubscribe listeners * outside of this file, which only listens to this to unsubscribe listeners
* of :rejected and :signed statuses when the inverse status changes. Likely * of :rejected and :signed statuses when the inverse status changes. Likely
* safe to drop. * safe to drop.
* @emits tx:status-update - every time a transaction's status changes we * @fires tx:status-update - every time a transaction's status changes we
* emit this event and pass txId and status. This event is subscribed to in * emit this event and pass txId and status. This event is subscribed to in
* the TransactionController and re-broadcast by the TransactionController. * the TransactionController and re-broadcast by the TransactionController.
* It is used internally within the TransactionController to try and update * It is used internally within the TransactionController to try and update
* pending transactions on each new block (from blockTracker). It's also * pending transactions on each new block (from blockTracker). It's also
* subscribed to in metamask-controller to display a browser notification on * subscribed to in metamask-controller to display a browser notification on
* confirmed or failed transactions. * confirmed or failed transactions.
* @emits txMeta.id:finished - When a transaction moves to a finished state * @fires txMeta.id:finished - When a transaction moves to a finished state
* this event is emitted, which is used in the TransactionController to pass * this event is emitted, which is used in the TransactionController to pass
* along details of the transaction to the dapp that suggested them. This * along details of the transaction to the dapp that suggested them. This
* pattern is replicated across all of the message managers and can likely * pattern is replicated across all of the message managers and can likely
* be supplemented or replaced by the ApprovalController. * be supplemented or replaced by the ApprovalController.
* @emits updateBadge - When the number of transactions changes in state, * @fires updateBadge - When the number of transactions changes in state,
* the badge in the browser extension bar should be updated to reflect the * the badge in the browser extension bar should be updated to reflect the
* number of pending transactions. This particular emit doesn't appear to * number of pending transactions. This particular emit doesn't appear to
* bubble up anywhere that is actually used. TransactionController emits * bubble up anywhere that is actually used. TransactionController emits

@ -679,8 +679,11 @@ describe('TransactionStateManager', function () {
// transaction // transaction
const txs = generateTransactions(limit + 5, { const txs = generateTransactions(limit + 5, {
chainId: (i) => { chainId: (i) => {
if (i === 0 || i === 1) return MAINNET_CHAIN_ID; if (i === 0 || i === 1) {
else if (i === 4 || i === 5) return RINKEBY_CHAIN_ID; return MAINNET_CHAIN_ID;
} else if (i === 4 || i === 5) {
return RINKEBY_CHAIN_ID;
}
return currentChainId; return currentChainId;
}, },
to: VALID_ADDRESS, to: VALID_ADDRESS,
@ -726,8 +729,11 @@ describe('TransactionStateManager', function () {
to: VALID_ADDRESS, to: VALID_ADDRESS,
from: VALID_ADDRESS_TWO, from: VALID_ADDRESS_TWO,
nonce: (i) => { nonce: (i) => {
if (i === 1) return '0'; if (i === 1) {
else if (i === 5) return '4'; return '0';
} else if (i === 5) {
return '4';
}
return `${i}`; return `${i}`;
}, },
status: (i) => status: (i) =>
@ -845,9 +851,9 @@ describe('TransactionStateManager', function () {
); );
// modify value and updateTransaction // modify value and updateTransaction
updatedTx.txParams.gasPrice = desiredGasPrice; updatedTx.txParams.gasPrice = desiredGasPrice;
const before = new Date().getTime(); const timeBefore = new Date().getTime();
txStateManager.updateTransaction(updatedTx); txStateManager.updateTransaction(updatedTx);
const after = new Date().getTime(); const timeAfter = new Date().getTime();
// check updated value // check updated value
const result = txStateManager.getTransaction('1'); const result = txStateManager.getTransaction('1');
assert.equal( assert.equal(
@ -888,8 +894,8 @@ describe('TransactionStateManager', function () {
'two history items (initial + diff) value', 'two history items (initial + diff) value',
); );
assert.ok( assert.ok(
result.history[1][0].timestamp >= before && result.history[1][0].timestamp >= timeBefore &&
result.history[1][0].timestamp <= after, result.history[1][0].timestamp <= timeAfter,
); );
}); });

@ -15,6 +15,7 @@ export default class ComposableObservableStore extends ObservableStore {
* store, and the value is either an ObserableStore, or a controller that * store, and the value is either an ObserableStore, or a controller that
* extends one of the two base controllers in the `@metamask/controllers` * extends one of the two base controllers in the `@metamask/controllers`
* package. * package.
*
* @type {Record<string, Object>} * @type {Record<string, Object>}
*/ */
config = {}; config = {};
@ -28,7 +29,7 @@ export default class ComposableObservableStore extends ObservableStore {
* messenger, used for subscribing to events from BaseControllerV2-based * messenger, used for subscribing to events from BaseControllerV2-based
* controllers. * controllers.
* @param {Object} [options.state] - The initial store state * @param {Object} [options.state] - The initial store state
* @param {boolean} [options.persist] - Wether or not to apply the persistence for v2 controllers * @param {boolean} [options.persist] - Whether or not to apply the persistence for v2 controllers
*/ */
constructor({ config, controllerMessenger, state, persist }) { constructor({ config, controllerMessenger, state, persist }) {
super(state); super(state);

@ -1,4 +1,3 @@
import { strict as assert } from 'assert';
import { ObservableStore } from '@metamask/obs-store'; import { ObservableStore } from '@metamask/obs-store';
import { import {
BaseController, BaseController,
@ -48,17 +47,17 @@ class ExampleController extends BaseControllerV2 {
} }
} }
describe('ComposableObservableStore', function () { describe('ComposableObservableStore', () => {
it('should register initial state', function () { it('should register initial state', () => {
const controllerMessenger = new ControllerMessenger(); const controllerMessenger = new ControllerMessenger();
const store = new ComposableObservableStore({ const store = new ComposableObservableStore({
controllerMessenger, controllerMessenger,
state: 'state', 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 controllerMessenger = new ControllerMessenger();
const testStore = new ObservableStore(); const testStore = new ObservableStore();
const store = new ComposableObservableStore({ const store = new ComposableObservableStore({
@ -66,28 +65,28 @@ describe('ComposableObservableStore', function () {
controllerMessenger, controllerMessenger,
}); });
testStore.putState('state'); 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 controllerMessenger = new ControllerMessenger();
const testStore = new ObservableStore(); const testStore = new ObservableStore();
const store = new ComposableObservableStore({ controllerMessenger }); const store = new ComposableObservableStore({ controllerMessenger });
store.updateStructure({ TestStore: testStore }); store.updateStructure({ TestStore: testStore });
testStore.putState('state'); 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 controllerMessenger = new ControllerMessenger();
const oldExampleController = new OldExampleController(); const oldExampleController = new OldExampleController();
const store = new ComposableObservableStore({ controllerMessenger }); const store = new ComposableObservableStore({ controllerMessenger });
store.updateStructure({ OldExample: oldExampleController }); store.updateStructure({ OldExample: oldExampleController });
oldExampleController.updateBaz('state'); 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 controllerMessenger = new ControllerMessenger();
const exampleController = new ExampleController({ const exampleController = new ExampleController({
messenger: controllerMessenger, messenger: controllerMessenger,
@ -95,11 +94,10 @@ describe('ComposableObservableStore', function () {
const store = new ComposableObservableStore({ controllerMessenger }); const store = new ComposableObservableStore({ controllerMessenger });
store.updateStructure({ Example: exampleController }); store.updateStructure({ Example: exampleController });
exampleController.updateBar('state'); exampleController.updateBar('state');
console.log(exampleController.state); expect(store.getState()).toStrictEqual({ Example: { bar: 'state' } });
assert.deepEqual(store.getState(), { 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 controllerMessenger = new ControllerMessenger();
const exampleStore = new ObservableStore(); const exampleStore = new ObservableStore();
const exampleController = new ExampleController({ const exampleController = new ExampleController({
@ -115,14 +113,14 @@ describe('ComposableObservableStore', function () {
exampleStore.putState('state'); exampleStore.putState('state');
exampleController.updateBar('state'); exampleController.updateBar('state');
oldExampleController.updateBaz('state'); oldExampleController.updateBaz('state');
assert.deepEqual(store.getState(), { expect(store.getState()).toStrictEqual({
Example: { bar: 'state' }, Example: { bar: 'state' },
OldExample: { baz: 'state' }, OldExample: { baz: 'state' },
Store: 'state', Store: 'state',
}); });
}); });
it('should return flattened state', function () { it('should return flattened state', () => {
const controllerMessenger = new ControllerMessenger(); const controllerMessenger = new ControllerMessenger();
const fooStore = new ObservableStore({ foo: 'foo' }); const fooStore = new ObservableStore({ foo: 'foo' });
const barController = new ExampleController({ const barController = new ExampleController({
@ -142,46 +140,48 @@ describe('ComposableObservableStore', function () {
BazStore: bazController.state, BazStore: bazController.state,
}, },
}); });
assert.deepEqual(store.getFlatState(), { expect(store.getFlatState()).toStrictEqual({
foo: 'foo', foo: 'foo',
bar: 'bar', bar: 'bar',
baz: 'baz', 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 controllerMessenger = new ControllerMessenger();
const store = new ComposableObservableStore({ 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 controllerMessenger = new ControllerMessenger();
const exampleController = new ExampleController({ const exampleController = new ExampleController({
messenger: controllerMessenger, messenger: controllerMessenger,
}); });
assert.throws( expect(
() => () =>
new ComposableObservableStore({ new ComposableObservableStore({
config: { config: {
Example: exampleController, 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 controllerMessenger = new ControllerMessenger();
const exampleController = new ExampleController({ const exampleController = new ExampleController({
messenger: controllerMessenger, messenger: controllerMessenger,
}); });
const store = new ComposableObservableStore({}); 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(); const controllerMessenger = new ControllerMessenger();
assert.throws( expect(
() => () =>
new ComposableObservableStore({ new ComposableObservableStore({
config: { config: {
@ -189,6 +189,6 @@ describe('ComposableObservableStore', function () {
}, },
controllerMessenger, controllerMessenger,
}), }),
); ).toThrow(`Undefined 'Example'`);
}); });
}); });

@ -44,7 +44,6 @@ import { bnToHex } from './util';
* @property {BlockTracker} _blockTracker A BlockTracker instance. Needed to ensure that accounts and their info updates * @property {BlockTracker} _blockTracker A BlockTracker instance. Needed to ensure that accounts and their info updates
* when a new block is created. * when a new block is created.
* @property {Object} _currentBlockNumber Reference to a property on the _blockTracker: the number (i.e. an id) of the the current block * @property {Object} _currentBlockNumber Reference to a property on the _blockTracker: the number (i.e. an id) of the the current block
*
*/ */
export default class AccountTracker { export default class AccountTracker {
/** /**
@ -96,9 +95,8 @@ export default class AccountTracker {
* Once this AccountTracker's accounts are up to date with those referenced by the passed addresses, each * Once this AccountTracker's accounts are up to date with those referenced by the passed addresses, each
* of these accounts are given an updated balance via EthQuery. * of these accounts are given an updated balance via EthQuery.
* *
* @param {Array} address - The array of hex addresses for accounts with which this AccountTracker's accounts should be * @param {Array} addresses - The array of hex addresses for accounts with which this AccountTracker's accounts should be
* in sync * in sync
*
*/ */
syncWithAddresses(addresses) { syncWithAddresses(addresses) {
const { accounts } = this.store.getState(); const { accounts } = this.store.getState();
@ -127,7 +125,6 @@ export default class AccountTracker {
* given a balance as long this._currentBlockNumber is defined. * given a balance as long this._currentBlockNumber is defined.
* *
* @param {Array} addresses - An array of hex addresses of new accounts to track * @param {Array} addresses - An array of hex addresses of new accounts to track
*
*/ */
addAccounts(addresses) { addAccounts(addresses) {
const { accounts } = this.store.getState(); const { accounts } = this.store.getState();
@ -147,8 +144,7 @@ export default class AccountTracker {
/** /**
* Removes accounts from being tracked * Removes accounts from being tracked
* *
* @param {Array} an - array of hex addresses to stop tracking * @param {Array} addresses - An array of hex addresses to stop tracking.
*
*/ */
removeAccount(addresses) { removeAccount(addresses) {
const { accounts } = this.store.getState(); const { accounts } = this.store.getState();
@ -175,7 +171,6 @@ export default class AccountTracker {
* @private * @private
* @param {number} blockNumber - the block number to update to. * @param {number} blockNumber - the block number to update to.
* @fires 'block' The updated state, if all account updates are successful * @fires 'block' The updated state, if all account updates are successful
*
*/ */
async _updateForBlock(blockNumber) { async _updateForBlock(blockNumber) {
this._currentBlockNumber = blockNumber; this._currentBlockNumber = blockNumber;
@ -200,7 +195,6 @@ export default class AccountTracker {
* for all other networks, calls this._updateAccount for each account in this.store * for all other networks, calls this._updateAccount for each account in this.store
* *
* @returns {Promise} after all account balances updated * @returns {Promise} after all account balances updated
*
*/ */
async _updateAccounts() { async _updateAccounts() {
const { accounts } = this.store.getState(); const { accounts } = this.store.getState();
@ -247,7 +241,6 @@ export default class AccountTracker {
* @private * @private
* @param {string} address - A hex address of a the account to be updated * @param {string} address - A hex address of a the account to be updated
* @returns {Promise} after the account balance is updated * @returns {Promise} after the account balance is updated
*
*/ */
async _updateAccount(address) { async _updateAccount(address) {
// query balance // query balance
@ -265,6 +258,7 @@ export default class AccountTracker {
/** /**
* Updates current address balances from balanceChecker deployed contract instance * Updates current address balances from balanceChecker deployed contract instance
*
* @param {*} addresses * @param {*} addresses
* @param {*} deployedContractAddress * @param {*} deployedContractAddress
*/ */

@ -17,7 +17,8 @@ const fetchWithTimeout = getFetchWithTimeout(SECOND * 30);
/** /**
* Create a Wyre purchase URL. * Create a Wyre purchase URL.
* @param {String} address Ethereum destination address *
* @param {string} address - Ethereum destination address
* @returns String * @returns String
*/ */
const createWyrePurchaseUrl = async (address) => { const createWyrePurchaseUrl = async (address) => {
@ -27,7 +28,7 @@ const createWyrePurchaseUrl = async (address) => {
const response = await fetchWithTimeout(fiatOnRampUrlApi, { const response = await fetchWithTimeout(fiatOnRampUrlApi, {
method: 'GET', method: 'GET',
headers: { headers: {
'Accept': 'application/json', Accept: 'application/json',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
}); });
@ -45,7 +46,8 @@ const createWyrePurchaseUrl = async (address) => {
/** /**
* Create a Transak Checkout URL. * Create a Transak Checkout URL.
* API docs here: https://www.notion.so/Query-Parameters-9ec523df3b874ec58cef4fa3a906f238 * API docs here: https://www.notion.so/Query-Parameters-9ec523df3b874ec58cef4fa3a906f238
* @param {String} address Ethereum destination address *
* @param {string} address - Ethereum destination address
* @returns String * @returns String
*/ */
const createTransakUrl = (address) => { const createTransakUrl = (address) => {
@ -64,9 +66,9 @@ const createTransakUrl = (address) => {
* @param {Object} opts - Options required to determine the correct url * @param {Object} opts - Options required to determine the correct url
* @param {string} opts.chainId - The chainId for which to return a url * @param {string} opts.chainId - The chainId for which to return a url
* @param {string} opts.address - The address the bought ETH should be sent to. Only relevant if chainId === '0x1'. * @param {string} opts.address - The address the bought ETH should be sent to. Only relevant if chainId === '0x1'.
* @param opts.service
* @returns {string|undefined} The url at which the user can access ETH, while in the given chain. If the passed * @returns {string|undefined} The url at which the user can access ETH, while in the given chain. If the passed
* chainId does not match any of the specified cases, or if no chainId is given, returns undefined. * chainId does not match any of the specified cases, or if no chainId is given, returns undefined.
*
*/ */
export default async function getBuyEthUrl({ chainId, address, service }) { export default async function getBuyEthUrl({ chainId, address, service }) {
// default service by network if not specified // default service by network if not specified

@ -1,4 +1,3 @@
import { strict as assert } from 'assert';
import nock from 'nock'; import nock from 'nock';
import { import {
KOVAN_CHAIN_ID, KOVAN_CHAIN_ID,
@ -27,8 +26,8 @@ const KOVAN = {
chainId: KOVAN_CHAIN_ID, chainId: KOVAN_CHAIN_ID,
}; };
describe('buy-eth-url', function () { describe('buy-eth-url', () => {
it('returns Wyre url with an ETH address for Ethereum mainnet', async function () { it('returns Wyre url with an ETH address for Ethereum mainnet', async () => {
nock(SWAPS_API_V2_BASE_URL) nock(SWAPS_API_V2_BASE_URL)
.get( .get(
`/networks/1/fiatOnRampUrl?serviceName=wyre&destinationAddress=${ETH_ADDRESS}`, `/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`, 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); const wyreUrl = await getBuyEthUrl(MAINNET);
assert.equal( expect(wyreUrl).toStrictEqual(
wyreUrl,
`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`, `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(); 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); const wyreUrl = await getBuyEthUrl(MAINNET);
assert.equal( expect(wyreUrl).toStrictEqual(
wyreUrl,
`https://pay.sendwyre.com/purchase?dest=ethereum:${ETH_ADDRESS}&destCurrency=ETH&accountId=${WYRE_ACCOUNT_ID}&paymentMethod=debit-card`, `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' }); const transakUrl = await getBuyEthUrl({ ...MAINNET, service: 'transak' });
assert.equal( expect(transakUrl).toStrictEqual(
transakUrl,
`https://global.transak.com/?apiKey=${TRANSAK_API_KEY}&hostURL=https%3A%2F%2Fmetamask.io&defaultCryptoCurrency=ETH&walletAddress=${ETH_ADDRESS}`, `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); 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); 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); const kovanUrl = await getBuyEthUrl(KOVAN);
assert.equal(kovanUrl, 'https://github.com/kovan-testnet/faucet'); expect(kovanUrl).toStrictEqual('https://github.com/kovan-testnet/faucet');
}); });
}); });

@ -1,5 +1,6 @@
/** /**
* Returns error without stack trace for better UI display * Returns error without stack trace for better UI display
*
* @param {Error} err - error * @param {Error} err - error
* @returns {Error} Error with clean stack trace. * @returns {Error} Error with clean stack trace.
*/ */

@ -1,34 +1,36 @@
import { strict as assert } from 'assert';
import cleanErrorStack from './cleanErrorStack'; import cleanErrorStack from './cleanErrorStack';
describe('Clean Error Stack', function () { describe('Clean Error Stack', () => {
const testMessage = 'Test Message'; const testMessage = 'Test Message';
const testError = new Error(testMessage); const testError = new Error(testMessage);
const undefinedErrorName = new Error(testMessage); const undefinedErrorName = new Error(testMessage);
const blankErrorName = new Error(testMessage); const blankErrorName = new Error(testMessage);
const blankMsgError = new Error(); const blankMsgError = new Error();
beforeEach(function () { beforeEach(() => {
undefinedErrorName.name = undefined; undefinedErrorName.name = undefined;
blankErrorName.name = ''; blankErrorName.name = '';
}); });
it('tests error with message', function () { it('tests error with message', () => {
assert.equal(cleanErrorStack(testError).toString(), 'Error: Test Message'); expect(cleanErrorStack(testError).toString()).toStrictEqual(
'Error: Test Message',
);
}); });
it('tests error with undefined name', function () { it('tests error with undefined name', () => {
assert.equal( expect(cleanErrorStack(undefinedErrorName).toString()).toStrictEqual(
cleanErrorStack(undefinedErrorName).toString(),
'Error: Test Message', 'Error: Test Message',
); );
}); });
it('tests error with blank name', function () { it('tests error with blank name', () => {
assert.equal(cleanErrorStack(blankErrorName).toString(), 'Test Message'); expect(cleanErrorStack(blankErrorName).toString()).toStrictEqual(
'Test Message',
);
}); });
it('tests error with blank message', function () { it('tests error with blank message', () => {
assert.equal(cleanErrorStack(blankMsgError).toString(), 'Error'); expect(cleanErrorStack(blankMsgError).toString()).toStrictEqual('Error');
}); });
}); });

@ -2,6 +2,7 @@ import log from 'loglevel';
/** /**
* Returns a middleware that logs RPC activity * Returns a middleware that logs RPC activity
*
* @param {{ origin: string }} opts - The middleware options * @param {{ origin: string }} opts - The middleware options
* @returns {Function} * @returns {Function}
*/ */

@ -1,7 +1,7 @@
import { ethErrors, serializeError } from 'eth-rpc-errors'; import { ethErrors, serializeError } from 'eth-rpc-errors';
const createMetaRPCHandler = (api, outStream) => { const createMetaRPCHandler = (api, outStream) => {
return (data) => { return async (data) => {
if (outStream._writableState.ended) { if (outStream._writableState.ended) {
return; return;
} }
@ -15,14 +15,26 @@ const createMetaRPCHandler = (api, outStream) => {
}); });
return; return;
} }
api[data.method](...data.params, (err, result) => {
let result;
let error;
try {
result = await api[data.method](...data.params);
} catch (err) {
error = err;
}
if (outStream._writableState.ended) { if (outStream._writableState.ended) {
if (error) {
console.error(error);
}
return; return;
} }
if (err) {
if (error) {
outStream.write({ outStream.write({
jsonrpc: '2.0', jsonrpc: '2.0',
error: serializeError(err, { shouldIncludeStack: true }), error: serializeError(error, { shouldIncludeStack: true }),
id: data.id, id: data.id,
}); });
} else { } else {
@ -32,7 +44,6 @@ const createMetaRPCHandler = (api, outStream) => {
id: data.id, id: data.id,
}); });
} }
});
}; };
}; };

@ -1,13 +1,11 @@
import { strict as assert } from 'assert';
import { obj as createThoughStream } from 'through2'; import { obj as createThoughStream } from 'through2';
import createMetaRPCHandler from './createMetaRPCHandler'; import createMetaRPCHandler from './createMetaRPCHandler';
describe('createMetaRPCHandler', function () { describe('createMetaRPCHandler', () => {
it('can call the api when handler receives a JSON-RPC request', function (done) { it('can call the api when handler receives a JSON-RPC request', () => {
const api = { const api = {
foo: (param1) => { foo: (param1) => {
assert.strictEqual(param1, 'bar'); expect(param1).toStrictEqual('bar');
done();
}, },
}; };
const streamTest = createThoughStream(); const streamTest = createThoughStream();
@ -18,11 +16,31 @@ describe('createMetaRPCHandler', function () {
params: ['bar'], 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 = { const api = {
foo: (param1, cb) => { foo: (param1) => {
assert.strictEqual(param1, 'bar'); expect(param1).toStrictEqual('bar');
cb(null, 'foobarbaz'); return 'foobarbaz';
},
};
const streamTest = createThoughStream();
const handler = createMetaRPCHandler(api, streamTest);
handler({
id: 1,
method: 'foo',
params: ['bar'],
});
streamTest.on('data', (data) => {
expect(data.result).toStrictEqual('foobarbaz');
streamTest.end();
});
});
it('can write an async response to the outstream', () => {
const api = {
foo: async (param1) => {
expect(param1).toStrictEqual('bar');
await new Promise((resolve) => setTimeout(() => resolve(), 100));
return 'foobarbaz';
}, },
}; };
const streamTest = createThoughStream(); const streamTest = createThoughStream();
@ -33,16 +51,15 @@ describe('createMetaRPCHandler', function () {
params: ['bar'], params: ['bar'],
}); });
streamTest.on('data', (data) => { streamTest.on('data', (data) => {
assert.strictEqual(data.result, 'foobarbaz'); expect(data.result).toStrictEqual('foobarbaz');
streamTest.end(); streamTest.end();
done();
}); });
}); });
it('can write the error to the outstream when api callback is called with an error', function (done) { it('can write the error to the outstream when method throws an error', () => {
const api = { const api = {
foo: (param1, cb) => { foo: (param1) => {
assert.strictEqual(param1, 'bar'); expect(param1).toStrictEqual('bar');
cb(new Error('foo-error')); throw new Error('foo-error');
}, },
}; };
const streamTest = createThoughStream(); const streamTest = createThoughStream();
@ -53,45 +70,46 @@ describe('createMetaRPCHandler', function () {
params: ['bar'], params: ['bar'],
}); });
streamTest.on('data', (data) => { streamTest.on('data', (data) => {
assert.strictEqual(data.error.message, 'foo-error'); expect(data.error.message).toStrictEqual('foo-error');
streamTest.end(); streamTest.end();
done();
}); });
}); });
it('can not throw an error for writing an error after end', function (done) { it('can not throw an error for writing an error after end', () => {
const api = { const api = {
foo: (param1, cb) => { foo: (param1) => {
assert.strictEqual(param1, 'bar'); expect(param1).toStrictEqual('bar');
cb(new Error('foo-error')); throw new Error('foo-error');
}, },
}; };
const streamTest = createThoughStream(); const streamTest = createThoughStream();
const handler = createMetaRPCHandler(api, streamTest); const handler = createMetaRPCHandler(api, streamTest);
streamTest.end(); streamTest.end();
expect(() => {
handler({ handler({
id: 1, id: 1,
method: 'foo', method: 'foo',
params: ['bar'], params: ['bar'],
}); });
done(); }).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 = { const api = {
foo: (param1, cb) => { foo: (param1) => {
assert.strictEqual(param1, 'bar'); expect(param1).toStrictEqual('bar');
cb(undefined, { return {
foo: 'bar', foo: 'bar',
}); };
}, },
}; };
const streamTest = createThoughStream(); const streamTest = createThoughStream();
const handler = createMetaRPCHandler(api, streamTest); const handler = createMetaRPCHandler(api, streamTest);
streamTest.end(); streamTest.end();
expect(() => {
handler({ handler({
id: 1, id: 1,
method: 'foo', method: 'foo',
params: ['bar'], params: ['bar'],
}); });
done(); }).not.toThrow();
}); });
}); });

@ -3,6 +3,7 @@ import extension from 'extensionizer';
/** /**
* Returns a middleware that intercepts `wallet_registerOnboarding` messages * Returns a middleware that intercepts `wallet_registerOnboarding` messages
*
* @param {{ location: string, registerOnboarding: Function }} opts - The middleware options * @param {{ location: string, registerOnboarding: Function }} opts - The middleware options
* @returns {(req: any, res: any, next: Function, end: Function) => void} * @returns {(req: any, res: any, next: Function, end: Function) => void}
*/ */

@ -1,5 +1,6 @@
/** /**
* Returns a middleware that appends the DApp origin to request * Returns a middleware that appends the DApp origin to request
*
* @param {{ origin: string }} opts - The middleware options * @param {{ origin: string }} opts - The middleware options
* @returns {Function} * @returns {Function}
*/ */

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

Loading…
Cancel
Save