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

feature/default_network_editable
Thomas Huang 3 years ago
commit 5002b341ce
  1. 38
      .circleci/config.yml
  2. 4
      .circleci/scripts/chrome-install.sh
  3. 2
      .circleci/scripts/yarn-audit.sh
  4. 8
      .eslintrc.js
  5. 4
      .metamaskrc.dist
  6. 9
      .mocharc.js
  7. 5
      .mocharc.lax.js
  8. 2
      .storybook/initial-states/approval-screens/token-approval.js
  9. 6
      .storybook/manager.js
  10. 14
      .storybook/metamask-storybook-theme.js
  11. 27
      .storybook/preview-head.html
  12. 11
      .storybook/preview.js
  13. 25
      .storybook/test-data.js
  14. 4
      README.md
  15. 3
      app/_locales/am/messages.json
  16. 3
      app/_locales/ar/messages.json
  17. 3
      app/_locales/bg/messages.json
  18. 3
      app/_locales/bn/messages.json
  19. 3
      app/_locales/ca/messages.json
  20. 3
      app/_locales/da/messages.json
  21. 3
      app/_locales/de/messages.json
  22. 3
      app/_locales/el/messages.json
  23. 400
      app/_locales/en/messages.json
  24. 46
      app/_locales/es/messages.json
  25. 46
      app/_locales/es_419/messages.json
  26. 3
      app/_locales/et/messages.json
  27. 3
      app/_locales/fa/messages.json
  28. 3
      app/_locales/fi/messages.json
  29. 3
      app/_locales/fil/messages.json
  30. 3
      app/_locales/fr/messages.json
  31. 3
      app/_locales/he/messages.json
  32. 46
      app/_locales/hi/messages.json
  33. 3
      app/_locales/hr/messages.json
  34. 3
      app/_locales/hu/messages.json
  35. 46
      app/_locales/id/messages.json
  36. 46
      app/_locales/it/messages.json
  37. 46
      app/_locales/ja/messages.json
  38. 3
      app/_locales/kn/messages.json
  39. 46
      app/_locales/ko/messages.json
  40. 3
      app/_locales/lt/messages.json
  41. 3
      app/_locales/lv/messages.json
  42. 3
      app/_locales/ms/messages.json
  43. 3
      app/_locales/no/messages.json
  44. 46
      app/_locales/ph/messages.json
  45. 3
      app/_locales/pl/messages.json
  46. 46
      app/_locales/pt_BR/messages.json
  47. 3
      app/_locales/ro/messages.json
  48. 46
      app/_locales/ru/messages.json
  49. 3
      app/_locales/sk/messages.json
  50. 3
      app/_locales/sl/messages.json
  51. 3
      app/_locales/sr/messages.json
  52. 3
      app/_locales/sv/messages.json
  53. 3
      app/_locales/sw/messages.json
  54. 36
      app/_locales/tl/messages.json
  55. 3
      app/_locales/uk/messages.json
  56. 46
      app/_locales/vi/messages.json
  57. 94
      app/_locales/zh_CN/messages.json
  58. 3
      app/_locales/zh_TW/messages.json
  59. 1
      app/images/curve-high.svg
  60. 1
      app/images/curve-low.svg
  61. 1
      app/images/curve-medium.svg
  62. 1
      app/images/high-arrow.svg
  63. 26
      app/images/icons/collapse.svg
  64. 28
      app/images/icons/expand.svg
  65. 1
      app/images/low-arrow.svg
  66. 56
      app/images/qrcode-wallet-demo.svg
  67. 11
      app/images/qrcode-wallet-logo.svg
  68. 11
      app/scripts/background.js
  69. 15
      app/scripts/controllers/app-state.js
  70. 2
      app/scripts/controllers/network/createJsonRpcClient.js
  71. 2
      app/scripts/controllers/network/network.js
  72. 71
      app/scripts/controllers/permissions/background-api.js
  73. 181
      app/scripts/controllers/permissions/background-api.test.js
  74. 39
      app/scripts/controllers/permissions/caveat-mutators.js
  75. 32
      app/scripts/controllers/permissions/caveat-mutators.test.js
  76. 78
      app/scripts/controllers/permissions/enums.js
  77. 722
      app/scripts/controllers/permissions/index.js
  78. 39
      app/scripts/controllers/permissions/permission-log.js
  79. 353
      app/scripts/controllers/permissions/permission-log.test.js
  80. 1562
      app/scripts/controllers/permissions/permissions-controller.test.js
  81. 950
      app/scripts/controllers/permissions/permissions-middleware.test.js
  82. 112
      app/scripts/controllers/permissions/permissionsMethodMiddleware.js
  83. 174
      app/scripts/controllers/permissions/restricted-methods.test.js
  84. 40
      app/scripts/controllers/permissions/restrictedMethods.js
  85. 84
      app/scripts/controllers/permissions/selectors.js
  86. 116
      app/scripts/controllers/permissions/selectors.test.js
  87. 258
      app/scripts/controllers/permissions/specifications.js
  88. 340
      app/scripts/controllers/permissions/specifications.test.js
  89. 31
      app/scripts/controllers/preferences.js
  90. 36
      app/scripts/controllers/preferences.test.js
  91. 23
      app/scripts/controllers/swaps.js
  92. 4
      app/scripts/controllers/swaps.test.js
  93. 23
      app/scripts/controllers/transactions/index.js
  94. 1
      app/scripts/controllers/transactions/lib/tx-state-history-helpers.js
  95. 15
      app/scripts/controllers/transactions/pending-tx-tracker.js
  96. 54
      app/scripts/lib/ComposableObservableStore.test.js
  97. 30
      app/scripts/lib/buy-eth-url.test.js
  98. 26
      app/scripts/lib/cleanErrorStack.test.js
  99. 56
      app/scripts/lib/createMetaRPCHandler.test.js
  100. 25
      app/scripts/lib/message-manager.js
  101. Some files were not shown because too many files have changed in this diff Show More

@ -12,7 +12,7 @@ executors:
NODE_OPTIONS: --max_old_space_size=2048
shellcheck:
docker:
- image: koalaman/shellcheck-alpine@sha256:35882cba254810c7de458528011e935ba2c4f3ebcb224275dfa7ebfa930ef294
- image: koalaman/shellcheck-alpine@sha256:dfaf08fab58c158549d3be64fb101c626abc5f16f341b569092577ae207db199
workflows:
test_and_release:
@ -25,10 +25,15 @@ workflows:
only:
- /^Version-v(\d+)[.](\d+)[.](\d+)/
- prep-deps
- test-deps-audit
- test-deps-audit:
requires:
- prep-deps
- test-deps-depcheck:
requires:
- prep-deps
- test-yarn-dedupe:
requires:
- prep-deps
- validate-lavamoat-config:
filters:
branches:
@ -51,9 +56,12 @@ workflows:
- prep-build-test-metrics:
requires:
- prep-deps
- prep-build-storybook:
- test-storybook:
requires:
- prep-deps
- prep-build-storybook:
requires:
- test-storybook
- test-lint:
requires:
- prep-deps
@ -330,6 +338,26 @@ jobs:
root: .
paths:
- storybook-build
test-storybook:
executor: node-browsers
steps:
- checkout
- attach_workspace:
at: .
- run:
name: Test Storybook
command: yarn storybook:test
test-yarn-dedupe:
executor: node-browsers
steps:
- checkout
- attach_workspace:
at: .
- run:
name: Detect yarn lock deduplications
command: yarn yarn-deduplicate && git diff --exit-code yarn.lock
test-lint:
executor: node-browsers
@ -632,8 +660,8 @@ jobs:
- attach_workspace:
at: .
- run:
name: test:coverage
command: yarn test:coverage
name: test:coverage:mocha
command: yarn test:coverage:mocha
- run:
name: test:coverage:jest
command: yarn test:coverage:jest

@ -5,12 +5,12 @@ set -u
set -o pipefail
# To get the latest version, see <https://www.ubuntuupdates.org/ppa/google_chrome?dist=stable>
CHROME_VERSION='95.0.4638.69-1'
CHROME_VERSION='96.0.4664.45-1'
CHROME_BINARY="google-chrome-stable_${CHROME_VERSION}_amd64.deb"
CHROME_BINARY_URL="https://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/${CHROME_BINARY}"
# To retrieve this checksum, run the `wget` and `shasum` commands below
CHROME_BINARY_SHA512SUM='f07d16ec0a41120c40064d030e9e5240ed740b9b24c50eaede7b9bfd9a9678821c0252b40bfcd57e933a708b08d761482c3be5b3006eee605c41f5dc9e21f456'
CHROME_BINARY_SHA512SUM='98433b003d43627e221faad212cba3df42d7f3d6e31894b1e14a9058069cbcd2bd3c83b3c59ecc1733dab11e36b181fa1d89e33b841a21fd53a8e82bbddc39aa'
wget -O "${CHROME_BINARY}" -t 5 "${CHROME_BINARY_URL}"

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

@ -22,6 +22,7 @@ module.exports = {
ignorePatterns: [
'!.eslintrc.js',
'!.mocharc.js',
'node_modules/**',
'dist/**',
'builds/**',
@ -136,8 +137,10 @@ module.exports = {
'ui/__mocks__/*.js',
'shared/**/*.test.js',
'development/**/*.test.js',
'app/scripts/lib/**/*.test.js',
'app/scripts/migrations/*.test.js',
'app/scripts/platforms/*.test.js',
'app/scripts/controllers/permissions/*.test.js',
],
extends: ['@metamask/eslint-config-mocha'],
rules: {
@ -160,8 +163,10 @@ module.exports = {
'ui/__mocks__/*.js',
'shared/**/*.test.js',
'development/**/*.test.js',
'app/scripts/lib/**/*.test.js',
'app/scripts/migrations/*.test.js',
'app/scripts/platforms/*.test.js',
'app/scripts/controllers/permissions/*.test.js',
],
extends: ['@metamask/eslint-config-jest'],
rules: {
@ -184,7 +189,9 @@ module.exports = {
{
files: [
'.eslintrc.js',
'.mocharc.js',
'babel.config.js',
'jest.config.js',
'nyc.config.js',
'stylelint.config.js',
'app/scripts/lockdown-run.js',
@ -195,7 +202,6 @@ module.exports = {
'test/setup.js',
'test/helpers/protect-intrinsics-helpers.js',
'test/lib/wait-until-called.js',
'jest.config.js',
],
parserOptions: {
sourceType: 'script',

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

@ -1,7 +1,12 @@
module.exports = {
// TODO: Remove the `exit` setting, it can hide broken tests.
exit: true,
ignore: ['./app/scripts/migrations/*.test.js', './app/scripts/platforms/*.test.js'],
ignore: [
'./app/scripts/lib/**/*.test.js',
'./app/scripts/migrations/*.test.js',
'./app/scripts/platforms/*.test.js',
'./app/scripts/controllers/permissions/*.test.js',
],
recursive: true,
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']
});

@ -46,7 +46,7 @@ export const currentNetworkTxListSample = {
]
}
export const domainMetadata = {
export const subjectMetadata = {
"https://metamask.github.io": {
"name": "E2E Test Dapp",
"icon": "https://metamask.github.io/test-dapp/metamask-fox.svg",

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

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

@ -1,8 +1,5 @@
# MetaMask Browser Extension
Hey! We are hiring JavaScript Engineers! [Apply here](https://boards.greenhouse.io/consensys/jobs/2572388)!
---
You can find the latest version of MetaMask on [our official website](https://metamask.io/). For help using MetaMask, visit our [User Support Site](https://metamask.zendesk.com/hc/en-us).
For [general questions](https://community.metamask.io/c/learn/26), [feature requests](https://community.metamask.io/c/feature-requests-ideas/13), or [developer questions](https://community.metamask.io/c/developer-questions/11), visit our [Community Forum](https://community.metamask.io/).
@ -64,6 +61,7 @@ Whenever you change dependencies (adding, removing, or updating, either in `pack
* `yarn.lock`:
* Run `yarn setup` again after your changes to ensure `yarn.lock` has been properly updated.
* Run `yarn yarn-deduplicate` to remove duplicate dependencies from the lockfile.
* The `allow-scripts` configuration in `package.json`
* Run `yarn allow-scripts auto` to update the `allow-scripts` configuration automatically. This config determines whether the package's install/postinstall scripts are allowed to run. Review each new package to determine whether the install script needs to run or not, testing if necessary.
* Unfortunately, `yarn allow-scripts auto` will behave inconsistently on different platforms. macOS and Windows users may see extraneous changes relating to optional dependencies.

@ -218,9 +218,6 @@
"copiedExclamation": {
"message": "ተቀድቷል"
},
"copiedTransactionId": {
"message": "የተቀዳ የግብይት መለያ ቁጥር"
},
"copyAddress": {
"message": "አድራሻን ወደ ቅንጥብ ሰሌዳ ቅዳ"
},

@ -218,9 +218,6 @@
"copiedExclamation": {
"message": "تم النسخ."
},
"copiedTransactionId": {
"message": "تم نسخ معرف المعاملة"
},
"copyAddress": {
"message": "نسخ العنوان إلى الحافظة"
},

@ -218,9 +218,6 @@
"copiedExclamation": {
"message": "Копирано!"
},
"copiedTransactionId": {
"message": "Копиран идентификационен номер на транзакцията"
},
"copyAddress": {
"message": "Копирайте адреса в клипборда"
},

@ -218,9 +218,6 @@
"copiedExclamation": {
"message": "কপি কর হয়!"
},
"copiedTransactionId": {
"message": "কপি করনদর আইডি"
},
"copyAddress": {
"message": "কিপবি কপি করন"
},

@ -215,9 +215,6 @@
"copiedExclamation": {
"message": "S'ha copiat!"
},
"copiedTransactionId": {
"message": "ID de transacció copiada"
},
"copyAddress": {
"message": "Copiar adreça al porta-retalls"
},

@ -218,9 +218,6 @@
"copiedExclamation": {
"message": "Kopieret!"
},
"copiedTransactionId": {
"message": "Kopieret transaktions-id"
},
"copyAddress": {
"message": "Kopier adresse til udklipsholder"
},

@ -209,9 +209,6 @@
"copiedExclamation": {
"message": "Kopiert!"
},
"copiedTransactionId": {
"message": "Transaktions-ID kopiert"
},
"copyAddress": {
"message": "Adresse in die Zwischenablage kopieren"
},

@ -215,9 +215,6 @@
"copiedExclamation": {
"message": "Έγινε αντιγραφή!"
},
"copiedTransactionId": {
"message": "Αντιγράφηκε το Αναγνωριστικό Συναλλαγής"
},
"copyAddress": {
"message": "Αντιγράψτε τη διεύθυνση στο πρόχειρο"
},

@ -1,4 +1,49 @@
{
"QRHardwareInvalidTransactionTitle": {
"message": "Error"
},
"QRHardwareMismatchedSignId": {
"message": "Incongruent transaction data. Please check the transaction details."
},
"QRHardwarePubkeyAccountOutOfRange": {
"message": "No more accounts. If you would like to access another account unlisted below, please reconnect your hardware wallet and select it."
},
"QRHardwareScanInstructions": {
"message": "Place the QR code in front of your camera. The screen is blurred, but it will not affect the reading."
},
"QRHardwareSignRequestCancel": {
"message": "Reject"
},
"QRHardwareSignRequestDescription": {
"message": "After you’ve signed with your wallet, click on 'Get Signature' to receive the signature"
},
"QRHardwareSignRequestGetSignature": {
"message": "Get Signature"
},
"QRHardwareSignRequestSubtitle": {
"message": "Scan the QR code with your wallet"
},
"QRHardwareSignRequestTitle": {
"message": "Request Signature"
},
"QRHardwareUnknownQRCodeTitle": {
"message": "Error"
},
"QRHardwareUnknownWalletQRCode": {
"message": "Invalid QR code. Please scan the sync QR code of the hardware wallet."
},
"QRHardwareWalletImporterTitle": {
"message": "Scan QR Code"
},
"QRHardwareWalletSteps1Description": {
"message": "Connect an airgapped hardware wallet that communicates through QR-codes. Officially supported airgapped hardware wallets include:"
},
"QRHardwareWalletSteps1Title": {
"message": "QR-based HW Wallet"
},
"QRHardwareWalletSteps2Description": {
"message": "AirGap Vault & Ngrave (Coming Soon)"
},
"about": {
"message": "About"
},
@ -41,7 +86,7 @@
"message": "Activity"
},
"activityLog": {
"message": "activity log"
"message": "Activity log"
},
"add": {
"message": "Add"
@ -49,6 +94,9 @@
"addANetwork": {
"message": "Add a network"
},
"addANickname": {
"message": "Add a nickname"
},
"addAcquiredTokens": {
"message": "Add the tokens you've acquired using MetaMask"
},
@ -85,9 +133,15 @@
"addFriendsAndAddresses": {
"message": "Add friends and addresses you trust"
},
"addMemo": {
"message": "Add memo"
},
"addNFT": {
"message": "Add NFT"
},
"addNFTLowerCase": {
"message": "add NFT"
},
"addNetwork": {
"message": "Add Network"
},
@ -112,12 +166,21 @@
"advanced": {
"message": "Advanced"
},
"advancedBaseGasFeeToolTip": {
"message": "When your transaction gets included in the block, any difference between your max base fee and the actual base fee will be refunded. Total amount is calculated as max base fee (in GWEI) * gas limit."
},
"advancedGasFeeModalTitle": {
"message": "Advanced gas fee"
},
"advancedGasPriceTitle": {
"message": "Gas price"
},
"advancedOptions": {
"message": "Advanced Options"
},
"advancedPriorityFeeToolTip": {
"message": "Priority fee (aka “miner tip”) goes directly to miners and incentivizes them to prioritize your transaction."
},
"advancedSettingsDescription": {
"message": "Access developer features, download State Logs, Reset Account, setup test networks and custom RPC"
},
@ -257,6 +320,9 @@
"balanceOutdated": {
"message": "Balance may be outdated"
},
"baseFee": {
"message": "Base fee"
},
"basic": {
"message": "Basic"
},
@ -291,10 +357,6 @@
"message": "Swap",
"description": "This is used with viewOnEtherscan e.g View Swap on Etherscan"
},
"blockExplorerTransactionAction": {
"message": "Transaction",
"description": "This is used with viewOnCustomBlockExplorer and viewOnEtherscan e.g View Transaction on Etherscan"
},
"blockExplorerUrl": {
"message": "Block Explorer URL"
},
@ -317,6 +379,9 @@
"builtAroundTheWorld": {
"message": "MetaMask is designed and built around the world."
},
"busy": {
"message": "Busy"
},
"buy": {
"message": "Buy"
},
@ -347,6 +412,9 @@
"cancelPopoverTitle": {
"message": "Cancel transaction"
},
"cancelSpeedUp": {
"message": "cancel or speed up a tranaction."
},
"cancellationGasFee": {
"message": "Cancellation Gas Fee"
},
@ -512,15 +580,15 @@
"copiedExclamation": {
"message": "Copied!"
},
"copiedTransactionId": {
"message": "Copied Transaction ID"
},
"copyAddress": {
"message": "Copy address to clipboard"
},
"copyPrivateKey": {
"message": "This is your private key (click to copy)"
},
"copyRawTransactionData": {
"message": "Copy raw transaction data"
},
"copyToClipboard": {
"message": "Copy to clipboard"
},
@ -560,6 +628,9 @@
"currentLanguage": {
"message": "Current Language"
},
"currentTitle": {
"message": "Current:"
},
"currentlyUnavailable": {
"message": "Unavailable on this network"
},
@ -569,6 +640,10 @@
"customGas": {
"message": "Customize Gas"
},
"customGasSettingToolTipMessage": {
"message": "Use $1 to customise the gas price. This can be confusing if you aren’t familiar. Interact at your own risk.",
"description": "$1 is key 'advanced' (text: 'Advanced') separated here so that it can be passed in with bold fontweight"
},
"customGasSubTitle": {
"message": "Increasing fee may decrease processing times, but it is not guaranteed."
},
@ -581,6 +656,13 @@
"dappSuggested": {
"message": "Site suggested"
},
"dappSuggestedGasSettingToolTipMessage": {
"message": "$1 has suggested this price.",
"description": "$1 is url for the dapp that has suggested gas settings"
},
"dappSuggestedShortLabel": {
"message": "Site"
},
"dappSuggestedTooltip": {
"message": "$1 has recommended this price.",
"description": "$1 represents the Dapp's origin"
@ -591,6 +673,9 @@
"dataBackupFoundInfo": {
"message": "Some of your account data was backed up during a previous installation of MetaMask. This could include your settings, contacts, and tokens. Would you like to restore this data now?"
},
"dataHex": {
"message": "Hex"
},
"decimal": {
"message": "Token Decimal"
},
@ -689,6 +774,12 @@
"edit": {
"message": "Edit"
},
"editANickname": {
"message": "Edit nickname"
},
"editAddressNickname": {
"message": "Edit address nickname"
},
"editContact": {
"message": "Edit Contact"
},
@ -710,18 +801,34 @@
"editGasEducationModalTitle": {
"message": "How to choose?"
},
"editGasFeeModalTitle": {
"message": "Edit gas fee"
},
"editGasHigh": {
"message": "High"
},
"editGasLimitOutOfBounds": {
"message": "Gas limit must be at least $1"
},
"editGasLimitOutOfBoundsV2": {
"message": "Gas limit must be greater than $1 and less than $2",
"description": "$1 is the minimum limit for gas and $2 is the maximum limit"
},
"editGasLimitTooltip": {
"message": "Gas limit is the maximum units of gas you are willing to use. Units of gas are a multiplier to “Max priority fee” and “Max fee”."
},
"editGasLow": {
"message": "Low"
},
"editGasMaxBaseFeeHigh": {
"message": "Max base fee is higher than necessary"
},
"editGasMaxBaseFeeImbalance": {
"message": "Max base fee cannot be lower than priority fee"
},
"editGasMaxBaseFeeLow": {
"message": "Max base fee is low for current network conditions"
},
"editGasMaxFeeHigh": {
"message": "Max fee is higher than necessary"
},
@ -737,12 +844,21 @@
"editGasMaxPriorityFeeBelowMinimum": {
"message": "Max priority fee must be greater than 0 GWEI"
},
"editGasMaxPriorityFeeBelowMinimumV2": {
"message": "Priority fee must be at least 1 GWEI"
},
"editGasMaxPriorityFeeHigh": {
"message": "Max priority fee is higher than necessary. You may pay more than needed."
},
"editGasMaxPriorityFeeHighV2": {
"message": "Priority fee is higher than necessary. You may pay more than needed"
},
"editGasMaxPriorityFeeLow": {
"message": "Max priority fee is low for current network conditions"
},
"editGasMaxPriorityFeeLowV2": {
"message": "Priority fee is low for current network conditions"
},
"editGasMaxPriorityFeeTooltip": {
"message": "Max priority fee (aka “miner tip”) goes directly to miners and incentivizes them to prioritize your transaction. You’ll most often pay your max setting"
},
@ -755,21 +871,12 @@
"editGasPriceTooltip": {
"message": "This network requires a “Gas price” field when submitting a transaction. Gas price is the amount you will pay pay per unit of gas."
},
"editGasSubTextAmount": {
"message": "$1 $2",
"description": "$1 will be passed the editGasSubTextAmountLabel and $2 will be passed the amount in either cryptocurrency or fiat"
},
"editGasSubTextAmountLabel": {
"message": "Max amount:",
"description": "This is meant to be used as the $1 substitution editGasSubTextAmount"
},
"editGasSubTextFee": {
"message": "$1 $2",
"description": "$1 will be passed the editGasSubTextFeeLabel and $2 will be passed the fee amount in either cryptocurrency or fiat"
},
"editGasSubTextFeeLabel": {
"message": "Max fee:",
"description": "$1 represents a dollar amount"
"message": "Max fee:"
},
"editGasTitle": {
"message": "Edit priority"
@ -783,6 +890,12 @@
"editGasTooLowWarningTooltip": {
"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": {
"message": "Edit Nonce"
},
@ -795,6 +908,16 @@
"enableFromSettings": {
"message": " Enable it from Settings."
},
"enableOpenSeaAPI": {
"message": "Enable OpenSea API"
},
"enableOpenSeaAPIDescription": {
"message": "Use OpenSea's API to fetch NFT data. NFT auto-detection relies on OpenSea's API, and will not be available when this is turned off."
},
"enableToken": {
"message": "enable $1",
"description": "$1 is a token symbol, e.g. ETH"
},
"encryptionPublicKeyNotice": {
"message": "$1 would like your public encryption key. By consenting, this site will be able to compose encrypted messages to you.",
"description": "$1 is the web3 site name"
@ -903,7 +1026,7 @@
"message": "Backup gas price is provided as the main gas estimation service is unavailable right now."
},
"eth_accounts": {
"message": "View the addresses of your permitted accounts (required)",
"message": "See address, account balance, activity and initiate transactions",
"description": "The description for the `eth_accounts` permission"
},
"ethereumPublicAddress": {
@ -966,6 +1089,33 @@
"message": "File import not working? Click here!",
"description": "Helps user import their account from a JSON file"
},
"flaskExperimentalText1": {
"message": "Using Flask can greatly increase your risk of fund loss:"
},
"flaskExperimentalText2": {
"message": "if you use it to install non-trustworthy Snaps"
},
"flaskExperimentalText3": {
"message": "if you do not review confirmations before approving changes"
},
"flaskExperimentalText4": {
"message": "if you interact with unfamiliar smart contracts"
},
"flaskExperimentalText5": {
"message": "Using Flask gives you much greater discretion in using the power of MetaMask, and that discretion is yours. Do you accept these risks as well as extra responsibility for your wallet's safety?"
},
"flaskSnapSettingsCardButtonCta": {
"message": "See details",
"description": "Call to action a user can take to see more information about the Snap that is installed"
},
"flaskSnapSettingsCardDateAddedOn": {
"message": "Added on",
"description": "Start of the sentence describing when and where snap was added"
},
"flaskSnapSettingsCardFrom": {
"message": "from",
"description": "Part of the sentence describing when and where snap was added"
},
"followUsOnTwitter": {
"message": "Follow us on Twitter"
},
@ -1011,6 +1161,12 @@
"message": "Gas limit must be at least $1",
"description": "$1 is the custom gas limit, in decimal."
},
"gasLimitV2": {
"message": "Gas limit"
},
"gasOption": {
"message": "Gas option"
},
"gasPrice": {
"message": "Gas Price (GWEI)"
},
@ -1029,13 +1185,18 @@
"gasPriceInfoTooltipContent": {
"message": "Gas price specifies the amount of Ether you are willing to pay for each unit of gas."
},
"gasPriceLabel": {
"message": "Gas price"
"gasTimingHoursShort": {
"message": "$1 hrs",
"description": "$1 represents a number of hours"
},
"gasTimingMinutes": {
"message": "$1 minutes",
"description": "$1 represents a number of minutes"
},
"gasTimingMinutesShort": {
"message": "$1 min",
"description": "$1 represents a number of minutes"
},
"gasTimingNegative": {
"message": "Maybe in $1",
"description": "$1 represents an amount of time"
@ -1048,6 +1209,10 @@
"message": "$1 seconds",
"description": "$1 represents a number of seconds"
},
"gasTimingSecondsShort": {
"message": "$1 sec",
"description": "$1 represents a number of seconds"
},
"gasTimingVeryPositive": {
"message": "Very likely in < $1",
"description": "$1 represents an amount of time"
@ -1133,6 +1298,16 @@
"high": {
"message": "Aggressive"
},
"highGasSettingToolTipDialog": {
"message": "High probability, even in volatile markets"
},
"highGasSettingToolTipMessage": {
"message": "Use $1 to cover surges in network traffic due to things like popular NFT drops.",
"description": "$1 is key 'high' (text: 'Aggressive') separated here so that it can be passed in with bold fontweight"
},
"highLowercase": {
"message": "high"
},
"history": {
"message": "History"
},
@ -1276,6 +1451,12 @@
"message": "JSON File",
"description": "format for importing an account"
},
"keystone": {
"message": "Keystone"
},
"keystoneTutorial": {
"message": " (Tutorials)"
},
"knownAddressRecipient": {
"message": "Known contract address."
},
@ -1291,8 +1472,15 @@
"layer1Fees": {
"message": "Layer 1 fees"
},
"learmMoreAboutGas": {
"message": "Want to $1 about gas?"
},
"learnCancelSpeeedup": {
"message": "Learn how to $1",
"description": "$1 is link to cancel or speed up transactions"
},
"learnMore": {
"message": "Learn more"
"message": "learn more"
},
"learnScamRisk": {
"message": "scams and security risks."
@ -1375,6 +1563,13 @@
"low": {
"message": "Low"
},
"lowGasSettingToolTipMessage": {
"message": "Use $1 to wait for a cheaper price. Time estimates are much less accurate as prices are somewhat unpredicible.",
"description": "$1 is key 'low' separated here so that it can be passed in with bold fontweight"
},
"lowLowercase": {
"message": "low"
},
"lowPriorityMessage": {
"message": "Future transactions will queue after this one. This price was last seen was some time ago."
},
@ -1403,6 +1598,10 @@
"medium": {
"message": "Market"
},
"mediumGasSettingToolTipMessage": {
"message": "Use $1 for fast processing at current market price.",
"description": "$1 is key 'medium' (text: 'Market') separated here so that it can be passed in with bold fontweight"
},
"memo": {
"message": "memo"
},
@ -1496,6 +1695,9 @@
"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."
},
"multiplier": {
"message": "multiplier"
},
"mustSelectOne": {
"message": "Must select at least 1 token."
},
@ -1558,6 +1760,20 @@
"networkSettingsDescription": {
"message": "Add and edit custom RPC networks"
},
"networkStatus": {
"message": "Network status"
},
"networkStatusBaseFeeTooltip": {
"message": "The base fee is set by the network and changes every 13-14 seconds. Our $1 and $2 options account for sudden increases.",
"description": "$1 and $2 are bold text for Medium and Aggressive respectively."
},
"networkStatusPriorityFeeTooltip": {
"message": "Range of priority fees (aka “miner tip”). This goes to miners and incentivizes them to prioritize your transaction."
},
"networkStatusStabilityFeeTooltip": {
"message": "Gas fees are $1 relative to the past 72 hours.",
"description": "$1 is networks stability value - stable, low, high"
},
"networkURL": {
"message": "Network URL"
},
@ -1580,12 +1796,24 @@
"message": "Account $1",
"description": "Default name of next account to be created on create account screen"
},
"newCollectibleAddFailed": {
"message": "Collectible was not added because: $1"
},
"newCollectibleAddedMessage": {
"message": "Collectible was successfully added!"
},
"newContact": {
"message": "New Contact"
},
"newContract": {
"message": "New Contract"
},
"newNFTsDetected": {
"message": "New NFTs detected"
},
"newNFTsDetectedInfo": {
"message": "One or more new NFTs were detected in your wallet."
},
"newNetworkAdded": {
"message": "“$1” was successfully added!"
},
@ -1614,6 +1842,9 @@
"nfts": {
"message": "NFTs"
},
"nickname": {
"message": "Nickname"
},
"noAccountsFound": {
"message": "No accounts found for the given search query"
},
@ -1623,6 +1854,9 @@
"noAlreadyHaveSeed": {
"message": "No, I already have a Secret Recovery Phrase"
},
"noConversionDateAvailable": {
"message": "No Currency Conversion Date Available"
},
"noConversionRateAvailable": {
"message": "No Conversion Rate Available"
},
@ -1653,6 +1887,9 @@
"nonceFieldHeading": {
"message": "Custom Nonce"
},
"notBusy": {
"message": "Not busy"
},
"notCurrentAccount": {
"message": "Is this the correct account? It's different from the currently selected account in your wallet"
},
@ -1861,15 +2098,19 @@
"pending": {
"message": "Pending"
},
"permissionCheckedIconDescription": {
"message": "You have approved this permission"
"pendingTransactionInfo": {
"message": "This transaction will not process until that one is complete."
},
"pendingTransactionMultiple": {
"message": "You have ($1) pending transactions."
},
"pendingTransactionSingle": {
"message": "You have (1) pending transaction.",
"description": "$1 is count of pending transactions"
},
"permissionRequest": {
"message": "Permission request"
},
"permissionUncheckedIconDescription": {
"message": "You have not approved this permission"
},
"permissions": {
"message": "Permissions"
},
@ -1893,6 +2134,12 @@
"primaryCurrencySettingDescription": {
"message": "Select native to prioritize displaying values in the native currency of the chain (e.g. ETH). Select Fiat to prioritize displaying values in your selected fiat currency."
},
"priorityFee": {
"message": "Priority fee"
},
"priorityFeeProperCase": {
"message": "Priority Fee"
},
"privacyMsg": {
"message": "Privacy Policy"
},
@ -1906,6 +2153,9 @@
"privateNetwork": {
"message": "Private Network"
},
"proceedWithTransaction": {
"message": "I want to proceed anyway"
},
"proposedApprovalLimit": {
"message": "Proposed Approval Limit"
},
@ -2180,6 +2430,9 @@
"selectHdPath": {
"message": "Select HD Path"
},
"selectNFTPrivacyPreference": {
"message": "Select NFT privacy preference"
},
"selectPathHelp": {
"message": "If you don't see the accounts you expect, try switching the HD path."
},
@ -2287,6 +2540,12 @@
"signed": {
"message": "Signed"
},
"simulationErrorMessage": {
"message": "This transaction is expected to fail. Trying to execute it is expected to be expensive but fail, and is not recommended."
},
"simulationErrorMessageV2": {
"message": "We were not able to estimate gas. There might be an error in the contract and this transaction may fail."
},
"skip": {
"message": "Skip"
},
@ -2339,6 +2598,12 @@
"spendLimitTooLarge": {
"message": "Spend limit too large"
},
"stable": {
"message": "Stable"
},
"stableLowercase": {
"message": "stable"
},
"stateLogError": {
"message": "Error in retrieving state logs."
},
@ -2427,8 +2692,9 @@
"message": "You need $1 more $2 to complete this swap",
"description": "Tells the user how many more of a given token they need for a specific swap. $1 is an amount of tokens and $2 is the token symbol."
},
"swapBetterQuoteAvailable": {
"message": "A better quote is available"
"swapBestOfNQuotes": {
"message": "Best of $1 quotes.",
"description": "$1 is the number of quotes that the user can select from when opening the list of quotes on the 'view quote' screen"
},
"swapBuildQuotePlaceHolderText": {
"message": "No tokens available matching $1",
@ -2459,12 +2725,9 @@
"message": "This is required and gives MetaMask permission to swap your $1.",
"description": "Gives the user info about the required approval transaction for swaps. $1 will be the symbol of a token being approved for swaps."
},
"swapEstimatedNetworkFee": {
"message": "Estimated network fee"
},
"swapEstimatedNetworkFeeSummary": {
"message": "The “$1” is what we expect the actual fee to be. The exact amount depends on network conditions.",
"description": "$1 will be the translation of swapEstimatedNetworkFee, with the font bolded"
"swapEnableTokenForSwapping": {
"message": "This will $1 for swapping",
"description": "$1 is for the 'enableToken' key, e.g. 'enable ETH'"
},
"swapEstimatedNetworkFees": {
"message": "Estimated network fees"
@ -2511,16 +2774,13 @@
"swapHighSlippageWarning": {
"message": "Slippage amount is very high."
},
"swapIncludesMMFee": {
"message": "Includes a $1% MetaMask fee.",
"description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number."
},
"swapLowSlippageError": {
"message": "Transaction may fail, max slippage too low."
},
"swapMaxNetworkFeeInfo": {
"message": "“$1” is the most you’ll spend. When the network is volatile this can be a large amount.",
"description": "$1 will be the translation of swapMaxNetworkFees, with the font bolded"
},
"swapMaxNetworkFees": {
"message": "Max network fee"
},
"swapMaxSlippage": {
"message": "Max slippage"
},
@ -2531,13 +2791,10 @@
"message": "We find the best price from the top liquidity sources, every time. A fee of $1% is automatically factored into this quote.",
"description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number."
},
"swapNQuotes": {
"message": "$1 quotes",
"swapNQuotesWithDot": {
"message": "$1 quotes.",
"description": "$1 is the number of quotes that the user can select from when opening the list of quotes on the 'view quote' screen"
},
"swapNetworkFeeSummary": {
"message": "The network fee covers the cost of processing your swap and storing it on the $1 network. MetaMask does not profit from this fee."
},
"swapNewQuoteIn": {
"message": "New quotes in $1",
"description": "Tells the user the amount of time until the currently displayed quotes are update. $1 is a time that is counting down from 1:00 to 0:00"
@ -2572,10 +2829,6 @@
"swapQuoteDetailsSlippageInfo": {
"message": "If the price changes between the time your order is placed and confirmed it’s called \"slippage\". Your Swap will automatically cancel if slippage exceeds your \"slippage tolerance\" setting."
},
"swapQuoteIncludesRate": {
"message": "Quote includes a $1% MetaMask fee",
"description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number."
},
"swapQuoteNofN": {
"message": "Quote $1 of $2",
"description": "A count of loaded quotes shown to the user while they are waiting for quotes to be fetched. $1 is the number of quotes already loaded, and $2 is the total number of quotes to load."
@ -2583,9 +2836,6 @@
"swapQuoteSource": {
"message": "Quote source"
},
"swapQuotesAreRefreshed": {
"message": "Quotes are refreshed often to reflect current market conditions."
},
"swapQuotesExpiredErrorDescription": {
"message": "Please request new quotes to get the latest rates."
},
@ -2646,9 +2896,6 @@
"swapSwapTo": {
"message": "Swap to"
},
"swapThisWillAllowApprove": {
"message": "This will allow $1 to be swapped."
},
"swapToConfirmWithHwWallet": {
"message": "to confirm with your hardware wallet"
},
@ -2691,9 +2938,6 @@
"swapUnknown": {
"message": "Unknown"
},
"swapUsingBestQuote": {
"message": "Using the best quote"
},
"swapVerifyTokenExplanation": {
"message": "Multiple tokens can use the same name and symbol. Check $1 to verify this is the token you're looking for.",
"description": "This appears in a tooltip next to the verifyThisTokenOn message. It gives the user more information about why they should check the token on a block explorer. $1 will be the name or url of the block explorer, which will be the translation of 'etherscan' or a block explorer url specified for a custom network."
@ -2793,6 +3037,9 @@
"thisWillCreate": {
"message": "This will create a new wallet and Secret Recovery Phrase"
},
"time": {
"message": "Time"
},
"tips": {
"message": "Tips"
},
@ -2846,6 +3093,18 @@
"transactionCreated": {
"message": "Transaction created with a value of $1 at $2."
},
"transactionData": {
"message": "Transaction data"
},
"transactionDecodingAccreditationDecoded": {
"message": "Decoded by Truffle"
},
"transactionDecodingAccreditationVerified": {
"message": "Verified contract on"
},
"transactionDecodingUnsupportedNetworkError": {
"message": "Transaction decoding is not available for chainId $1"
},
"transactionDetailDappGasMoreInfo": {
"message": "Site suggested"
},
@ -2947,9 +3206,18 @@
"tryAgain": {
"message": "Try again"
},
"tryAnywayOption": {
"message": "I will try anyway"
},
"turnOnTokenDetection": {
"message": "Turn on enhanced token detection"
},
"twelveHrTitle": {
"message": "12hr:"
},
"txInsightsNotSupported": {
"message": "Transaction insights not supported for this contract at this time."
},
"typePassword": {
"message": "Type your MetaMask password"
},
@ -3008,6 +3276,12 @@
"urlExistsErrorMsg": {
"message": "This URL is currently used by the $1 network."
},
"useCollectibleDetection": {
"message": "Autodetect NFTs"
},
"useCollectibleDetectionDescription": {
"message": "Displaying NFTs media & data may expose your IP address to centralized servers. Third-party APIs (like OpenSea) are used to detect NFTs in your wallet. This exposes your account address with those services. Leave this disabled if you don’t want the app to pull data from those those services."
},
"usePhishingDetection": {
"message": "Use Phishing Detection"
},
@ -3023,6 +3297,9 @@
"usedByClients": {
"message": "Used by a variety of different clients"
},
"userAccepts": {
"message": "I accept"
},
"userName": {
"message": "Username"
},
@ -3053,6 +3330,9 @@
"viewMore": {
"message": "View More"
},
"viewOnBlockExplorer": {
"message": "View on block explorer"
},
"viewOnCustomBlockExplorer": {
"message": "View $1 at $2",
"description": "$1 is the action type. e.g (Account, Transaction, Swap) and $2 is the Custom Block Exporer URL"

@ -416,9 +416,6 @@
"copiedExclamation": {
"message": "¡Copiado!"
},
"copiedTransactionId": {
"message": "Id. de transacción copiado"
},
"copyAddress": {
"message": "Copiar dirección al Portapapeles"
},
@ -1346,12 +1343,6 @@
"pending": {
"message": "Pendiente"
},
"permissionCheckedIconDescription": {
"message": "Aprobó este permiso"
},
"permissionUncheckedIconDescription": {
"message": "No aprobó este permiso"
},
"permissions": {
"message": "Permisos"
},
@ -1816,9 +1807,6 @@
"message": "Necesita $1 más $2 para completar este canje",
"description": "Tells the user how many more of a given token they need for a specific swap. $1 is an amount of tokens and $2 is the token symbol."
},
"swapBetterQuoteAvailable": {
"message": "Hay una mejor cotización disponible"
},
"swapBuildQuotePlaceHolderText": {
"message": "No hay tokens disponibles que coincidan con $1",
"description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text"
@ -1845,13 +1833,6 @@
"message": "Esta acción es obligatoria y le da permiso a MetaMask para canjear su $1.",
"description": "Gives the user info about the required approval transaction for swaps. $1 will be the symbol of a token being approved for swaps."
},
"swapEstimatedNetworkFee": {
"message": "Cuota de red estimada"
},
"swapEstimatedNetworkFeeSummary": {
"message": "“$1” es la cuota real que esperamos que sea. El monto exacto depende de las condiciones de la red.",
"description": "$1 will be the translation of swapEstimatedNetworkFee, with the font bolded"
},
"swapEstimatedNetworkFees": {
"message": "Cuotas de red estimadas"
},
@ -1887,13 +1868,6 @@
"swapLowSlippageError": {
"message": "Es posible que la transacción tenga errores, el desfase máximo es demasiado bajo."
},
"swapMaxNetworkFeeInfo": {
"message": "“$1” es el máximo que gastará. Cuando la red es volátil, puede ser un monto grande.",
"description": "$1 will be the translation of swapMaxNetworkFees, with the font bolded"
},
"swapMaxNetworkFees": {
"message": "Cuota máxima de red"
},
"swapMaxSlippage": {
"message": "Desfase máximo"
},
@ -1904,13 +1878,6 @@
"message": "Buscamos el mejor precio en las fuentes de liquidez más importantes, todo el tiempo. Se incorpora de manera automática a esta cotización una cuota del $1 %.",
"description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number."
},
"swapNQuotes": {
"message": "$1 cotizaciones",
"description": "$1 is the number of quotes that the user can select from when opening the list of quotes on the 'view quote' screen"
},
"swapNetworkFeeSummary": {
"message": "La cuota de red cubre el costo de procesamiento del canje y su almacenamiento en la red de $1. MetaMask no se beneficia de esta cuota."
},
"swapNewQuoteIn": {
"message": "Cotizaciones nuevas en $1",
"description": "Tells the user the amount of time until the currently displayed quotes are update. $1 is a time that is counting down from 1:00 to 0:00"
@ -1945,10 +1912,6 @@
"swapQuoteDetailsSlippageInfo": {
"message": "Si el precio cambia entre el momento en que hace el pedido y cuando se confirma, se denomina \"desfase\". El canje se cancelará automáticamente si el desfase supera lo establecido en la configuración \"tolerancia de desfase\"."
},
"swapQuoteIncludesRate": {
"message": "La cotización incluye una cuota de MetaMask de $1 %",
"description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number."
},
"swapQuoteNofN": {
"message": "Cotización $1 de $2",
"description": "A count of loaded quotes shown to the user while they are waiting for quotes to be fetched. $1 is the number of quotes already loaded, and $2 is the total number of quotes to load."
@ -1956,9 +1919,6 @@
"swapQuoteSource": {
"message": "Fuente de la cotización"
},
"swapQuotesAreRefreshed": {
"message": "Las cotizaciones se actualizan con frecuencia para reflejar las condiciones actuales del mercado."
},
"swapQuotesExpiredErrorDescription": {
"message": "Solicite cotizaciones nuevas para tener los costos más recientes."
},
@ -2019,9 +1979,6 @@
"swapSwapTo": {
"message": "Canjear a"
},
"swapThisWillAllowApprove": {
"message": "Esto permitirá canjear $1."
},
"swapToConfirmWithHwWallet": {
"message": "confirmar con la cartera de hardware"
},
@ -2060,9 +2017,6 @@
"swapUnknown": {
"message": "Desconocido"
},
"swapUsingBestQuote": {
"message": "Uso de la mejor cotización"
},
"swapVerifyTokenExplanation": {
"message": "Varios tokens pueden usar el mismo nombre y símbolo. Revise $1 para comprobar que este es el token que busca.",
"description": "This appears in a tooltip next to the verifyThisTokenOn message. It gives the user more information about why they should check the token on a block explorer. $1 will be the name or url of the block explorer, which will be the translation of 'etherscan' or a block explorer url specified for a custom network."

@ -416,9 +416,6 @@
"copiedExclamation": {
"message": "¡Copiado!"
},
"copiedTransactionId": {
"message": "Id. de transacción copiado"
},
"copyAddress": {
"message": "Copiar dirección al Portapapeles"
},
@ -1346,12 +1343,6 @@
"pending": {
"message": "Pendiente"
},
"permissionCheckedIconDescription": {
"message": "Aprobó este permiso"
},
"permissionUncheckedIconDescription": {
"message": "No aprobó este permiso"
},
"permissions": {
"message": "Permisos"
},
@ -1816,9 +1807,6 @@
"message": "Necesita $1 más $2 para completar este canje",
"description": "Tells the user how many more of a given token they need for a specific swap. $1 is an amount of tokens and $2 is the token symbol."
},
"swapBetterQuoteAvailable": {
"message": "Hay una mejor cotización disponible"
},
"swapBuildQuotePlaceHolderText": {
"message": "No hay tokens disponibles que coincidan con $1",
"description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text"
@ -1845,13 +1833,6 @@
"message": "Esta acción es obligatoria y le da permiso a MetaMask para canjear su $1.",
"description": "Gives the user info about the required approval transaction for swaps. $1 will be the symbol of a token being approved for swaps."
},
"swapEstimatedNetworkFee": {
"message": "Cuota de red estimada"
},
"swapEstimatedNetworkFeeSummary": {
"message": "“$1” es la cuota real que esperamos que sea. El monto exacto depende de las condiciones de la red.",
"description": "$1 will be the translation of swapEstimatedNetworkFee, with the font bolded"
},
"swapEstimatedNetworkFees": {
"message": "Cuotas de red estimadas"
},
@ -1887,13 +1868,6 @@
"swapLowSlippageError": {
"message": "Es posible que la transacción tenga errores, el desfase máximo es demasiado bajo."
},
"swapMaxNetworkFeeInfo": {
"message": "“$1” es el máximo que gastará. Cuando la red es volátil, puede ser un monto grande.",
"description": "$1 will be the translation of swapMaxNetworkFees, with the font bolded"
},
"swapMaxNetworkFees": {
"message": "Cuota máxima de red"
},
"swapMaxSlippage": {
"message": "Desfase máximo"
},
@ -1904,13 +1878,6 @@
"message": "Buscamos el mejor precio en las fuentes de liquidez más importantes, todo el tiempo. Se incorpora de manera automática a esta cotización una cuota del $1 %.",
"description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number."
},
"swapNQuotes": {
"message": "$1 cotizaciones",
"description": "$1 is the number of quotes that the user can select from when opening the list of quotes on the 'view quote' screen"
},
"swapNetworkFeeSummary": {
"message": "La cuota de red cubre el costo de procesamiento del canje y su almacenamiento en la red de $1. MetaMask no se beneficia de esta cuota."
},
"swapNewQuoteIn": {
"message": "Cotizaciones nuevas en $1",
"description": "Tells the user the amount of time until the currently displayed quotes are update. $1 is a time that is counting down from 1:00 to 0:00"
@ -1945,10 +1912,6 @@
"swapQuoteDetailsSlippageInfo": {
"message": "Si el precio cambia entre el momento en que hace el pedido y cuando se confirma, se denomina \"desfase\". El canje se cancelará automáticamente si el desfase supera lo establecido en la configuración \"tolerancia de desfase\"."
},
"swapQuoteIncludesRate": {
"message": "La cotización incluye una cuota de MetaMask de $1 %",
"description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number."
},
"swapQuoteNofN": {
"message": "Cotización $1 de $2",
"description": "A count of loaded quotes shown to the user while they are waiting for quotes to be fetched. $1 is the number of quotes already loaded, and $2 is the total number of quotes to load."
@ -1956,9 +1919,6 @@
"swapQuoteSource": {
"message": "Fuente de la cotización"
},
"swapQuotesAreRefreshed": {
"message": "Las cotizaciones se actualizan con frecuencia para reflejar las condiciones actuales del mercado."
},
"swapQuotesExpiredErrorDescription": {
"message": "Solicite cotizaciones nuevas para tener los costos más recientes."
},
@ -2019,9 +1979,6 @@
"swapSwapTo": {
"message": "Canjear a"
},
"swapThisWillAllowApprove": {
"message": "Esto permitirá canjear $1."
},
"swapToConfirmWithHwWallet": {
"message": "confirmar con la cartera de hardware"
},
@ -2060,9 +2017,6 @@
"swapUnknown": {
"message": "Desconocido"
},
"swapUsingBestQuote": {
"message": "Uso de la mejor cotización"
},
"swapVerifyTokenExplanation": {
"message": "Varios tokens pueden usar el mismo nombre y símbolo. Revise $1 para comprobar que este es el token que busca.",
"description": "This appears in a tooltip next to the verifyThisTokenOn message. It gives the user more information about why they should check the token on a block explorer. $1 will be the name or url of the block explorer, which will be the translation of 'etherscan' or a block explorer url specified for a custom network."

@ -218,9 +218,6 @@
"copiedExclamation": {
"message": "Kopeeritud!"
},
"copiedTransactionId": {
"message": "Kopeeritud tehingu ID"
},
"copyAddress": {
"message": "Kopeeri aadress lõikelauale"
},

@ -218,9 +218,6 @@
"copiedExclamation": {
"message": "کپی شد!"
},
"copiedTransactionId": {
"message": "آی دی معامله کاپی شده"
},
"copyAddress": {
"message": "کاپی آدرس به کلیپ بورد"
},

@ -218,9 +218,6 @@
"copiedExclamation": {
"message": "Kopioitu"
},
"copiedTransactionId": {
"message": "Kopioitu tapahtuman tunnus"
},
"copyAddress": {
"message": "Kopioi osoite leikepöydälle"
},

@ -197,9 +197,6 @@
"copiedExclamation": {
"message": "Nakopya!"
},
"copiedTransactionId": {
"message": "Nakopya ang Transaction ID"
},
"copyAddress": {
"message": "Kopyahin ang address sa clipboard"
},

@ -209,9 +209,6 @@
"copiedExclamation": {
"message": "Copié!"
},
"copiedTransactionId": {
"message": "ID de transaction copié"
},
"copyAddress": {
"message": "Copier l'addresse dans le presse-papier"
},

@ -218,9 +218,6 @@
"copiedExclamation": {
"message": "הועתק!"
},
"copiedTransactionId": {
"message": "מזהה עסקה הועתק"
},
"copyAddress": {
"message": "העתק כתובת ללוח"
},

@ -416,9 +416,6 @@
"copiedExclamation": {
"message": "कि गय!"
},
"copiedTransactionId": {
"message": "क गई लनदन ID"
},
"copyAddress": {
"message": "किपबड पर पत कर"
},
@ -1346,12 +1343,6 @@
"pending": {
"message": "लित"
},
"permissionCheckedIconDescription": {
"message": "आपन इस अनमति अनित कर दि"
},
"permissionUncheckedIconDescription": {
"message": "आपन इस अनमति अनित नहि"
},
"permissions": {
"message": "अनमति"
},
@ -1816,9 +1807,6 @@
"message": "इस सप क करनिए आपक $1 और $2 क आवशयकत",
"description": "Tells the user how many more of a given token they need for a specific swap. $1 is an amount of tokens and $2 is the token symbol."
},
"swapBetterQuoteAvailable": {
"message": "एक बहतर उदधरण उपलबध ह"
},
"swapBuildQuotePlaceHolderText": {
"message": "$1 किन वई भकन उपलबध नह",
"description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text"
@ -1845,13 +1833,6 @@
"message": "यह आवशयक ह और MetaMask क आपक $1 कप करन अनमति।",
"description": "Gives the user info about the required approval transaction for swaps. $1 will be the symbol of a token being approved for swaps."
},
"swapEstimatedNetworkFee": {
"message": "अनित नटवरक शक"
},
"swapEstimatedNetworkFeeSummary": {
"message": "“$1” वह शक ह, जिसक हम वतविक रप स उमद करत। सटक रिटवरक किि पर निभर करत।",
"description": "$1 will be the translation of swapEstimatedNetworkFee, with the font bolded"
},
"swapEstimatedNetworkFees": {
"message": "अनित नटवरक शक"
},
@ -1887,13 +1868,6 @@
"swapLowSlippageError": {
"message": "लनदन विफल ह सकत, अधिकतम सिज बहत कम ह सकत।"
},
"swapMaxNetworkFeeInfo": {
"message": "आप सबस अधिक “$1” खरच करत। जब नटवरक असिर ह, त यह एक बडि सकत।",
"description": "$1 will be the translation of swapMaxNetworkFees, with the font bolded"
},
"swapMaxNetworkFees": {
"message": "अधिकतम नटवरक शक"
},
"swapMaxSlippage": {
"message": "अधिकतम सिज"
},
@ -1904,13 +1878,6 @@
"message": "हम हर बर शष चलनिि सबस अचय प। इस उदधरण म $1% कक सवतिल ह।",
"description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number."
},
"swapNQuotes": {
"message": "$1 उदधरण",
"description": "$1 is the number of quotes that the user can select from when opening the list of quotes on the 'view quote' screen"
},
"swapNetworkFeeSummary": {
"message": "नटवरक शक आपकप कित करनक और उस $1 नटवरक पर सरह करन कवर करत। MetaMask इस शक सभ नह कम।"
},
"swapNewQuoteIn": {
"message": "$1 म नए उदधरण",
"description": "Tells the user the amount of time until the currently displayed quotes are update. $1 is a time that is counting down from 1:00 to 0:00"
@ -1945,10 +1912,6 @@
"swapQuoteDetailsSlippageInfo": {
"message": "यदि आपक ऑरडर किए ज और पििए ज समय कच मय म परिवरतन ह, त इस \"सिज\" कह। यदििज आपक \"सिज टलरस\" सिग स अधिक ह, त आपकप सवत रदद हएग।"
},
"swapQuoteIncludesRate": {
"message": "उदधरण म $1% क MetaMask शक शिल ह",
"description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number."
},
"swapQuoteNofN": {
"message": "$2 म $1 उदधरण",
"description": "A count of loaded quotes shown to the user while they are waiting for quotes to be fetched. $1 is the number of quotes already loaded, and $2 is the total number of quotes to load."
@ -1956,9 +1919,6 @@
"swapQuoteSource": {
"message": "उदधरण कत"
},
"swapQuotesAreRefreshed": {
"message": "वरतमन बर कििरतििित करनिए उदधरण अकसर त रहत।"
},
"swapQuotesExpiredErrorDescription": {
"message": "कपय नवनतम दरत करनिए नए उदधरण अनध कर।"
},
@ -2019,9 +1979,6 @@
"swapSwapTo": {
"message": "इसमप कर"
},
"swapThisWillAllowApprove": {
"message": "यह $1 कप करन अनमति।"
},
"swapToConfirmWithHwWallet": {
"message": "अपनडवयर वट सि करनिए"
},
@ -2060,9 +2017,6 @@
"swapUnknown": {
"message": "अजत"
},
"swapUsingBestQuote": {
"message": "सरतम उदधरण क उपयग करन"
},
"swapVerifyTokenExplanation": {
"message": "एकिक टकन एक हम और परतक क उपयग कर सकत। यह सतित करनिए $1 कच करि यह वहकन ह, जिसक आप तलश कर रह।",
"description": "This appears in a tooltip next to the verifyThisTokenOn message. It gives the user more information about why they should check the token on a block explorer. $1 will be the name or url of the block explorer, which will be the translation of 'etherscan' or a block explorer url specified for a custom network."

@ -218,9 +218,6 @@
"copiedExclamation": {
"message": "Kopirano!"
},
"copiedTransactionId": {
"message": "Kopirana identifikacijska oznaka transakcije"
},
"copyAddress": {
"message": "Kopiraj adresu u međuspremnik"
},

@ -218,9 +218,6 @@
"copiedExclamation": {
"message": "Kimásolva!"
},
"copiedTransactionId": {
"message": "Másolt tranzakció-azonosító"
},
"copyAddress": {
"message": "Másolja a címet a vágólapra"
},

@ -416,9 +416,6 @@
"copiedExclamation": {
"message": "Disalin!"
},
"copiedTransactionId": {
"message": "ID Transaksi yang Disalin"
},
"copyAddress": {
"message": "Salin alamat ke clipboard"
},
@ -1346,12 +1343,6 @@
"pending": {
"message": "Tunda"
},
"permissionCheckedIconDescription": {
"message": "Anda telah menyetujui izin ini"
},
"permissionUncheckedIconDescription": {
"message": "Anda belum menyetujui izin ini"
},
"permissions": {
"message": "Izin"
},
@ -1816,9 +1807,6 @@
"message": "Anda memerlukan $1 lagi $2 untuk menyelesaikan penukaran ini",
"description": "Tells the user how many more of a given token they need for a specific swap. $1 is an amount of tokens and $2 is the token symbol."
},
"swapBetterQuoteAvailable": {
"message": "Kuota yang lebih baik tersedia"
},
"swapBuildQuotePlaceHolderText": {
"message": "Tidak ada token yang cocok yang tersedia $1",
"description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text"
@ -1845,13 +1833,6 @@
"message": "Ini wajib dan memberikan MetaMask izin untuk menukar $1 Anda.",
"description": "Gives the user info about the required approval transaction for swaps. $1 will be the symbol of a token being approved for swaps."
},
"swapEstimatedNetworkFee": {
"message": "Biaya jaringan yang diperkirakan"
},
"swapEstimatedNetworkFeeSummary": {
"message": "“$1” adalah yang kami harapkan untuk biaya yang seharusnya. Jumlah yang tepat tergantung pada kondisi jaringan.",
"description": "$1 will be the translation of swapEstimatedNetworkFee, with the font bolded"
},
"swapEstimatedNetworkFees": {
"message": "Biaya jaringan yang diperkirakan"
},
@ -1887,13 +1868,6 @@
"swapLowSlippageError": {
"message": "Transaksi bisa gagal, slippage maks. terlalu rendah."
},
"swapMaxNetworkFeeInfo": {
"message": "“$1” adalah yang paling banyak yang akan Anda gunakan. Bila jaringan tidak stabil ini bisa menjadi jumlah yang besar.",
"description": "$1 will be the translation of swapMaxNetworkFees, with the font bolded"
},
"swapMaxNetworkFees": {
"message": "Biaya jaringan maks."
},
"swapMaxSlippage": {
"message": "Maks. slippage"
},
@ -1904,13 +1878,6 @@
"message": "Kami menemukan harga terbaik dari sumber likuiditas teratas, setiap waktu. Biaya sebesar $1% otomatis diperhitungkan ke kuota ini.",
"description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number."
},
"swapNQuotes": {
"message": "$1 kuota",
"description": "$1 is the number of quotes that the user can select from when opening the list of quotes on the 'view quote' screen"
},
"swapNetworkFeeSummary": {
"message": "Biaya jaringan mencakup biaya pemrosesan penukaran Anda dan menyimpannya di jaringan $1. MetaMask tidak mendapatkan keuntungan dari biaya ini."
},
"swapNewQuoteIn": {
"message": "Kuota baru di $1",
"description": "Tells the user the amount of time until the currently displayed quotes are update. $1 is a time that is counting down from 1:00 to 0:00"
@ -1945,10 +1912,6 @@
"swapQuoteDetailsSlippageInfo": {
"message": "Jika harga berubah antara waktu pesanan Anda ditempatkan dan dikonfirmasi, ini disebut \"slippage\". Penukaran Anda akan otomatis dibatalkan jika slippage melebihi pengaturan \"toleransi slippage\"."
},
"swapQuoteIncludesRate": {
"message": "Kuota mencakup biaya MetaMask $1%",
"description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number."
},
"swapQuoteNofN": {
"message": "Kuota $1 dari $2",
"description": "A count of loaded quotes shown to the user while they are waiting for quotes to be fetched. $1 is the number of quotes already loaded, and $2 is the total number of quotes to load."
@ -1956,9 +1919,6 @@
"swapQuoteSource": {
"message": "Sumber kuota"
},
"swapQuotesAreRefreshed": {
"message": "Kuota disegarkan sering kali untuk menerapkan kondisi pasar terkini."
},
"swapQuotesExpiredErrorDescription": {
"message": "Silakan minta kuota baru untuk mendapatkan tarif terbaru."
},
@ -2019,9 +1979,6 @@
"swapSwapTo": {
"message": "Tukar untuk"
},
"swapThisWillAllowApprove": {
"message": "Ini akan memungkinkan $1 untuk ditukar."
},
"swapToConfirmWithHwWallet": {
"message": "untuk mengonfirmasikan dengan dompet perangkat keras Anda"
},
@ -2060,9 +2017,6 @@
"swapUnknown": {
"message": "Tidak diketahui"
},
"swapUsingBestQuote": {
"message": "Menggunakan kuota terbaik"
},
"swapVerifyTokenExplanation": {
"message": "Beberapa token dapat menggunakan simbol dan nama yang sama. Periksa $1 untuk memverifikasi inilah token yang Anda cari.",
"description": "This appears in a tooltip next to the verifyThisTokenOn message. It gives the user more information about why they should check the token on a block explorer. $1 will be the name or url of the block explorer, which will be the translation of 'etherscan' or a block explorer url specified for a custom network."

@ -356,9 +356,6 @@
"copiedExclamation": {
"message": "Copiato!"
},
"copiedTransactionId": {
"message": "ID Transazione Copiato"
},
"copyAddress": {
"message": "Copia l'indirizzo"
},
@ -1105,12 +1102,6 @@
"pending": {
"message": "in corso"
},
"permissionCheckedIconDescription": {
"message": "Hai approvato questo permesso"
},
"permissionUncheckedIconDescription": {
"message": "Non hai approvato questo permesso"
},
"permissions": {
"message": "Permessi"
},
@ -1478,9 +1469,6 @@
"message": "Devi avere $1 $2 in più per completare lo scambio",
"description": "Tells the user how many more of a given token they need for a specific swap. $1 is an amount of tokens and $2 is the token symbol."
},
"swapBetterQuoteAvailable": {
"message": "È disponibile una quotazione migliore"
},
"swapBuildQuotePlaceHolderText": {
"message": "Non ci sono token disponibile con questo nome $1",
"description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text"
@ -1498,13 +1486,6 @@
"message": "Questo è richiesto e darà a MetaMask il permesso di scambiare $1.",
"description": "Gives the user info about the required approval transaction for swaps. $1 will be the symbol of a token being approved for swaps."
},
"swapEstimatedNetworkFee": {
"message": "Tassa di rete stimata"
},
"swapEstimatedNetworkFeeSummary": {
"message": "La “$1” è ciò che ci aspettiamo possa essere la tassa reale. L'importo esatto dipende dalle condizioni della rete.",
"description": "$1 will be the translation of swapEstimatedNetworkFee, with the font bolded"
},
"swapEstimatedNetworkFees": {
"message": "Tasse di rete stimate"
},
@ -1526,13 +1507,6 @@
"swapLowSlippageError": {
"message": "La transazione può fallire, il massimo slippage è troppo basso."
},
"swapMaxNetworkFeeInfo": {
"message": "“$1” è il massimo che spenderai. Quando la rete è volatile può essere un importo alto.",
"description": "$1 will be the translation of swapMaxNetworkFees, with the font bolded"
},
"swapMaxNetworkFees": {
"message": "Tassa di rete massima"
},
"swapMaxSlippage": {
"message": "Slippage massimo"
},
@ -1543,13 +1517,6 @@
"message": "Troviamo i migliori prezzi dalle sorgenti di liquidità migliori, sempre. Una tassa del $1% è automaticamente aggiunta ad ogni quotazione, ciò serve a supportare lo sviluppo in modo da rendere MetaMask sempre migliore.",
"description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number."
},
"swapNQuotes": {
"message": "$1 quotazioni",
"description": "$1 is the number of quotes that the user can select from when opening the list of quotes on the 'view quote' screen"
},
"swapNetworkFeeSummary": {
"message": "La tassa di rete copre il costo di processamento dello scambio e della memorizzazione nella rete $1. MetaMask non trae profitto da questa tassa."
},
"swapNewQuoteIn": {
"message": "Nuove quotazioni in $1",
"description": "Tells the user the amount of time until the currently displayed quotes are update. $1 is a time that is counting down from 1:00 to 0:00"
@ -1575,10 +1542,6 @@
"swapQuoteDetailsSlippageInfo": {
"message": "Si chiama \"slippage\" la differenza tra il prezzo quando il tuo ordine viene inserito e quando viene confermato. Lo scambio sarà annullato automaticamente se lo slippage supera il \"massimo slippage\" impostato."
},
"swapQuoteIncludesRate": {
"message": "La quotazione include la tassa del $1% di MetaMask",
"description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number."
},
"swapQuoteNofN": {
"message": "Quotazione $1 di $2",
"description": "A count of loaded quotes shown to the user while they are waiting for quotes to be fetched. $1 is the number of quotes already loaded, and $2 is the total number of quotes to load."
@ -1586,9 +1549,6 @@
"swapQuoteSource": {
"message": "Sorgente della quota"
},
"swapQuotesAreRefreshed": {
"message": "Le quotazioni sono aggiornate spesso per riflettere le condizioni di mercato correnti."
},
"swapQuotesExpiredErrorDescription": {
"message": "Richiedi nuove quotazioni per ottenere le ultime tariffe."
},
@ -1646,9 +1606,6 @@
"swapSwapTo": {
"message": "Scambia a"
},
"swapThisWillAllowApprove": {
"message": "Questo permetterà di scambiare $1."
},
"swapTokenAvailable": {
"message": "I $1 sono stati aggiunti al tuo account.",
"description": "This message is shown after a swap is successful and communicates the exact amount of tokens the user has received for a swap. The $1 is a decimal number of tokens followed by the token symbol."
@ -1674,9 +1631,6 @@
"swapUnknown": {
"message": "Sconosciuto"
},
"swapUsingBestQuote": {
"message": "Quotazione migliore"
},
"swapVerifyTokenExplanation": {
"message": "Più token possono usare lo stesso nome e simbolo. Verifica su $1 che questo sia il token che stai cercando.",
"description": "This appears in a tooltip next to the verifyThisTokenOn message. It gives the user more information about why they should check the token on a block explorer. $1 will be the name or url of the block explorer, which will be the translation of 'etherscan' or a block explorer url specified for a custom network."

@ -416,9 +416,6 @@
"copiedExclamation": {
"message": "コピーされました!"
},
"copiedTransactionId": {
"message": "コピーされたトランザクション ID"
},
"copyAddress": {
"message": "アドレスをクリップボードにコピー"
},
@ -1346,12 +1343,6 @@
"pending": {
"message": "処理"
},
"permissionCheckedIconDescription": {
"message": "この許可の承認が完了しました。"
},
"permissionUncheckedIconDescription": {
"message": "この許可の承認が完了していません。"
},
"permissions": {
"message": "許可"
},
@ -1816,9 +1807,6 @@
"message": "このスワップを完了するには、さらに $1 個の $2 が必要です。",
"description": "Tells the user how many more of a given token they need for a specific swap. $1 is an amount of tokens and $2 is the token symbol."
},
"swapBetterQuoteAvailable": {
"message": "より適切な見積もりが利用可能です"
},
"swapBuildQuotePlaceHolderText": {
"message": "$1 と一致するトークンがありません",
"description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text"
@ -1845,13 +1833,6 @@
"message": "これは必須であり、$1 をスワップするための MetaMask 許可を付与します。",
"description": "Gives the user info about the required approval transaction for swaps. $1 will be the symbol of a token being approved for swaps."
},
"swapEstimatedNetworkFee": {
"message": "推定のネットワーク手数料"
},
"swapEstimatedNetworkFeeSummary": {
"message": "“$1” は予定する実際の手数料です。正確な額はネットワークの状態によって異なります。",
"description": "$1 will be the translation of swapEstimatedNetworkFee, with the font bolded"
},
"swapEstimatedNetworkFees": {
"message": "推定のネットワーク手数料"
},
@ -1887,13 +1868,6 @@
"swapLowSlippageError": {
"message": "トランザクションが失敗する可能性があります。最大スリッページが低すぎます。"
},
"swapMaxNetworkFeeInfo": {
"message": "“$1” は使用する最大量です。ネットワークが不安定なときは、これは大きな量になることがあります。",
"description": "$1 will be the translation of swapMaxNetworkFees, with the font bolded"
},
"swapMaxNetworkFees": {
"message": "最大ネットワーク手数料"
},
"swapMaxSlippage": {
"message": "最大スリッページ"
},
@ -1904,13 +1878,6 @@
"message": "当社は毎回最上位の流動性のソースから最良の価格を見つけます。$1 の手数料が自動的にこの見積もりに含まれます。",
"description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number."
},
"swapNQuotes": {
"message": "$1 の見積もり",
"description": "$1 is the number of quotes that the user can select from when opening the list of quotes on the 'view quote' screen"
},
"swapNetworkFeeSummary": {
"message": "ネットワーク手数料には、スワップを処理して $1 ネットワーク上に保管する費用も含まれています。MetaMask は手数料から利益を得ません。"
},
"swapNewQuoteIn": {
"message": "$1 での新規の見積もり",
"description": "Tells the user the amount of time until the currently displayed quotes are update. $1 is a time that is counting down from 1:00 to 0:00"
@ -1945,10 +1912,6 @@
"swapQuoteDetailsSlippageInfo": {
"message": "注文した時点と注文が承認された時点で価格が変わることを \"スリッページ\" と呼びます。スリッページが \"最大スリッページ\" 設定を超える場合、スワップは自動的にキャンセルされます。"
},
"swapQuoteIncludesRate": {
"message": "見積もりには $1% の MetaMask 手数料が含まれています",
"description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number."
},
"swapQuoteNofN": {
"message": "$2 個中の $1 個の見積もり",
"description": "A count of loaded quotes shown to the user while they are waiting for quotes to be fetched. $1 is the number of quotes already loaded, and $2 is the total number of quotes to load."
@ -1956,9 +1919,6 @@
"swapQuoteSource": {
"message": "見積もりのソース"
},
"swapQuotesAreRefreshed": {
"message": "現在のマーケット状態を反映するために、見積もりはたびたび更新されます。"
},
"swapQuotesExpiredErrorDescription": {
"message": "最新のレートを取得するには、新しい見積もりを要求してください。"
},
@ -2019,9 +1979,6 @@
"swapSwapTo": {
"message": "スワップ先"
},
"swapThisWillAllowApprove": {
"message": "これにより、$1 のスワップが可能になります。"
},
"swapToConfirmWithHwWallet": {
"message": "ハードウェア ウォレットで確認する"
},
@ -2060,9 +2017,6 @@
"swapUnknown": {
"message": "不明です"
},
"swapUsingBestQuote": {
"message": "最良の見積もりを使用する"
},
"swapVerifyTokenExplanation": {
"message": "複数のトークンが同じ名前とシンボルを使用できます。$1 をチェックして、これが探しているトークンであることを確認します。",
"description": "This appears in a tooltip next to the verifyThisTokenOn message. It gives the user more information about why they should check the token on a block explorer. $1 will be the name or url of the block explorer, which will be the translation of 'etherscan' or a block explorer url specified for a custom network."

@ -218,9 +218,6 @@
"copiedExclamation": {
"message": "ನಕಲಿಸಲಿ!"
},
"copiedTransactionId": {
"message": "ವಯವಹರ ID ಅನ ನಕಲಿಸಲಿ"
},
"copyAddress": {
"message": "ವಿಸವನಿ ನಕಲಿಿ"
},

@ -416,9 +416,6 @@
"copiedExclamation": {
"message": "복사 완료!"
},
"copiedTransactionId": {
"message": "거래 ID 복사됨"
},
"copyAddress": {
"message": "주소를 클립보드에 복사"
},
@ -1346,12 +1343,6 @@
"pending": {
"message": "보류 중"
},
"permissionCheckedIconDescription": {
"message": "이 권한을 승인했습니다."
},
"permissionUncheckedIconDescription": {
"message": "이 권한을 승인하지 않았습니다."
},
"permissions": {
"message": "권한"
},
@ -1816,9 +1807,6 @@
"message": "이 스왑을 완료하려면 $1개의 추가 $2이(가) 필요합니다.",
"description": "Tells the user how many more of a given token they need for a specific swap. $1 is an amount of tokens and $2 is the token symbol."
},
"swapBetterQuoteAvailable": {
"message": "더 나은 견적이 있습니다."
},
"swapBuildQuotePlaceHolderText": {
"message": "$1와(과) 일치하는 토큰이 없습니다.",
"description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text"
@ -1845,13 +1833,6 @@
"message": "필수이며 MetaMask에게 $1을(를) 스왑할 권한을 부여합니다.",
"description": "Gives the user info about the required approval transaction for swaps. $1 will be the symbol of a token being approved for swaps."
},
"swapEstimatedNetworkFee": {
"message": "예상 네트워크 수수료"
},
"swapEstimatedNetworkFeeSummary": {
"message": "'$1'은(는) 당사가 예상하는 실제 수수료입니다. 정확한 금액은 네트워크 상태에 따라 달라집니다.",
"description": "$1 will be the translation of swapEstimatedNetworkFee, with the font bolded"
},
"swapEstimatedNetworkFees": {
"message": "예상 네트워크 수수료"
},
@ -1887,13 +1868,6 @@
"swapLowSlippageError": {
"message": "거래가 실패할 수도 있습니다. 최대 슬리패지가 너무 낮습니다."
},
"swapMaxNetworkFeeInfo": {
"message": "“$1”이(가) 최대 지출 금액입니다. 네트워크가 불안정한 경우 금액이 증가할 수 있습니다.",
"description": "$1 will be the translation of swapMaxNetworkFees, with the font bolded"
},
"swapMaxNetworkFees": {
"message": "최대 네트워크 수수료"
},
"swapMaxSlippage": {
"message": "최대 슬리패지"
},
@ -1904,13 +1878,6 @@
"message": "당사는 매번 최상의 유동성 소스에서 최적의 가격을 찾습니다. 이 견적에는 $1%의 수수료가 자동으로 반영됩니다.",
"description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number."
},
"swapNQuotes": {
"message": "$1 견적",
"description": "$1 is the number of quotes that the user can select from when opening the list of quotes on the 'view quote' screen"
},
"swapNetworkFeeSummary": {
"message": "네트워크 수수료에는 스왑을 처리하고 $1 네트워크에 보관하는 비용이 포함됩니다. MetaMask는 이 수수료로 수익을 얻지 않습니다."
},
"swapNewQuoteIn": {
"message": "$1의 새 견적",
"description": "Tells the user the amount of time until the currently displayed quotes are update. $1 is a time that is counting down from 1:00 to 0:00"
@ -1945,10 +1912,6 @@
"swapQuoteDetailsSlippageInfo": {
"message": "주문 시점과 확인 시점 사이에 가격이 변동되는 현상을 \"슬리패지\"라고 합니다. 슬리패지가 \"최대 슬리패지\" 설정을 초과하면 스왑이 자동으로 취소됩니다."
},
"swapQuoteIncludesRate": {
"message": "견적에는 $1% MetaMask 요금이 포함됩니다.",
"description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number."
},
"swapQuoteNofN": {
"message": "$2의 $1 견적",
"description": "A count of loaded quotes shown to the user while they are waiting for quotes to be fetched. $1 is the number of quotes already loaded, and $2 is the total number of quotes to load."
@ -1956,9 +1919,6 @@
"swapQuoteSource": {
"message": "견적 소스"
},
"swapQuotesAreRefreshed": {
"message": "견적은 현재 시장 상황을 반영하도록 자주 갱신됩니다."
},
"swapQuotesExpiredErrorDescription": {
"message": "새 견적을 요청해 최신 요율을 확인하세요."
},
@ -2019,9 +1979,6 @@
"swapSwapTo": {
"message": "다음으로 스왑"
},
"swapThisWillAllowApprove": {
"message": "$1이(가) 스왑될 수 있도록 허용합니다."
},
"swapToConfirmWithHwWallet": {
"message": "하드웨어 지갑으로 확인하기 위해"
},
@ -2060,9 +2017,6 @@
"swapUnknown": {
"message": "알 수 없음"
},
"swapUsingBestQuote": {
"message": "최고 견적을 사용 중"
},
"swapVerifyTokenExplanation": {
"message": "여러 토큰이 같은 이름과 기호를 사용할 수 있습니다. $1을(를) 확인하여 원하는 토큰인지 확인하세요.",
"description": "This appears in a tooltip next to the verifyThisTokenOn message. It gives the user more information about why they should check the token on a block explorer. $1 will be the name or url of the block explorer, which will be the translation of 'etherscan' or a block explorer url specified for a custom network."

@ -218,9 +218,6 @@
"copiedExclamation": {
"message": "Nukopijuota!"
},
"copiedTransactionId": {
"message": "Nukopijuotas operacijos ID"
},
"copyAddress": {
"message": "Kopijuoti adresą į iškarpinę"
},

@ -218,9 +218,6 @@
"copiedExclamation": {
"message": "Nokopēts!"
},
"copiedTransactionId": {
"message": "Nokopētais darījuma ID"
},
"copyAddress": {
"message": "Iekopēt adresi starpliktuvē"
},

@ -218,9 +218,6 @@
"copiedExclamation": {
"message": "Disalin!"
},
"copiedTransactionId": {
"message": "ID Transaksi yang Disalin"
},
"copyAddress": {
"message": "Salin alamat kepada papan klip"
},

@ -215,9 +215,6 @@
"copiedExclamation": {
"message": "Kopiert!"
},
"copiedTransactionId": {
"message": "Kopiert transaksjonsidentifikasjon"
},
"copyAddress": {
"message": "Kopier adresse til utklippstavlen "
},

@ -416,9 +416,6 @@
"copiedExclamation": {
"message": "Nakopya na!"
},
"copiedTransactionId": {
"message": "Nakopya ang Transaction ID"
},
"copyAddress": {
"message": "Kopyahin ang address sa clipboard"
},
@ -1346,12 +1343,6 @@
"pending": {
"message": "Nakabinbin"
},
"permissionCheckedIconDescription": {
"message": "Inaprubahan mo ang pahintulot na ito"
},
"permissionUncheckedIconDescription": {
"message": "Hindi mo inaprubahan ang pahintulot na ito"
},
"permissions": {
"message": "Mga Pahintulot"
},
@ -1816,9 +1807,6 @@
"message": "Kailangan mo ng $1 pa $2 para makumpleto ang pag-swap na ito",
"description": "Tells the user how many more of a given token they need for a specific swap. $1 is an amount of tokens and $2 is the token symbol."
},
"swapBetterQuoteAvailable": {
"message": "May available na mas magandang quote"
},
"swapBuildQuotePlaceHolderText": {
"message": "Walang available na token na tumutugma sa $1",
"description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text"
@ -1845,13 +1833,6 @@
"message": "Kinakailangan ito at nagbibigay ito ng pahintulot sa MetaMask na i-swap ang iyong $1.",
"description": "Gives the user info about the required approval transaction for swaps. $1 will be the symbol of a token being approved for swaps."
},
"swapEstimatedNetworkFee": {
"message": "Tinatayang bayarin sa network"
},
"swapEstimatedNetworkFeeSummary": {
"message": "Ang “$1” ay ang inaasahan naming magiging aktuwal na bayarin. Ang eksaktong halaga ay nakadepende sa mga kundisyon ng network.",
"description": "$1 will be the translation of swapEstimatedNetworkFee, with the font bolded"
},
"swapEstimatedNetworkFees": {
"message": "Mga tinatayang bayarin sa network"
},
@ -1887,13 +1868,6 @@
"swapLowSlippageError": {
"message": "Posibleng hindi magtagumpay ang transaksyon, masyadong mababa ang max na slippage."
},
"swapMaxNetworkFeeInfo": {
"message": "“$1” ang pinakamalaking gagastusin mo. Kapag volatile ang network, maaaring malaking halaga ito.",
"description": "$1 will be the translation of swapMaxNetworkFees, with the font bolded"
},
"swapMaxNetworkFees": {
"message": "Max na bayarin sa network"
},
"swapMaxSlippage": {
"message": "Max na slippage"
},
@ -1904,13 +1878,6 @@
"message": "Hinahanap namin ang pinakasulit na presyo mula sa mga nangungunang pinagkukunan ng liquidity, sa lahat ng pagkakataon. Awtomatikong fina-factor ang bayaring $1% sa quote na ito.",
"description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number."
},
"swapNQuotes": {
"message": "$1 quote",
"description": "$1 is the number of quotes that the user can select from when opening the list of quotes on the 'view quote' screen"
},
"swapNetworkFeeSummary": {
"message": "Kasama sa bayarin sa network ang gastusin sa pagproseso ng iyong pag-swap at pag-store nito sa $1 network. Hindi kumikita ang MetaMask mula sa bayaring ito."
},
"swapNewQuoteIn": {
"message": "Mga bagong quote sa $1",
"description": "Tells the user the amount of time until the currently displayed quotes are update. $1 is a time that is counting down from 1:00 to 0:00"
@ -1945,10 +1912,6 @@
"swapQuoteDetailsSlippageInfo": {
"message": "Kung magbabago ang presyo sa pagitan ng oras ng pag-order mo at sa oras na nakumpirma ito, tinatawag itong \"slippage\". Awtomatikong makakansela ang iyong Pag-swap kung lalampas ang slippage sa iyong setting na \"tolerance ng slippage.\""
},
"swapQuoteIncludesRate": {
"message": "Kasama sa quote ang $1% bayarin sa MetaMask",
"description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number."
},
"swapQuoteNofN": {
"message": "Quote $1 ng $2",
"description": "A count of loaded quotes shown to the user while they are waiting for quotes to be fetched. $1 is the number of quotes already loaded, and $2 is the total number of quotes to load."
@ -1956,9 +1919,6 @@
"swapQuoteSource": {
"message": "Pinagkunan ng quote"
},
"swapQuotesAreRefreshed": {
"message": "Madalas na nire-refresh ang mga quote para maipakita ang mga kasalukuyang kundisyon ng market."
},
"swapQuotesExpiredErrorDescription": {
"message": "Mag-request ng mga bagong quote para makuha ang mga pinakabagong rate."
},
@ -2019,9 +1979,6 @@
"swapSwapTo": {
"message": "I-swap sa"
},
"swapThisWillAllowApprove": {
"message": "Mabibigyang-daan nito ang $1 na ma-swap."
},
"swapToConfirmWithHwWallet": {
"message": "para kumpirmahin ang iyong hardware wallet"
},
@ -2060,9 +2017,6 @@
"swapUnknown": {
"message": "Hindi Alam"
},
"swapUsingBestQuote": {
"message": "Gamit ang pinakamagandang quote"
},
"swapVerifyTokenExplanation": {
"message": "Maaaring gamitin ng maraming token ang iisang pangalan at simbolo. Suriin ang $1 para ma-verify na ito ang token na hinahanap mo.",
"description": "This appears in a tooltip next to the verifyThisTokenOn message. It gives the user more information about why they should check the token on a block explorer. $1 will be the name or url of the block explorer, which will be the translation of 'etherscan' or a block explorer url specified for a custom network."

@ -218,9 +218,6 @@
"copiedExclamation": {
"message": "Skopiowane!"
},
"copiedTransactionId": {
"message": "Skopiowano identyfikator transakcji"
},
"copyAddress": {
"message": "Skopiuj adres do schowka"
},

@ -416,9 +416,6 @@
"copiedExclamation": {
"message": "Copiado!"
},
"copiedTransactionId": {
"message": "ID da transação copiado"
},
"copyAddress": {
"message": "Copiar endereço para área de transferência"
},
@ -1346,12 +1343,6 @@
"pending": {
"message": "Pendente"
},
"permissionCheckedIconDescription": {
"message": "Você aprovou esta permissão"
},
"permissionUncheckedIconDescription": {
"message": "Você não aprovou esta permissão"
},
"permissions": {
"message": "Permissões"
},
@ -1816,9 +1807,6 @@
"message": "Você precisa de $1 mais $2 para concluir este swap",
"description": "Tells the user how many more of a given token they need for a specific swap. $1 is an amount of tokens and $2 is the token symbol."
},
"swapBetterQuoteAvailable": {
"message": "Uma cotação melhor está disponível"
},
"swapBuildQuotePlaceHolderText": {
"message": "Nenhum token disponível correspondente a $1",
"description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text"
@ -1845,13 +1833,6 @@
"message": "Isso é obrigatório e dá ao MetaMask permissão para fazer o swap do seu $1.",
"description": "Gives the user info about the required approval transaction for swaps. $1 will be the symbol of a token being approved for swaps."
},
"swapEstimatedNetworkFee": {
"message": "Taxa de rede estimada"
},
"swapEstimatedNetworkFeeSummary": {
"message": "O “$1” é o que esperamos que a taxa real seja. O valor exato depende das condições de rede.",
"description": "$1 will be the translation of swapEstimatedNetworkFee, with the font bolded"
},
"swapEstimatedNetworkFees": {
"message": "Taxas de rede estimadas"
},
@ -1887,13 +1868,6 @@
"swapLowSlippageError": {
"message": "A transação pode falhar; slippage máximo baixo demais."
},
"swapMaxNetworkFeeInfo": {
"message": "“$1” é o máximo que você gastará. Quando a rede for volátil, esse poderá ser um grande valor.",
"description": "$1 will be the translation of swapMaxNetworkFees, with the font bolded"
},
"swapMaxNetworkFees": {
"message": "Taxa máxima de rede"
},
"swapMaxSlippage": {
"message": "Slippage máximo"
},
@ -1904,13 +1878,6 @@
"message": "Encontramos o melhor preço das principais fontes de liquidez – todas as vezes. Uma taxa de $1% é automaticamente fatorada nesta cotação.",
"description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number."
},
"swapNQuotes": {
"message": "$1 cotações",
"description": "$1 is the number of quotes that the user can select from when opening the list of quotes on the 'view quote' screen"
},
"swapNetworkFeeSummary": {
"message": "A taxa de rede abrange o custo de processamento do seu swap e o armazenamento dele na rede $1. O MetaMask não lucra com essa taxa."
},
"swapNewQuoteIn": {
"message": "Novas cotações em $1",
"description": "Tells the user the amount of time until the currently displayed quotes are update. $1 is a time that is counting down from 1:00 to 0:00"
@ -1945,10 +1912,6 @@
"swapQuoteDetailsSlippageInfo": {
"message": "Quando o preço varia entre o momento em que seu pedido é feito e o momento em que é confirmado, isso recebe o nome de \"slippage\". Seu swap será automaticamente cancelado se o slippage for superior à configuração \"tolerância de slippage\"."
},
"swapQuoteIncludesRate": {
"message": "A cotação inclui uma taxa de $1% do MetaMask",
"description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number."
},
"swapQuoteNofN": {
"message": "Cotação $1 de $2",
"description": "A count of loaded quotes shown to the user while they are waiting for quotes to be fetched. $1 is the number of quotes already loaded, and $2 is the total number of quotes to load."
@ -1956,9 +1919,6 @@
"swapQuoteSource": {
"message": "Fonte da cotação"
},
"swapQuotesAreRefreshed": {
"message": "As cotações são atualizadas com frequência para refletir as condições atuais do mercado."
},
"swapQuotesExpiredErrorDescription": {
"message": "Solicite novas cotações para obter as tarifas mais recentes."
},
@ -2019,9 +1979,6 @@
"swapSwapTo": {
"message": "Swap para"
},
"swapThisWillAllowApprove": {
"message": "isso permitirá o swap de $1."
},
"swapToConfirmWithHwWallet": {
"message": "para confirmar com sua carteira de hardware"
},
@ -2060,9 +2017,6 @@
"swapUnknown": {
"message": "Desconhecido"
},
"swapUsingBestQuote": {
"message": "Usando a melhor cotação"
},
"swapVerifyTokenExplanation": {
"message": "Vários tokens podem usar o mesmo nome e símbolo. Confira $1 para verificar se esse é o token que você está buscando.",
"description": "This appears in a tooltip next to the verifyThisTokenOn message. It gives the user more information about why they should check the token on a block explorer. $1 will be the name or url of the block explorer, which will be the translation of 'etherscan' or a block explorer url specified for a custom network."

@ -218,9 +218,6 @@
"copiedExclamation": {
"message": "Copiat!"
},
"copiedTransactionId": {
"message": "ID-ul tranzacției a fost copiat"
},
"copyAddress": {
"message": "Copiere adresă în clipboard"
},

@ -416,9 +416,6 @@
"copiedExclamation": {
"message": "Скопировано!"
},
"copiedTransactionId": {
"message": "Скопированный идентификатор транзакции"
},
"copyAddress": {
"message": "Скопировать адрес в буфер обмена"
},
@ -1346,12 +1343,6 @@
"pending": {
"message": "В ожидании"
},
"permissionCheckedIconDescription": {
"message": "Вы одобрили это разрешение"
},
"permissionUncheckedIconDescription": {
"message": "Вы не одобрили это разрешение"
},
"permissions": {
"message": "Разрешения"
},
@ -1816,9 +1807,6 @@
"message": "Вам нужно еще $1 $2 для завершения этого свопа",
"description": "Tells the user how many more of a given token they need for a specific swap. $1 is an amount of tokens and $2 is the token symbol."
},
"swapBetterQuoteAvailable": {
"message": "Есть лучшая котировка"
},
"swapBuildQuotePlaceHolderText": {
"message": "Нет доступных токенов соответствующих $1",
"description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text"
@ -1845,13 +1833,6 @@
"message": "Это необходимо и дает MetaMask разрешение на своп вашего $1.",
"description": "Gives the user info about the required approval transaction for swaps. $1 will be the symbol of a token being approved for swaps."
},
"swapEstimatedNetworkFee": {
"message": "Ориентировочная комиссия сети"
},
"swapEstimatedNetworkFeeSummary": {
"message": "«$1» — это ожидаемая нами реальная комиссия. Точная сумма зависит от условий сети.",
"description": "$1 will be the translation of swapEstimatedNetworkFee, with the font bolded"
},
"swapEstimatedNetworkFees": {
"message": "Ориентировочные комиссии сети"
},
@ -1887,13 +1868,6 @@
"swapLowSlippageError": {
"message": "Транзакции могут завершиться неудачей, максимальное проскальзывание слишком мало."
},
"swapMaxNetworkFeeInfo": {
"message": "«$1» — это максимальная сумма, которую вы потратите. Когда сеть нестабильна, это может быть большая сумма.",
"description": "$1 will be the translation of swapMaxNetworkFees, with the font bolded"
},
"swapMaxNetworkFees": {
"message": "Максимальная комиссия за сеть"
},
"swapMaxSlippage": {
"message": "Максимальное проскальзывание"
},
@ -1904,13 +1878,6 @@
"message": "Мы всегда находим лучшую цену из лучших источников ликвидности. В эту котировку автоматически включается комиссия в размере $1%.",
"description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number."
},
"swapNQuotes": {
"message": "Котировок: $1",
"description": "$1 is the number of quotes that the user can select from when opening the list of quotes on the 'view quote' screen"
},
"swapNetworkFeeSummary": {
"message": "Сетевая комиссия покрывает стоимость обработки вашего свопа и его хранения в сети $1. MetaMask не получает прибыли от этой комиссии."
},
"swapNewQuoteIn": {
"message": "Новые котировки в $1",
"description": "Tells the user the amount of time until the currently displayed quotes are update. $1 is a time that is counting down from 1:00 to 0:00"
@ -1945,10 +1912,6 @@
"swapQuoteDetailsSlippageInfo": {
"message": "Изменение цены в период между размещением заказа и подтверждением называется проскальзыванием. Своп будет автоматически отменен, если фактическое проскальзывание превысит установленное допустимое значение."
},
"swapQuoteIncludesRate": {
"message": "Котировка включает $1% MetaMask fee",
"description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number."
},
"swapQuoteNofN": {
"message": "Котировка $1 из $2",
"description": "A count of loaded quotes shown to the user while they are waiting for quotes to be fetched. $1 is the number of quotes already loaded, and $2 is the total number of quotes to load."
@ -1956,9 +1919,6 @@
"swapQuoteSource": {
"message": "Источник котировки"
},
"swapQuotesAreRefreshed": {
"message": "Котировки часто обновляются, чтобы отражать текущие рыночные условия."
},
"swapQuotesExpiredErrorDescription": {
"message": "Запрашивайте новые котировки, чтобы узнать последние цены."
},
@ -2019,9 +1979,6 @@
"swapSwapTo": {
"message": "Своп на"
},
"swapThisWillAllowApprove": {
"message": "Это позволит обмен $1."
},
"swapToConfirmWithHwWallet": {
"message": "подтвердить с помощью аппаратного кошелька"
},
@ -2060,9 +2017,6 @@
"swapUnknown": {
"message": "Неизвестный"
},
"swapUsingBestQuote": {
"message": "Используется лучшая котировка"
},
"swapVerifyTokenExplanation": {
"message": "Несколько токенов могут использовать одно и то же имя и символ. Убедитесь, что это именно тот токен, который вы ищете, на $1.",
"description": "This appears in a tooltip next to the verifyThisTokenOn message. It gives the user more information about why they should check the token on a block explorer. $1 will be the name or url of the block explorer, which will be the translation of 'etherscan' or a block explorer url specified for a custom network."

@ -212,9 +212,6 @@
"copiedExclamation": {
"message": "Zkopírováno!"
},
"copiedTransactionId": {
"message": "Kopírované ID transakcie"
},
"copyAddress": {
"message": "Kopírovať adresu do schránky"
},

@ -218,9 +218,6 @@
"copiedExclamation": {
"message": "Kopirano!"
},
"copiedTransactionId": {
"message": "ID transakcije skopirana"
},
"copyAddress": {
"message": "Kopiraj naslov v odložišče"
},

@ -215,9 +215,6 @@
"copiedExclamation": {
"message": "Kopirano!"
},
"copiedTransactionId": {
"message": "Kopiran identifikator transakcije"
},
"copyAddress": {
"message": "Kopirajte adresu u ostavu"
},

@ -212,9 +212,6 @@
"copiedExclamation": {
"message": "Kopierades!"
},
"copiedTransactionId": {
"message": "Kopierade transaktions-ID"
},
"copyAddress": {
"message": "Kopiera adress till urklipp"
},

@ -212,9 +212,6 @@
"copiedExclamation": {
"message": "Imenakiliwa!"
},
"copiedTransactionId": {
"message": "Imenakili Utambulisho wa Muamala"
},
"copyAddress": {
"message": "Nakili anwani kwenye ubao wa kunakilia"
},

@ -344,9 +344,6 @@
"copiedExclamation": {
"message": "Nakopya na!"
},
"copiedTransactionId": {
"message": "Nakopya ang ID ng Transaksyon"
},
"copyAddress": {
"message": "Kopyahin ang address sa clipboard"
},
@ -1096,12 +1093,6 @@
"pending": {
"message": "Nakabinbin"
},
"permissionCheckedIconDescription": {
"message": "Inaprubahan mo ang pahintulot na ito"
},
"permissionUncheckedIconDescription": {
"message": "Hindi mo inaprubahan ang pahintulot na ito"
},
"permissions": {
"message": "Mga Pahintulot"
},
@ -1486,13 +1477,6 @@
"message": "Kinakailangan ito at nagbibigay ito ng pahintulot sa MetaMask na i-swap ang iyong $1.",
"description": "Gives the user info about the required approval transaction for swaps. $1 will be the symbol of a token being approved for swaps."
},
"swapEstimatedNetworkFee": {
"message": "Tinatayang bayarin sa network"
},
"swapEstimatedNetworkFeeSummary": {
"message": "Ang “$1” ay ang inaasahan naming magiging aktuwal na bayarin. Ang eksaktong halaga ay nakadepende sa mga kundisyon ng network.",
"description": "$1 will be the translation of swapEstimatedNetworkFee, with the font bolded"
},
"swapEstimatedNetworkFees": {
"message": "Mga tinatayang bayarin sa network"
},
@ -1514,13 +1498,6 @@
"swapLowSlippageError": {
"message": "Maaaring hindi magtagumpay ang transaksyon, masyadong mababa ang max na slippage."
},
"swapMaxNetworkFeeInfo": {
"message": "Aang “$1” ay ang pinakamalaking gagastusin mo. Kapag volatile ang network, maaaring malaking halaga ito.",
"description": "$1 will be the translation of swapMaxNetworkFees, with the font bolded"
},
"swapMaxNetworkFees": {
"message": "Max na bayarin sa network"
},
"swapMaxSlippage": {
"message": "Max na slippage"
},
@ -1531,9 +1508,6 @@
"message": "Hinahanap namin ang pinakasulit na presyo mula sa mga nangungunang pinagkukunan ng liquidity, sa lahat ng pagkakataon. Ang bayarin na $1% ay awtomatikong fina-factor sa bawat quote, na sumusuporta sa kasalukuyang development para mas mapahusay ang MetaMask.",
"description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number."
},
"swapNetworkFeeSummary": {
"message": "Kasama sa bayarin sa network ang gastusin sa pagproseso ng iyong pag-swap at pag-store nito sa $1 network. Hindi kumikita ang MetaMask mula sa bayaring ito."
},
"swapNewQuoteIn": {
"message": "Mga bagong quote sa $1",
"description": "Tells the user the amount of time until the currently displayed quotes are update. $1 is a time that is counting down from 1:00 to 0:00"
@ -1551,10 +1525,6 @@
"swapQuoteDetailsSlippageInfo": {
"message": "Kung magbabago ang presyo sa pagitan ng oras ng pag-order mo at sa oras na nakumpirma ito, tinatawag itong \"slippage\". Awtomatikong makakansela ang iyong Pag-swap kung lalampas ang slippage sa iyong setting na \"max slippage\"."
},
"swapQuoteIncludesRate": {
"message": "Kasama sa quote ang $1% bayarin sa MetaMask",
"description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number."
},
"swapQuoteNofN": {
"message": "Quote $1 ng $2",
"description": "A count of loaded quotes shown to the user while they are waiting for quotes to be fetched. $1 is the number of quotes already loaded, and $2 is the total number of quotes to load."
@ -1562,9 +1532,6 @@
"swapQuoteSource": {
"message": "Pinagkunan ng quote"
},
"swapQuotesAreRefreshed": {
"message": "Madalas na nire-refresh ang mga quote para maipakita ang mga kasalukuyang kundisyon ng market."
},
"swapQuotesExpiredErrorDescription": {
"message": "Mag-request ng mga bagong quote para makuha ang mga pinakabagong rate."
},
@ -1619,9 +1586,6 @@
"swapSwapTo": {
"message": "Palitan ng"
},
"swapThisWillAllowApprove": {
"message": "Mabibigyang-daan nito ang $1 na ma-swap."
},
"swapTokenAvailable": {
"message": "Naidagdag na ang $1 sa iyong account.",
"description": "This message is shown after a swap is successful and communicates the exact amount of tokens the user has received for a swap. The $1 is a decimal number of tokens followed by the token symbol."

@ -218,9 +218,6 @@
"copiedExclamation": {
"message": "Скопійовано!"
},
"copiedTransactionId": {
"message": "ID Скопійованої транзакції"
},
"copyAddress": {
"message": "Копіювати адресу в буфер обміну"
},

@ -416,9 +416,6 @@
"copiedExclamation": {
"message": "Đã sao chép!"
},
"copiedTransactionId": {
"message": "Đã sao chép mã giao dịch"
},
"copyAddress": {
"message": "Sao chép địa chỉ vào khay nhớ tạm"
},
@ -1346,12 +1343,6 @@
"pending": {
"message": "Đang chờ xử lý"
},
"permissionCheckedIconDescription": {
"message": "Bạn đã phê duyệt quyền này"
},
"permissionUncheckedIconDescription": {
"message": "Bạn chưa phê duyệt quyền này"
},
"permissions": {
"message": "Quyền"
},
@ -1816,9 +1807,6 @@
"message": "Bạn cần $1 $2 nữa để hoàn tất giao dịch hoán đổi này",
"description": "Tells the user how many more of a given token they need for a specific swap. $1 is an amount of tokens and $2 is the token symbol."
},
"swapBetterQuoteAvailable": {
"message": "Có một báo giá tốt hơn"
},
"swapBuildQuotePlaceHolderText": {
"message": "Không có token nào khớp với $1",
"description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text"
@ -1845,13 +1833,6 @@
"message": "Thao tác này là bắt buộc và cấp cho MetaMask quyền hoán đổi $1 của bạn.",
"description": "Gives the user info about the required approval transaction for swaps. $1 will be the symbol of a token being approved for swaps."
},
"swapEstimatedNetworkFee": {
"message": "Phí mạng ước tính"
},
"swapEstimatedNetworkFeeSummary": {
"message": "“$1” là giá trị mà chúng tôi dự kiến sẽ là khoản phí thực sự. Số tiền chính xác phụ thuộc vào tình trạng mạng.",
"description": "$1 will be the translation of swapEstimatedNetworkFee, with the font bolded"
},
"swapEstimatedNetworkFees": {
"message": "Phí mạng ước tính"
},
@ -1887,13 +1868,6 @@
"swapLowSlippageError": {
"message": "Giao dịch có thể không thành công, mức trượt giá tối đa quá thấp."
},
"swapMaxNetworkFeeInfo": {
"message": "“$1” là mức chi tiêu cao nhất của bạn. Khi mạng không ổn định, đây có thể là số tiền lớn.",
"description": "$1 will be the translation of swapMaxNetworkFees, with the font bolded"
},
"swapMaxNetworkFees": {
"message": "Phí mạng tối đa"
},
"swapMaxSlippage": {
"message": "Mức trượt giá tối đa"
},
@ -1904,13 +1878,6 @@
"message": "Chúng tôi luôn tìm giá tốt nhất từ các nguồn thanh khoản hàng đầu. Phí $1% được tự động tính vào báo giá này.",
"description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number."
},
"swapNQuotes": {
"message": "$1 báo giá",
"description": "$1 is the number of quotes that the user can select from when opening the list of quotes on the 'view quote' screen"
},
"swapNetworkFeeSummary": {
"message": "Phí mạng dùng để chi trả chi phí xử lý giao dịch hoán đổi của bạn và lưu trữ giao dịch đó trên mạng $1. MetaMask không thu lợi từ khoản phí này."
},
"swapNewQuoteIn": {
"message": "Báo giá mới sẽ có sau $1",
"description": "Tells the user the amount of time until the currently displayed quotes are update. $1 is a time that is counting down from 1:00 to 0:00"
@ -1945,10 +1912,6 @@
"swapQuoteDetailsSlippageInfo": {
"message": "Khi giá giữa thời điểm đặt lệnh và thời điểm xác nhận lệnh thay đổi, hiện tượng này được gọi là \"trượt giá\". Giao dịch hoán đổi của bạn sẽ tự động hủy nếu mức trượt giá vượt quá \"mức trượt giá cho phép\" đã đặt."
},
"swapQuoteIncludesRate": {
"message": "Báo giá có bao gồm khoản phí $1% cho MetaMask",
"description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number."
},
"swapQuoteNofN": {
"message": "Báo giá $1/$2",
"description": "A count of loaded quotes shown to the user while they are waiting for quotes to be fetched. $1 is the number of quotes already loaded, and $2 is the total number of quotes to load."
@ -1956,9 +1919,6 @@
"swapQuoteSource": {
"message": "Nguồn báo giá"
},
"swapQuotesAreRefreshed": {
"message": "Báo giá được làm mới thường xuyên để thể hiện tình trạng hiện tại của thị trường."
},
"swapQuotesExpiredErrorDescription": {
"message": "Vui lòng yêu cầu báo giá mới để biết các mức tỷ lệ mới nhất."
},
@ -2019,9 +1979,6 @@
"swapSwapTo": {
"message": "Hoán đổi sang"
},
"swapThisWillAllowApprove": {
"message": "Thao tác này sẽ cho phép hoán đổi $1."
},
"swapToConfirmWithHwWallet": {
"message": "để xác nhận ví cứng của bạn"
},
@ -2060,9 +2017,6 @@
"swapUnknown": {
"message": "Không xác định"
},
"swapUsingBestQuote": {
"message": "Sử dụng báo giá tốt nhất"
},
"swapVerifyTokenExplanation": {
"message": "Nhiều token có thể dùng cùng một tên và ký hiệu. Hãy kiểm tra $1 để xác minh xem đây có phải là token bạn đang tìm kiếm không.",
"description": "This appears in a tooltip next to the verifyThisTokenOn message. It gives the user more information about why they should check the token on a block explorer. $1 will be the name or url of the block explorer, which will be the translation of 'etherscan' or a block explorer url specified for a custom network."

@ -1,4 +1,46 @@
{
"QRHardwareInvalidTransactionTitle": {
"message": "非法交易"
},
"QRHardwareMismatchedSignId": {
"message": "扫描的签名二维码不属于当前交易,请检查交易详情后重试。"
},
"QRHardwarePubkeyAccountOutOfRange": {
"message": "暂无更多账户,若想切换到其他账户,请在硬件钱包中选择想要的账户重新同步。"
},
"QRHardwareScanInstructions": {
"message": "为了保护您的隐私,屏幕是模糊的,但不影响对二维码的读取。"
},
"QRHardwareSignRequestCancel": {
"message": "拒绝该交易"
},
"QRHardwareSignRequestDescription": {
"message": "硬件钱包扫描上方二维码完成签名后,点击“获取签名”按钮扫描已签名的二维码"
},
"QRHardwareSignRequestGetSignature": {
"message": "获取签名"
},
"QRHardwareSignRequestSubtitle": {
"message": "用硬件钱包扫描二维码"
},
"QRHardwareSignRequestTitle": {
"message": "获取签名"
},
"QRHardwareUnknownQRCodeTitle": {
"message": "非法二维码"
},
"QRHardwareUnknownWalletQRCode": {
"message": "请扫描硬件钱包的同步二维码。"
},
"QRHardwareWalletImporterTitle": {
"message": "扫描二维码"
},
"QRHardwareWalletSteps1Description": {
"message": "该类硬件钱包通过二维码实现通讯交互,做到完全脱网。官方支持的钱包有:"
},
"QRHardwareWalletSteps2Description": {
"message": "AirGap Vault & Ngrave (即将上线)"
},
"about": {
"message": "关于"
},
@ -353,9 +395,6 @@
"copiedExclamation": {
"message": "已复制"
},
"copiedTransactionId": {
"message": "交易 ID 复制成功"
},
"copyAddress": {
"message": "复制地址到剪贴板"
},
@ -835,6 +874,12 @@
"message": "JSON 文件",
"description": "format for importing an account"
},
"keystone": {
"message": "铠石钱包"
},
"keystoneTutorial": {
"message": " (使用教程)"
},
"knownAddressRecipient": {
"message": "已知接收方地址。"
},
@ -1099,12 +1144,6 @@
"pending": {
"message": "待处理"
},
"permissionCheckedIconDescription": {
"message": "您已同意该权限"
},
"permissionUncheckedIconDescription": {
"message": "您还未同意该权限"
},
"permissions": {
"message": "权限"
},
@ -1472,9 +1511,6 @@
"message": "您还需 $1 $2 来完成这笔兑换",
"description": "Tells the user how many more of a given token they need for a specific swap. $1 is an amount of tokens and $2 is the token symbol."
},
"swapBetterQuoteAvailable": {
"message": "有一个可用的更优报价"
},
"swapBuildQuotePlaceHolderText": {
"message": "没有匹配的代币符合 $1",
"description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text"
@ -1492,13 +1528,6 @@
"message": "这是必须的,并且允许 MetaMask 兑换您的 $1。",
"description": "Gives the user info about the required approval transaction for swaps. $1 will be the symbol of a token being approved for swaps."
},
"swapEstimatedNetworkFee": {
"message": "预计网络手续费"
},
"swapEstimatedNetworkFeeSummary": {
"message": "“$1”是我们预计的实际产生费用。具体数额视网络情况而定。",
"description": "$1 will be the translation of swapEstimatedNetworkFee, with the font bolded"
},
"swapEstimatedNetworkFees": {
"message": "预计网络手续费"
},
@ -1520,13 +1549,6 @@
"swapLowSlippageError": {
"message": "交易可能失败,最大滑点过低。"
},
"swapMaxNetworkFeeInfo": {
"message": "“$1”是您最多所话费的数量,当网络不稳定时,这可能是一个大的数额。",
"description": "$1 will be the translation of swapMaxNetworkFees, with the font bolded"
},
"swapMaxNetworkFees": {
"message": "最大网络手续费"
},
"swapMaxSlippage": {
"message": "最大滑点"
},
@ -1537,13 +1559,6 @@
"message": "我们每次都能从顶级流动性资源中找到最好的价格。每次报价都会自动收取1%的手续费用,以支持 MetaMask 的持续发展,使其更加完善。",
"description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number."
},
"swapNQuotes": {
"message": "$1 个报价",
"description": "$1 is the number of quotes that the user can select from when opening the list of quotes on the 'view quote' screen"
},
"swapNetworkFeeSummary": {
"message": "网络手续费包括处理您的兑换和在以太坊($1)网络上存储的成本。MetaMask 不从这笔费用中获利。"
},
"swapNewQuoteIn": {
"message": "$1 后更新报价",
"description": "Tells the user the amount of time until the currently displayed quotes are update. $1 is a time that is counting down from 1:00 to 0:00"
@ -1569,10 +1584,6 @@
"swapQuoteDetailsSlippageInfo": {
"message": "如果在您下订单和确认订单之间的价格发生了变化,这就叫做\"滑点\"。如果滑点超过您的\"最大滑点\"设置,您的兑换将自动取消。"
},
"swapQuoteIncludesRate": {
"message": "报价包含 $1% MetaMask 手续费",
"description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number."
},
"swapQuoteNofN": {
"message": "报价 $1 / $2",
"description": "A count of loaded quotes shown to the user while they are waiting for quotes to be fetched. $1 is the number of quotes already loaded, and $2 is the total number of quotes to load."
@ -1580,9 +1591,6 @@
"swapQuoteSource": {
"message": "报价来源"
},
"swapQuotesAreRefreshed": {
"message": "报价会经常刷新,以反映当前的市场状况。"
},
"swapQuotesExpiredErrorDescription": {
"message": "请请求新的报价,以获得最新的价格。"
},
@ -1637,9 +1645,6 @@
"swapSwapTo": {
"message": "兑换到"
},
"swapThisWillAllowApprove": {
"message": "这样将允许 $1 用于兑换。"
},
"swapTokenAvailable": {
"message": "您的 $1 已添加到您的账户。",
"description": "This message is shown after a swap is successful and communicates the exact amount of tokens the user has received for a swap. The $1 is a decimal number of tokens followed by the token symbol."
@ -1654,9 +1659,6 @@
"swapUnknown": {
"message": "未知的"
},
"swapUsingBestQuote": {
"message": "使用最好的报价"
},
"swapVerifyTokenExplanation": {
"message": "多个代币可以使用相同的名称和符号。检查 $1(以太坊浏览器)以确认这是您正在寻找的代币。",
"description": "This appears in a tooltip next to the verifyThisTokenOn message. It gives the user more information about why they should check the token on a block explorer. $1 will be the name or url of the block explorer, which will be the translation of 'etherscan' or a block explorer url specified for a custom network."

@ -227,9 +227,6 @@
"copiedExclamation": {
"message": "已複製!"
},
"copiedTransactionId": {
"message": "已複製的交易 ID"
},
"copyAddress": {
"message": "複製到剪貼簿"
},

@ -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 @@
<svg width="14" height="14" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M4.022 4.82c0 .275.22.469.483.482l3.26-.082L3.8 9.183a.451.451 0 0 0 0 .663l.442.442c.18.18.47.193.663 0l3.963-3.964-.082 3.232a.49.49 0 0 0 .47.497h.607a.484.484 0 0 0 .47-.47V4.199a.46.46 0 0 0-.456-.456H4.49a.484.484 0 0 0-.47.47v.607Z" fill="#219E37"/></svg>

After

Width:  |  Height:  |  Size: 347 B

@ -0,0 +1,26 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="16" height="16" fill="#E5E5E5"/>
<g clip-path="url(#clip0_0_1)">
<rect width="413" height="679" transform="translate(-57 -402)" fill="white"/>
<g filter="url(#filter0_d_0_1)">
<path d="M-31 -376C-31 -380.418 -27.4183 -384 -23 -384H321C325.418 -384 329 -380.418 329 -376V252C329 256.418 325.418 260 321 260H-23C-27.4183 260 -31 256.418 -31 252V-376Z" fill="white"/>
</g>
<circle cx="8" cy="8" r="7.5" fill="#F2F3F4" stroke="#D6D9DC"/>
<line x1="4.1001" y1="7.83325" x2="11.7668" y2="7.83325" stroke="#D6D9DC" stroke-linecap="round"/>
</g>
<rect x="-2918.5" y="-1621.5" width="9747" height="4874" stroke="black"/>
<defs>
<filter id="filter0_d_0_1" x="-58" y="-409" width="414" height="698" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="2"/>
<feGaussianBlur stdDeviation="13.5"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.12594 0 0 0 0 0.182502 0 0 0 0 0.305378 0 0 0 0.26 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_0_1"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_0_1" result="shape"/>
</filter>
<clipPath id="clip0_0_1">
<rect width="413" height="679" fill="white" transform="translate(-57 -402)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@ -0,0 +1,28 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="16" height="16" fill="#E5E5E5"/>
<g clip-path="url(#clip0_0_1)">
<rect width="413" height="679" transform="translate(-57 -546)" fill="white"/>
<g filter="url(#filter0_d_0_1)">
<path d="M-31 -520C-31 -524.418 -27.4183 -528 -23 -528H321C325.418 -528 329 -524.418 329 -520V108C329 112.418 325.418 116 321 116H-23C-27.4183 116 -31 112.418 -31 108V-520Z" fill="white"/>
</g>
<line x1="7.5" y1="-128" x2="7.49999" y2="8" stroke="#D6D9DC"/>
<circle cx="8" cy="8" r="7.5" fill="white" stroke="#D6D9DC"/>
<line x1="4.1001" y1="7.83325" x2="11.7668" y2="7.83325" stroke="#D6D9DC" stroke-linecap="round"/>
<line x1="8.1001" y1="4.16699" x2="8.1001" y2="11.8337" stroke="#D6D9DC" stroke-linecap="round"/>
</g>
<rect x="-2918.5" y="-1765.5" width="9747" height="4874" stroke="black"/>
<defs>
<filter id="filter0_d_0_1" x="-58" y="-553" width="414" height="698" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="2"/>
<feGaussianBlur stdDeviation="13.5"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.12594 0 0 0 0 0.182502 0 0 0 0 0.305378 0 0 0 0.26 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_0_1"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_0_1" result="shape"/>
</filter>
<clipPath id="clip0_0_1">
<rect width="413" height="679" fill="white" transform="translate(-57 -546)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 358 B

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="297px" height="101px" viewBox="0 0 297 101" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>png-chahua</title>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="png-chahua" transform="translate(1.000000, 0.000000)">
<g id="P-1">
<g id="png-key" transform="translate(0.000000, 22.000000)">
<circle id="oval" stroke="#3098DC" fill="#D9F0FF" cx="32" cy="32" r="32"></circle>
<g id="png_logo" transform="translate(15.850003, 11.617735)">
<path d="M13.4677287,26.0869239 C13.6814978,26.2099569 13.8780148,26.3607837 14.0521451,26.5354635 L20.8323207,33.3370367 C22.2800205,34.7893053 22.3263651,37.1092074 20.9740009,38.6170536 L20.8237527,38.7754247 C20.8235032,38.7756734 20.8232537,38.7759221 20.8230041,38.7761707 L19.8377351,39.7578004 C18.7942353,40.7974458 17.2102004,41.0696737 15.8794816,40.438055 L8.37983921,36.8783886 C6.63920381,36.052205 5.89789413,33.9713871 6.7240777,32.2307517 C6.76351709,32.1476593 6.80622708,32.066159 6.85210754,31.986442 L9.6565953,27.1136614 C10.425485,25.7777203 12.1317875,25.3180342 13.4677287,26.0869239 Z M16.4259709,0.276152586 C16.7491885,0.460847609 17.0173036,0.728398213 17.2026794,1.05122584 L18.5283228,3.35980278 C19.1466826,4.43666186 19.1460308,5.76104031 18.5266115,6.83729026 L3.68360588,32.6272236 C3.39176095,33.1343083 2.7441005,33.3087947 2.23701577,33.0169497 C2.07620665,32.9243985 1.94252531,32.7912356 1.8493499,32.6307873 C1.23868219,31.579216 0.773046665,30.4498818 0.465104842,29.2734937 L0.222743083,28.3476321 C-0.230049649,26.6178897 0.0132040067,24.7795765 0.900309637,23.2271354 L13.5700193,1.05505383 C14.1435809,0.0513169242 15.422234,-0.297409042 16.4259709,0.276152586 Z M22.314756,10.7149284 C22.7424128,10.9610537 23.0971746,11.3161563 23.3428892,11.7440492 L26.2513718,16.8089476 C27.0189573,18.1456385 26.5576062,19.8514916 25.2209153,20.6190771 C24.7979802,20.8619446 24.3187864,20.9897452 23.8310788,20.9897452 L17.4043388,20.9897452 C16.0556092,20.9897452 14.9622479,19.8963838 14.9622479,18.5476542 C14.9622479,18.1201149 15.0744902,17.7000673 15.2877511,17.3295143 L18.5036388,11.7417258 C19.2725074,10.4057726 20.9788028,9.94605968 22.314756,10.7149284 Z" id="combine" fill="#000000"></path>
<path d="M31.1744255,25.3821635 C31.8384048,26.538444 31.6433147,27.9964878 30.6987072,28.937535 L27.4928953,32.1312641 C26.2639847,33.3541704 24.2764867,33.3507256 23.0518226,32.1235666 L17.3441941,26.3978915 C16.6640589,25.7156057 16.6658022,24.6111453 17.348088,23.9310101 C17.6750575,23.6050712 18.1179052,23.422047 18.5795817,23.422047 L27.7885506,23.422047 C29.1867243,23.422047 30.4781713,24.1696784 31.1744255,25.3821635 Z" id="path" fill="#2161FF"></path>
</g>
</g>
<path d="M83,17.0235687 L73.4978847,30 L64.0002855,17.0235687 L73.4973317,22.4634417 L73.4978847,22.4646936 L83,17.0235687 Z M73.4973317,11.0923553 L82.9936676,15.2782188 L73.4978847,20.720593 L64.0002855,15.2782188 L73.4973317,11.0923553 Z M73.4970462,0 L82.9936676,15.2781544 L73.4978847,11.0897901 L64,15.2781544 L73.4970462,0 Z" id="combine" fill="#3098DC" fill-rule="nonzero"></path>
<g id="png-key" transform="translate(64.000000, 77.000000)" fill-rule="nonzero">
<g id="Airgap_Logo_sideways_color-xs">
<path d="M9.79205788,0.504426266 C9.79205788,0.0716219234 9.47417837,-0.113865652 9.09272297,0.0716219234 C9.09272297,0.0716219234 7.56690134,0.937230609 6.35895922,1.49369334 C4.96028939,2.11198525 2.73513285,2.73027717 1.33646303,2.97759394 C0.446400413,3.16308152 0.319248611,3.71954424 0.25567271,4.21417778 C0.192096809,5.01795727 0.128520908,6.00722434 0.128520908,6.81100384 C0.0649450067,8.91319636 -0.125782697,11.0772181 0.128520908,13.1175814 C0.25567271,14.3541652 0.637128117,15.7762367 1.20931123,16.8891621 C2.09937384,18.4967211 3.24374006,19.9806217 4.57883399,21.2172055 C5.97750381,22.4537894 9.02914707,23.8758608 9.02914707,23.8758608 C9.47417837,24.0613484 9.79205788,23.8758608 9.79205788,23.3812273 L9.79205788,0.504426266 Z" id="path" fill="#3098DC"></path>
<path d="M12.2079421,23.4430564 C12.2079421,23.8758608 12.5258216,24.1231776 12.9708529,23.93769 C12.9708529,23.93769 16.0224962,22.5156186 17.421166,21.2790347 C18.8198358,20.0424509 19.9642021,18.5585503 20.7906888,16.9509913 C21.3628719,15.8380658 21.7443273,14.4159944 21.8714791,13.1794106 C22.1257827,11.1390473 21.935055,8.91319636 21.8714791,6.87283303 C21.8714791,6.06905353 21.8079032,5.07978646 21.7443273,4.27600697 C21.6807514,3.71954424 21.5535996,3.16308152 20.663537,3.03942313 C19.2648671,2.79210637 17.0397106,2.17381445 15.6410408,1.55552253 C14.4966746,0.999059801 12.907277,0.133451115 12.907277,0.133451115 C12.5258216,-0.113865652 12.2079421,0.0716219234 12.2079421,0.566255458 L12.2079421,23.4430564 L12.2079421,23.4430564 Z" id="path" fill="#3098DC"></path>
<path d="M9.85563378,1.18454738 L9.8542943,3.37036304 C9.98221357,3.6905739 10.2954143,3.90503182 10.6821205,3.90503182 L10.6821205,3.90503182 L11.3814554,3.90503182 L11.3814554,4.09051939 L9.85479032,4.08954738 L9.85479032,8.54154738 L12.8437011,8.54222121 L12.8437011,13.7358733 L9.85479032,13.7355474 L9.85479032,17.8165474 L12.907277,17.8166 C13.8609156,17.8166 14.5602505,18.5585503 14.5602505,19.424159 C14.5602505,20.2897677 13.8609156,21.031718 12.907277,21.031718 L9.85479032,21.0315474 L9.85563378,22.9484229 L9.53775427,22.8247645 C8.01193265,22.1446434 6.42253512,21.4026931 5.214593,20.2897677 C3.94307498,19.1768422 2.92586056,17.6929416 2.16294974,16.2708702 C1.65434253,15.2816031 1.33646303,14.0450193 1.20931123,12.9320938 C0.955007622,11.0772181 1.14573533,9.16051313 1.20931123,7.30563737 C1.20931123,6.3163703 1.27288713,5.26527404 1.40003893,4.21417778 C1.40003893,4.21417778 4.26095448,3.53405667 6.29538332,2.79210637 C7.50332544,2.29747283 8.64769166,1.8028393 9.85563378,1.18454738 Z" id="combine" fill="#FFFFFF"></path>
<path d="M12.3350939,1.18454738 C13.4158842,1.8028393 14.5602505,2.35930202 15.7681926,2.79210637 C17.7390455,3.59588586 20.663537,4.21417778 20.663537,4.21417778 L20.663537,4.21417778 L20.8542647,7.36746656 C20.9178406,9.22234232 21.0449924,11.1390473 20.8542647,12.993923 C20.7271129,14.1068485 20.4092334,15.3434323 19.9006262,16.3326994 C19.0741394,17.7547708 18.1205009,19.2386714 16.8489829,20.3515969 C15.5774649,21.4645223 14.0516433,22.2064726 12.5258216,22.8865937 L12.5258216,22.8865937 L12.3350939,23.0102521 L12.3343299,17.8165474 L8.32981215,17.8166 C7.56690134,17.8166 6.99471823,17.1983081 6.99471823,16.4563578 C6.99471823,15.7144075 7.63047724,15.1579447 8.32981215,15.1579447 L12.3343299,15.1575474 L12.3343299,12.3135474 L10.8728482,12.3138019 C10.3006651,12.3138019 9.85563378,11.8191684 9.85563378,11.2627056 C9.85563378,10.9631464 9.98460231,10.699423 10.1929484,10.5197638 L8.39338805,10.5207553 C7.24902183,10.5207553 6.35895922,9.59331747 6.42253512,8.54222121 C6.42253512,7.49112495 7.31259773,6.62551626 8.39338805,6.62551626 L12.3343299,6.62454738 Z" id="combine" fill="#3098DC"></path>
<path d="M14.5602505,6.56368707 L4.83313759,6.56368707 C4.07022678,6.56368707 3.49804367,5.94539515 3.49804367,5.20344485 C3.49804367,4.46149455 4.13380268,3.90503182 4.83313759,3.90503182 L14.5602505,3.90503182 C15.3231613,3.90503182 15.8953444,4.52332374 15.8953444,5.26527404 C15.8317685,6.00722434 15.2595854,6.56368707 14.5602505,6.56368707 Z" id="path" fill="#FFFFFF"></path>
<path d="M14.0516433,15.1579447 L4.83313759,15.1579447 C4.00665088,15.1579447 3.37089187,14.5396528 3.37089187,13.7358733 C3.37089187,12.9320938 4.00665088,12.3138019 4.83313759,12.3138019 L14.1152192,12.3138019 C14.9417059,12.3138019 15.5774649,12.9320938 15.5774649,13.7358733 C15.5774649,14.5396528 14.87813,15.1579447 14.0516433,15.1579447 L14.0516433,15.1579447 Z" id="path" fill="#FFFFFF"></path>
</g>
</g>
</g>
<g id="Group" transform="translate(187.000000, 17.000000)">
<g id="cp" stroke="#3098DC">
<path d="M10,0 L98,0 C100.209139,-4.05812251e-16 102,1.790861 102,4 L102,69 L102,69 L6,69 L6,4 C6,1.790861 7.790861,2.18216909e-15 10,0 Z" id="rectangle" fill="#FFFFFF"></path>
<rect id="rectangle" fill="#FFFFFF" x="10.5" y="4.5" width="87" height="57"></rect>
<rect id="rectangle" fill="#D9F0FF" x="0" y="66" width="108" height="7" rx="1"></rect>
</g>
<g id="5" transform="translate(35.000000, 17.000000)" fill-rule="nonzero">
<g id="4">
<path d="M35.7940726,0.865647154 L37.3076975,5.33187939 L36.0572619,11.2664578 L35.3130052,12.3918268 L36.2488109,13.108213 L35.0978726,14.1466087 L35.8822714,14.7050297 L34.478956,16.3199413 L37.0166977,24.0476925 L34.9619918,30.9406553 L27.2570674,28.8445942 L25.4588663,30.2992958 L22.489449,32.3310811 L15.4970666,32.3310811 L12.5415596,30.2997394 L10.7428766,28.8445914 L3.03908343,30.9403669 L0.996819559,24.0470926 L3.50539453,16.3206129 L2.11516087,14.7068706 L2.90215816,14.146619 L1.74643066,13.1038982 L2.66706954,12.387498 L1.5590983,11.552182 L0.692123065,5.33103555 L2.19338019,0.865960951 L13.1228366,4.89862071 L24.8771779,4.89862071 L35.7940726,0.865647154 Z" id="symbol" stroke="#3098DC" stroke-width="1.33783784"></path>
<path d="M20.5,27.5 L21,30 L16.5,30 L17,27.5 L20.5,27.5 Z M14.3439693,19.0321194 L15.8156414,22.0321194 L10.8156414,20.6010552 L14.3439693,19.0321194 Z M23.2820965,19.0321194 L26.8156414,20.6010552 L21.8156414,22.0321194 L23.2820965,19.0321194 Z" id="combine" fill="#3098DC"></path>
</g>
</g>
</g>
<g id="code" transform="translate(82.000000, 37.000000)">
<g id="p-2" transform="translate(26.000000, 0.000000)" fill="#3098DC" fill-rule="nonzero">
<g id="qr_code">
<path d="M1.94444444,11.6666667 L9.72222222,11.6666667 C10.7961092,11.6666667 11.6666667,10.7961092 11.6666667,9.72222222 L11.6666667,1.94444444 C11.6666667,0.870557431 10.7961092,0 9.72222222,0 L1.94444444,0 C0.870557431,0 0,0.870557431 0,1.94444444 L0,9.72222222 C0,10.7961092 0.870557431,11.6666667 1.94444444,11.6666667 Z M3.88888889,3.88888889 L7.77777778,3.88888889 L7.77777778,7.77777778 L3.88888889,7.77777778 L3.88888889,3.88888889 Z M0.972222222,19.4444444 L2.91666667,19.4444444 C3.45361017,19.4444444 3.88888889,19.0091657 3.88888889,18.4722222 L3.88888889,16.5277778 C3.88888889,15.9908343 3.45361017,15.5555556 2.91666667,15.5555556 L0.972222222,15.5555556 C0.435278715,15.5555556 0,15.9908343 0,16.5277778 L0,18.4722222 C0,19.0091657 0.435278715,19.4444444 0.972222222,19.4444444 Z M9.72222222,23.3333333 L1.94444444,23.3333333 C0.870557431,23.3333333 0,24.2038908 0,25.2777778 L0,33.0555556 C0,34.1294426 0.870557431,35 1.94444444,35 L9.72222222,35 C10.7961092,35 11.6666667,34.1294426 11.6666667,33.0555556 L11.6666667,25.2777778 C11.6666667,24.2038908 10.7961092,23.3333333 9.72222222,23.3333333 Z M7.77777778,31.1111111 L3.88888889,31.1111111 L3.88888889,27.2222222 L7.77777778,27.2222222 L7.77777778,31.1111111 Z M28.1944444,35 L34.0277778,35 C34.5647213,35 35,34.5647213 35,34.0277778 L35,28.1944444 C35,27.6575009 34.5647213,27.2222222 34.0277778,27.2222222 L32.0833333,27.2222222 C31.5463898,27.2222222 31.1111111,27.6575009 31.1111111,28.1944444 L31.1111111,31.1111111 L27.2222222,31.1111111 L27.2222222,34.0277778 C27.2222222,34.5647213 27.6575009,35 28.1944444,35 Z M33.0555556,0 L25.2777778,0 C24.2038908,0 23.3333333,0.870557431 23.3333333,1.94444444 L23.3333333,9.72222222 C23.3333333,10.7961092 24.2038908,11.6666667 25.2777778,11.6666667 L33.0555556,11.6666667 C34.1294426,11.6666667 35,10.7961092 35,9.72222222 L35,1.94444444 C35,0.870557431 34.1294426,0 33.0555556,0 Z M31.1111111,7.77777778 L27.2222222,7.77777778 L27.2222222,3.88888889 L31.1111111,3.88888889 L31.1111111,7.77777778 Z M8.75,15.5555556 C8.21305649,15.5555556 7.77777778,15.9908343 7.77777778,16.5277778 L7.77777778,18.4722222 C7.77777778,19.0091657 8.21305649,19.4444444 8.75,19.4444444 L15.5555556,19.4444444 L15.5555556,15.5555556 L8.75,15.5555556 Z M15.5555556,22.3611111 C15.5555556,22.8980546 15.9908343,23.3333333 16.5277778,23.3333333 L19.4444444,23.3333333 L19.4444444,26.25 C19.4444444,26.7869435 19.8797232,27.2222222 20.4166667,27.2222222 L23.3333333,27.2222222 L23.3333333,19.4444444 L15.5555556,19.4444444 L15.5555556,22.3611111 Z M15.5555556,32.0833333 L15.5555556,34.0277778 C15.5555556,34.5647213 15.9908343,35 16.5277778,35 L22.3611111,35 C22.8980546,35 23.3333333,34.5647213 23.3333333,34.0277778 L23.3333333,31.1111111 L16.5277778,31.1111111 C15.9908343,31.1111111 15.5555556,31.5463898 15.5555556,32.0833333 Z M34.0277778,15.5555556 L24.3055556,15.5555556 C23.768612,15.5555556 23.3333333,15.9908343 23.3333333,16.5277778 L23.3333333,19.4444444 L27.2222222,19.4444444 L27.2222222,22.3611111 C27.2222222,22.8980546 27.6575009,23.3333333 28.1944444,23.3333333 L30.1388889,23.3333333 C30.6758324,23.3333333 31.1111111,22.8980546 31.1111111,22.3611111 L31.1111111,19.4444444 L34.0277778,19.4444444 C34.5647213,19.4444444 35,19.0091657 35,18.4722222 L35,16.5277778 C35,15.9908343 34.5647213,15.5555556 34.0277778,15.5555556 Z M27.2222222,31.1111111 L27.2222222,27.2222222 L23.3333333,27.2222222 L23.3333333,31.1111111 L27.2222222,31.1111111 Z M16.5277778,7.77777778 L18.4722222,7.77777778 C19.0091657,7.77777778 19.4444444,7.34249906 19.4444444,6.80555556 L19.4444444,0.972222222 C19.4444444,0.435278715 19.0091657,0 18.4722222,0 L16.5277778,0 C15.9908343,0 15.5555556,0.435278715 15.5555556,0.972222222 L15.5555556,6.80555556 C15.5555556,7.34249906 15.9908343,7.77777778 16.5277778,7.77777778 Z M19.4444444,14.5833333 L19.4444444,12.6388889 C19.4444444,12.1019454 19.0091657,11.6666667 18.4722222,11.6666667 L16.5277778,11.6666667 C15.9908343,11.6666667 15.5555556,12.1019454 15.5555556,12.6388889 L15.5555556,15.5555556 L18.4722222,15.5555556 C19.0091657,15.5555556 19.4444444,15.1202768 19.4444444,14.5833333 Z" id="Icon-color"></path>
</g>
</g>
<g id="4" transform="translate(73.000000, 12.000000)">
<rect id="path" fill="#3098DC" x="0" y="5" width="14" height="1"></rect>
<polyline id="path" stroke="#3098DC" points="8 0 14 5.5 8 10.5"></polyline>
</g>
<g id="4" transform="translate(7.000000, 17.250000) scale(-1, 1) translate(-7.000000, -17.250000) translate(0.000000, 12.000000)">
<rect id="path" fill="#3098DC" x="0" y="5" width="14" height="1"></rect>
<polyline id="path" stroke="#3098DC" points="8 0 14 5.5 8 10.5"></polyline>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

@ -56,7 +56,7 @@ const openMetamaskTabsIDs = {};
const requestAccountTabIds = {};
// state persistence
const inTest = process.env.IN_TEST === 'true';
const inTest = process.env.IN_TEST;
const localStore = inTest ? new ReadOnlyNetworkStore() : new LocalStore();
let versionedData;
@ -533,13 +533,8 @@ function setupController(initState, initLangCode) {
),
);
// We're specifcally avoid using approvalController directly for better
// Error support during rejection
Object.keys(
controller.permissionsController.approvals.state.pendingApprovals,
).forEach((approvalId) =>
controller.permissionsController.rejectPermissionsRequest(approvalId),
);
// Finally, reject all approvals managed by the ApprovalController
controller.approvalController.clear();
updateBadge();
}

@ -16,6 +16,7 @@ export default class AppStateController extends EventEmitter {
onInactiveTimeout,
showUnlockRequest,
preferencesStore,
qrHardwareStore,
} = opts;
super();
@ -31,7 +32,9 @@ export default class AppStateController extends EventEmitter {
recoveryPhraseReminderHasBeenShown: false,
recoveryPhraseReminderLastShown: new Date().getTime(),
showTestnetMessageInDropdown: true,
trezorModel: null,
...initState,
qrHardware: {},
});
this.timer = null;
@ -48,6 +51,10 @@ export default class AppStateController extends EventEmitter {
}
});
qrHardwareStore.subscribe((state) => {
this.store.updateState({ qrHardware: state });
});
const { preferences } = preferencesStore.getState();
this._setInactiveTimeout(preferences.autoLockTimeLimit);
}
@ -237,4 +244,12 @@ export default class AppStateController extends EventEmitter {
setShowTestnetMessageInDropdown(showTestnetMessageInDropdown) {
this.store.updateState({ showTestnetMessageInDropdown });
}
/**
* Sets a property indicating the model of the user's Trezor hardware wallet
* @returns {void}
*/
setTrezorModel(trezorModel) {
this.store.updateState({ trezorModel });
}
}

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

@ -33,7 +33,7 @@ const env = process.env.METAMASK_ENV;
const fetchWithTimeout = getFetchWithTimeout(SECOND * 30);
let defaultProviderConfigOpts;
if (process.env.IN_TEST === 'true') {
if (process.env.IN_TEST) {
defaultProviderConfigOpts = {
type: NETWORK_TYPE_RPC,
rpcUrl: 'http://localhost:8545',

@ -0,0 +1,71 @@
import {
CaveatTypes,
RestrictedMethods,
} from '../../../../shared/constants/permissions';
export function getPermissionBackgroundApiMethods(permissionController) {
return {
addPermittedAccount: (origin, account) => {
const existing = permissionController.getCaveat(
origin,
RestrictedMethods.eth_accounts,
CaveatTypes.restrictReturnedAccounts,
);
if (existing.value.includes(account)) {
throw new Error(
`eth_accounts permission for origin "${origin}" already permits account "${account}".`,
);
}
permissionController.updateCaveat(
origin,
RestrictedMethods.eth_accounts,
CaveatTypes.restrictReturnedAccounts,
[...existing.value, account],
);
},
removePermittedAccount: (origin, account) => {
const existing = permissionController.getCaveat(
origin,
RestrictedMethods.eth_accounts,
CaveatTypes.restrictReturnedAccounts,
);
if (!existing.value.includes(account)) {
throw new Error(
`eth_accounts permission for origin "${origin}" already does not permit account "${account}".`,
);
}
const remainingAccounts = existing.value.filter(
(existingAccount) => existingAccount !== account,
);
if (remainingAccounts.length === 0) {
permissionController.revokePermission(
origin,
RestrictedMethods.eth_accounts,
);
} else {
permissionController.updateCaveat(
origin,
RestrictedMethods.eth_accounts,
CaveatTypes.restrictReturnedAccounts,
remainingAccounts,
);
}
},
requestAccountsPermissionWithId: async (origin) => {
const [, { id }] = await permissionController.requestPermissions(
{ origin },
{
eth_accounts: {},
},
);
return id;
},
};
}

@ -0,0 +1,181 @@
import {
CaveatTypes,
RestrictedMethods,
} from '../../../../shared/constants/permissions';
import { getPermissionBackgroundApiMethods } from './background-api';
describe('permission background API methods', () => {
describe('addPermittedAccount', () => {
it('adds a permitted account', () => {
const permissionController = {
getCaveat: jest.fn().mockImplementationOnce(() => {
return { type: CaveatTypes.restrictReturnedAccounts, value: ['0x1'] };
}),
updateCaveat: jest.fn(),
};
getPermissionBackgroundApiMethods(
permissionController,
).addPermittedAccount('foo.com', '0x2');
expect(permissionController.getCaveat).toHaveBeenCalledTimes(1);
expect(permissionController.getCaveat).toHaveBeenCalledWith(
'foo.com',
RestrictedMethods.eth_accounts,
CaveatTypes.restrictReturnedAccounts,
);
expect(permissionController.updateCaveat).toHaveBeenCalledTimes(1);
expect(permissionController.updateCaveat).toHaveBeenCalledWith(
'foo.com',
RestrictedMethods.eth_accounts,
CaveatTypes.restrictReturnedAccounts,
['0x1', '0x2'],
);
});
it('throws if the specified account is already permitted', () => {
const permissionController = {
getCaveat: jest.fn().mockImplementationOnce(() => {
return { type: CaveatTypes.restrictReturnedAccounts, value: ['0x1'] };
}),
updateCaveat: jest.fn(),
};
expect(() =>
getPermissionBackgroundApiMethods(
permissionController,
).addPermittedAccount('foo.com', '0x1'),
).toThrow(
`eth_accounts permission for origin "foo.com" already permits account "0x1".`,
);
expect(permissionController.getCaveat).toHaveBeenCalledTimes(1);
expect(permissionController.getCaveat).toHaveBeenCalledWith(
'foo.com',
RestrictedMethods.eth_accounts,
CaveatTypes.restrictReturnedAccounts,
);
expect(permissionController.updateCaveat).not.toHaveBeenCalled();
});
});
describe('removePermittedAccount', () => {
it('removes a permitted account', () => {
const permissionController = {
getCaveat: jest.fn().mockImplementationOnce(() => {
return {
type: CaveatTypes.restrictReturnedAccounts,
value: ['0x1', '0x2'],
};
}),
revokePermission: jest.fn(),
updateCaveat: jest.fn(),
};
getPermissionBackgroundApiMethods(
permissionController,
).removePermittedAccount('foo.com', '0x2');
expect(permissionController.getCaveat).toHaveBeenCalledTimes(1);
expect(permissionController.getCaveat).toHaveBeenCalledWith(
'foo.com',
RestrictedMethods.eth_accounts,
CaveatTypes.restrictReturnedAccounts,
);
expect(permissionController.revokePermission).not.toHaveBeenCalled();
expect(permissionController.updateCaveat).toHaveBeenCalledTimes(1);
expect(permissionController.updateCaveat).toHaveBeenCalledWith(
'foo.com',
RestrictedMethods.eth_accounts,
CaveatTypes.restrictReturnedAccounts,
['0x1'],
);
});
it('revokes the accounts permission if the removed account is the only permitted account', () => {
const permissionController = {
getCaveat: jest.fn().mockImplementationOnce(() => {
return {
type: CaveatTypes.restrictReturnedAccounts,
value: ['0x1'],
};
}),
revokePermission: jest.fn(),
updateCaveat: jest.fn(),
};
getPermissionBackgroundApiMethods(
permissionController,
).removePermittedAccount('foo.com', '0x1');
expect(permissionController.getCaveat).toHaveBeenCalledTimes(1);
expect(permissionController.getCaveat).toHaveBeenCalledWith(
'foo.com',
RestrictedMethods.eth_accounts,
CaveatTypes.restrictReturnedAccounts,
);
expect(permissionController.revokePermission).toHaveBeenCalledTimes(1);
expect(permissionController.revokePermission).toHaveBeenCalledWith(
'foo.com',
RestrictedMethods.eth_accounts,
);
expect(permissionController.updateCaveat).not.toHaveBeenCalled();
});
it('throws if the specified account is not permitted', () => {
const permissionController = {
getCaveat: jest.fn().mockImplementationOnce(() => {
return { type: CaveatTypes.restrictReturnedAccounts, value: ['0x1'] };
}),
revokePermission: jest.fn(),
updateCaveat: jest.fn(),
};
expect(() =>
getPermissionBackgroundApiMethods(
permissionController,
).removePermittedAccount('foo.com', '0x2'),
).toThrow(
`eth_accounts permission for origin "foo.com" already does not permit account "0x2".`,
);
expect(permissionController.getCaveat).toHaveBeenCalledTimes(1);
expect(permissionController.getCaveat).toHaveBeenCalledWith(
'foo.com',
RestrictedMethods.eth_accounts,
CaveatTypes.restrictReturnedAccounts,
);
expect(permissionController.revokePermission).not.toHaveBeenCalled();
expect(permissionController.updateCaveat).not.toHaveBeenCalled();
});
});
describe('requestAccountsPermissionWithId', () => {
it('request an accounts permission and returns the request id', async () => {
const permissionController = {
requestPermissions: jest.fn().mockImplementationOnce(async () => {
return [null, { id: 'arbitraryId' }];
}),
};
const id = await getPermissionBackgroundApiMethods(
permissionController,
).requestAccountsPermissionWithId('foo.com');
expect(permissionController.requestPermissions).toHaveBeenCalledTimes(1);
expect(permissionController.requestPermissions).toHaveBeenCalledWith(
{ origin: 'foo.com' },
{ eth_accounts: {} },
);
expect(id).toStrictEqual('arbitraryId');
});
});
});

@ -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 HISTORY_STORE_KEY = 'permissionsHistory';
export const LOG_STORE_KEY = 'permissionsLog';
export const METADATA_STORE_KEY = 'domainMetadata';
export const METADATA_CACHE_MAX_SIZE = 100;
export const CAVEAT_TYPES = {
limitResponseLength: 'limitResponseLength',
filterResponse: 'filterResponse',
};
export const NOTIFICATION_NAMES = {
accountsChanged: 'metamask_accountsChanged',
unlockStateChanged: 'metamask_unlockStateChanged',
@ -31,64 +16,7 @@ export const LOG_METHOD_TYPES = {
internal: 'internal',
};
/**
* The permission activity log size limit.
*/
export const LOG_LIMIT = 100;
export const SAFE_METHODS = [
'eth_blockNumber',
'eth_call',
'eth_chainId',
'eth_coinbase',
'eth_decrypt',
'eth_estimateGas',
'eth_feeHistory',
'eth_gasPrice',
'eth_getBalance',
'eth_getBlockByHash',
'eth_getBlockByNumber',
'eth_getBlockTransactionCountByHash',
'eth_getBlockTransactionCountByNumber',
'eth_getCode',
'eth_getEncryptionPublicKey',
'eth_getFilterChanges',
'eth_getFilterLogs',
'eth_getLogs',
'eth_getProof',
'eth_getStorageAt',
'eth_getTransactionByBlockHashAndIndex',
'eth_getTransactionByBlockNumberAndIndex',
'eth_getTransactionByHash',
'eth_getTransactionCount',
'eth_getTransactionReceipt',
'eth_getUncleByBlockHashAndIndex',
'eth_getUncleByBlockNumberAndIndex',
'eth_getUncleCountByBlockHash',
'eth_getUncleCountByBlockNumber',
'eth_getWork',
'eth_hashrate',
'eth_mining',
'eth_newBlockFilter',
'eth_newFilter',
'eth_newPendingTransactionFilter',
'eth_protocolVersion',
'eth_sendRawTransaction',
'eth_sendTransaction',
'eth_sign',
'eth_signTypedData',
'eth_signTypedData_v1',
'eth_signTypedData_v3',
'eth_signTypedData_v4',
'eth_submitHashrate',
'eth_submitWork',
'eth_syncing',
'eth_uninstallFilter',
'metamask_getProviderState',
'metamask_watchAsset',
'net_listening',
'net_peerCount',
'net_version',
'personal_ecRecover',
'personal_sign',
'wallet_watchAsset',
'web3_clientVersion',
'web3_sha3',
];

@ -1,718 +1,4 @@
import nanoid from 'nanoid';
import { JsonRpcEngine } from 'json-rpc-engine';
import { ObservableStore } from '@metamask/obs-store';
import log from 'loglevel';
import { CapabilitiesController as RpcCap } from 'rpc-cap';
import { ethErrors } from 'eth-rpc-errors';
import { cloneDeep } from 'lodash';
import { CAVEAT_NAMES } from '../../../../shared/constants/permissions';
import {
APPROVAL_TYPE,
SAFE_METHODS, // methods that do not require any permissions to use
WALLET_PREFIX,
METADATA_STORE_KEY,
METADATA_CACHE_MAX_SIZE,
LOG_STORE_KEY,
HISTORY_STORE_KEY,
NOTIFICATION_NAMES,
CAVEAT_TYPES,
} from './enums';
import createPermissionsMethodMiddleware from './permissionsMethodMiddleware';
import PermissionsLogController from './permissionsLog';
// instanbul ignore next
const noop = () => undefined;
export class PermissionsController {
constructor(
{
approvals,
getKeyringAccounts,
getRestrictedMethods,
getUnlockPromise,
isUnlocked,
notifyDomain,
notifyAllDomains,
preferences,
} = {},
restoredPermissions = {},
restoredState = {},
) {
// additional top-level store key set in _initializeMetadataStore
this.store = new ObservableStore({
[LOG_STORE_KEY]: restoredState[LOG_STORE_KEY] || [],
[HISTORY_STORE_KEY]: restoredState[HISTORY_STORE_KEY] || {},
});
this.getKeyringAccounts = getKeyringAccounts;
this._getUnlockPromise = getUnlockPromise;
this._notifyDomain = notifyDomain;
this._notifyAllDomains = notifyAllDomains;
this._isUnlocked = isUnlocked;
this._restrictedMethods = getRestrictedMethods({
getKeyringAccounts: this.getKeyringAccounts.bind(this),
getIdentities: this._getIdentities.bind(this),
});
this.permissionsLog = new PermissionsLogController({
restrictedMethods: Object.keys(this._restrictedMethods),
store: this.store,
});
/**
* @type {import('@metamask/controllers').ApprovalController}
* @public
*/
this.approvals = approvals;
this._initializePermissions(restoredPermissions);
this._lastSelectedAddress = preferences.getState().selectedAddress;
this.preferences = preferences;
this._initializeMetadataStore(restoredState);
preferences.subscribe(async ({ selectedAddress }) => {
if (selectedAddress && selectedAddress !== this._lastSelectedAddress) {
this._lastSelectedAddress = selectedAddress;
await this._handleAccountSelected(selectedAddress);
}
});
}
createMiddleware({ origin, extensionId }) {
if (typeof origin !== 'string' || !origin.length) {
throw new Error('Must provide non-empty string origin.');
}
const metadataState = this.store.getState()[METADATA_STORE_KEY];
if (extensionId && metadataState[origin]?.extensionId !== extensionId) {
this.addDomainMetadata(origin, { extensionId });
}
const engine = new JsonRpcEngine();
engine.push(this.permissionsLog.createMiddleware());
engine.push(
createPermissionsMethodMiddleware({
addDomainMetadata: this.addDomainMetadata.bind(this),
getAccounts: this.getAccounts.bind(this, origin),
getUnlockPromise: () => this._getUnlockPromise(true),
hasPermission: this.hasPermission.bind(this, origin),
notifyAccountsChanged: this.notifyAccountsChanged.bind(this, origin),
requestAccountsPermission: this._requestPermissions.bind(
this,
{ origin },
{ eth_accounts: {} },
),
}),
);
engine.push(
this.permissions.providerMiddlewareFunction.bind(this.permissions, {
origin,
}),
);
return engine.asMiddleware();
}
/**
* Request {@code eth_accounts} permissions
* @param {string} origin - The requesting origin
* @returns {Promise<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,
);
}
}
export * from './caveat-mutators';
export * from './background-api';
export * from './specifications';
export * from './selectors';

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

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

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

@ -37,6 +37,8 @@ export default class PreferencesController {
// set to true means the dynamic list from the API is being used
// set to false will be using the static list from contract-metadata
useTokenDetection: false,
useCollectibleDetection: false,
openSeaEnabled: false,
advancedGasFee: null,
// WARNING: Do not use feature flags for security-sensitive things.
@ -130,6 +132,35 @@ export default class PreferencesController {
this.store.updateState({ useTokenDetection: val });
}
/**
* Setter for the `useCollectibleDetection` property
*
* @param {boolean} val - Whether or not the user prefers to autodetect collectibles.
*
*/
setUseCollectibleDetection(val) {
const { openSeaEnabled } = this.store.getState();
if (val && !openSeaEnabled) {
throw new Error(
'useCollectibleDetection cannot be enabled if openSeaEnabled is false',
);
}
this.store.updateState({ useCollectibleDetection: val });
}
/**
* Setter for the `openSeaEnabled` property
*
* @param {boolean} val - Whether or not the user prefers to use the OpenSea API for collectibles data.
*
*/
setOpenSeaEnabled(val) {
this.store.updateState({ openSeaEnabled: val });
if (!val) {
this.store.updateState({ useCollectibleDetection: false });
}
}
/**
* Setter for the `advancedGasFee` property
*

@ -267,6 +267,42 @@ describe('preferences controller', function () {
});
});
describe('setUseCollectibleDetection', function () {
it('should default to false', function () {
const state = preferencesController.store.getState();
assert.equal(state.useCollectibleDetection, false);
});
it('should set the useCollectibleDetection property in state', function () {
assert.equal(
preferencesController.store.getState().useCollectibleDetection,
false,
);
preferencesController.setOpenSeaEnabled(true);
preferencesController.setUseCollectibleDetection(true);
assert.equal(
preferencesController.store.getState().useCollectibleDetection,
true,
);
});
});
describe('setOpenSeaEnabled', function () {
it('should default to false', function () {
const state = preferencesController.store.getState();
assert.equal(state.openSeaEnabled, false);
});
it('should set the openSeaEnabled property in state', function () {
assert.equal(
preferencesController.store.getState().openSeaEnabled,
false,
);
preferencesController.setOpenSeaEnabled(true);
assert.equal(preferencesController.store.getState().openSeaEnabled, true);
});
});
describe('setAdvancedGasFee', function () {
it('should default to null', function () {
const state = preferencesController.store.getState();

@ -79,7 +79,6 @@ const initialState = {
topAggId: null,
routeState: '',
swapsFeatureIsLive: true,
useNewSwapsApi: false,
saveFetchedQuotes: false,
swapsQuoteRefreshTime: FALLBACK_QUOTE_REFRESH_TIME,
swapsQuotePrefetchingRefreshTime: FALLBACK_QUOTE_REFRESH_TIME,
@ -123,9 +122,9 @@ export default class SwapsController {
});
}
async fetchSwapsRefreshRates(chainId, useNewSwapsApi) {
async fetchSwapsRefreshRates(chainId) {
const response = await fetchWithCache(
getBaseApi('network', chainId, useNewSwapsApi),
getBaseApi('network', chainId),
{ method: 'GET' },
{ cacheRefreshTime: 600000 },
);
@ -149,13 +148,9 @@ export default class SwapsController {
// Sets the refresh rate for quote updates from the MetaSwap API
async _setSwapsRefreshRates() {
const chainId = this._getCurrentChainId();
const { swapsState } = this.store.getState();
let swapsRefreshRates;
try {
swapsRefreshRates = await this.fetchSwapsRefreshRates(
chainId,
swapsState.useNewSwapsApi,
);
swapsRefreshRates = await this.fetchSwapsRefreshRates(chainId);
} catch (e) {
console.error('Request for swaps quote refresh time failed: ', e);
}
@ -210,11 +205,7 @@ export default class SwapsController {
) {
const { chainId } = fetchParamsMetaData;
const {
swapsState: {
useNewSwapsApi,
quotesPollingLimitEnabled,
saveFetchedQuotes,
},
swapsState: { quotesPollingLimitEnabled, saveFetchedQuotes },
} = this.store.getState();
if (!fetchParams) {
@ -242,7 +233,6 @@ export default class SwapsController {
let [newQuotes] = await Promise.all([
this._fetchTradesInfo(fetchParams, {
...fetchParamsMetaData,
useNewSwapsApi,
}),
this._setSwapsRefreshRates(),
]);
@ -574,9 +564,9 @@ export default class SwapsController {
setSwapsLiveness(swapsLiveness) {
const { swapsState } = this.store.getState();
const { swapsFeatureIsLive, useNewSwapsApi } = swapsLiveness;
const { swapsFeatureIsLive } = swapsLiveness;
this.store.updateState({
swapsState: { ...swapsState, swapsFeatureIsLive, useNewSwapsApi },
swapsState: { ...swapsState, swapsFeatureIsLive },
});
}
@ -588,7 +578,6 @@ export default class SwapsController {
tokens: swapsState.tokens,
fetchParams: swapsState.fetchParams,
swapsFeatureIsLive: swapsState.swapsFeatureIsLive,
useNewSwapsApi: swapsState.useNewSwapsApi,
swapsQuoteRefreshTime: swapsState.swapsQuoteRefreshTime,
swapsQuotePrefetchingRefreshTime:
swapsState.swapsQuotePrefetchingRefreshTime,

@ -131,7 +131,6 @@ const EMPTY_INIT_STATE = {
topAggId: null,
routeState: '',
swapsFeatureIsLive: true,
useNewSwapsApi: false,
swapsQuoteRefreshTime: 60000,
swapsQuotePrefetchingRefreshTime: 60000,
swapsUserFeeLevel: '',
@ -707,7 +706,6 @@ describe('SwapsController', function () {
assert.strictEqual(
fetchTradesInfoStub.calledOnceWithExactly(MOCK_FETCH_PARAMS, {
...MOCK_FETCH_METADATA,
useNewSwapsApi: false,
}),
true,
);
@ -885,7 +883,6 @@ describe('SwapsController', function () {
const tokens = 'test';
const fetchParams = 'test';
const swapsFeatureIsLive = false;
const useNewSwapsApi = false;
const swapsQuoteRefreshTime = 0;
const swapsQuotePrefetchingRefreshTime = 0;
swapsController.store.updateState({
@ -893,7 +890,6 @@ describe('SwapsController', function () {
tokens,
fetchParams,
swapsFeatureIsLive,
useNewSwapsApi,
swapsQuoteRefreshTime,
swapsQuotePrefetchingRefreshTime,
},

@ -33,6 +33,7 @@ import {
GAS_ESTIMATE_TYPES,
GAS_RECOMMENDATIONS,
CUSTOM_GAS_ESTIMATE,
PRIORITY_LEVELS,
} from '../../../../shared/constants/gas';
import { decGWEIToHexWEI } from '../../../../shared/modules/conversion.utils';
import {
@ -438,7 +439,11 @@ export default class TransactionController extends EventEmitter {
) {
txMeta.txParams.maxFeePerGas = txMeta.txParams.gasPrice;
txMeta.txParams.maxPriorityFeePerGas = txMeta.txParams.gasPrice;
txMeta.userFeeLevel = CUSTOM_GAS_ESTIMATE;
if (process.env.EIP_1559_V2) {
txMeta.userFeeLevel = PRIORITY_LEVELS.DAPP_SUGGESTED;
} else {
txMeta.userFeeLevel = CUSTOM_GAS_ESTIMATE;
}
} else {
if (
(defaultMaxFeePerGas &&
@ -448,6 +453,8 @@ export default class TransactionController extends EventEmitter {
txMeta.origin === 'metamask'
) {
txMeta.userFeeLevel = GAS_RECOMMENDATIONS.MEDIUM;
} else if (process.env.EIP_1559_V2) {
txMeta.userFeeLevel = PRIORITY_LEVELS.DAPP_SUGGESTED;
} else {
txMeta.userFeeLevel = CUSTOM_GAS_ESTIMATE;
}
@ -970,7 +977,7 @@ export default class TransactionController extends EventEmitter {
* @param {number} txId - The tx's ID
* @returns {Promise<void>}
*/
async confirmTransaction(txId, txReceipt, baseFeePerGas) {
async confirmTransaction(txId, txReceipt, baseFeePerGas, blockTimestamp) {
// get the txReceipt before marking the transaction confirmed
// to ensure the receipt is gotten before the ui revives the tx
const txMeta = this.txStateManager.getTransaction(txId);
@ -995,6 +1002,9 @@ export default class TransactionController extends EventEmitter {
if (baseFeePerGas) {
txMeta.baseFeePerGas = baseFeePerGas;
}
if (blockTimestamp) {
txMeta.blockTimestamp = blockTimestamp;
}
this.txStateManager.setTxStatusConfirmed(txId);
this._markNonceDuplicatesDropped(txId);
@ -1176,8 +1186,13 @@ export default class TransactionController extends EventEmitter {
});
this.pendingTxTracker.on(
'tx:confirmed',
(txId, transactionReceipt, baseFeePerGas) =>
this.confirmTransaction(txId, transactionReceipt, baseFeePerGas),
(txId, transactionReceipt, baseFeePerGas, blockTimestamp) =>
this.confirmTransaction(
txId,
transactionReceipt,
baseFeePerGas,
blockTimestamp,
),
);
this.pendingTxTracker.on('tx:dropped', (txId) => {
this._dropTransaction(txId);

@ -38,7 +38,6 @@ export function generateHistoryEntry(previousState, newState, note) {
if (note) {
entry[0].note = note;
}
entry[0].timestamp = Date.now();
}
return entry;

@ -164,6 +164,7 @@ export default class PendingTransactionTracker extends EventEmitter {
* @emits tx:warning
* @private
*/
async _checkPendingTx(txMeta) {
const txHash = txMeta.hash;
const txId = txMeta.id;
@ -193,11 +194,21 @@ export default class PendingTransactionTracker extends EventEmitter {
try {
const transactionReceipt = await this.query.getTransactionReceipt(txHash);
if (transactionReceipt?.blockNumber) {
const { baseFeePerGas } = await this.query.getBlockByHash(
const {
baseFeePerGas,
timestamp: blockTimestamp,
} = await this.query.getBlockByHash(
transactionReceipt?.blockHash,
false,
);
this.emit('tx:confirmed', txId, transactionReceipt, baseFeePerGas);
this.emit(
'tx:confirmed',
txId,
transactionReceipt,
baseFeePerGas,
blockTimestamp,
);
return;
}
} catch (err) {

@ -1,4 +1,3 @@
import { strict as assert } from 'assert';
import { ObservableStore } from '@metamask/obs-store';
import {
BaseController,
@ -48,17 +47,17 @@ class ExampleController extends BaseControllerV2 {
}
}
describe('ComposableObservableStore', function () {
it('should register initial state', function () {
describe('ComposableObservableStore', () => {
it('should register initial state', () => {
const controllerMessenger = new ControllerMessenger();
const store = new ComposableObservableStore({
controllerMessenger,
state: 'state',
});
assert.strictEqual(store.getState(), 'state');
expect(store.getState()).toStrictEqual('state');
});
it('should register initial structure', function () {
it('should register initial structure', () => {
const controllerMessenger = new ControllerMessenger();
const testStore = new ObservableStore();
const store = new ComposableObservableStore({
@ -66,28 +65,28 @@ describe('ComposableObservableStore', function () {
controllerMessenger,
});
testStore.putState('state');
assert.deepEqual(store.getState(), { TestStore: 'state' });
expect(store.getState()).toStrictEqual({ TestStore: 'state' });
});
it('should update structure with observable store', function () {
it('should update structure with observable store', () => {
const controllerMessenger = new ControllerMessenger();
const testStore = new ObservableStore();
const store = new ComposableObservableStore({ controllerMessenger });
store.updateStructure({ TestStore: testStore });
testStore.putState('state');
assert.deepEqual(store.getState(), { TestStore: 'state' });
expect(store.getState()).toStrictEqual({ TestStore: 'state' });
});
it('should update structure with BaseController-based controller', function () {
it('should update structure with BaseController-based controller', () => {
const controllerMessenger = new ControllerMessenger();
const oldExampleController = new OldExampleController();
const store = new ComposableObservableStore({ controllerMessenger });
store.updateStructure({ OldExample: oldExampleController });
oldExampleController.updateBaz('state');
assert.deepEqual(store.getState(), { OldExample: { baz: 'state' } });
expect(store.getState()).toStrictEqual({ OldExample: { baz: 'state' } });
});
it('should update structure with BaseControllerV2-based controller', function () {
it('should update structure with BaseControllerV2-based controller', () => {
const controllerMessenger = new ControllerMessenger();
const exampleController = new ExampleController({
messenger: controllerMessenger,
@ -95,11 +94,10 @@ describe('ComposableObservableStore', function () {
const store = new ComposableObservableStore({ controllerMessenger });
store.updateStructure({ Example: exampleController });
exampleController.updateBar('state');
console.log(exampleController.state);
assert.deepEqual(store.getState(), { Example: { bar: 'state' } });
expect(store.getState()).toStrictEqual({ Example: { bar: 'state' } });
});
it('should update structure with all three types of stores', function () {
it('should update structure with all three types of stores', () => {
const controllerMessenger = new ControllerMessenger();
const exampleStore = new ObservableStore();
const exampleController = new ExampleController({
@ -115,14 +113,14 @@ describe('ComposableObservableStore', function () {
exampleStore.putState('state');
exampleController.updateBar('state');
oldExampleController.updateBaz('state');
assert.deepEqual(store.getState(), {
expect(store.getState()).toStrictEqual({
Example: { bar: 'state' },
OldExample: { baz: 'state' },
Store: 'state',
});
});
it('should return flattened state', function () {
it('should return flattened state', () => {
const controllerMessenger = new ControllerMessenger();
const fooStore = new ObservableStore({ foo: 'foo' });
const barController = new ExampleController({
@ -142,46 +140,48 @@ describe('ComposableObservableStore', function () {
BazStore: bazController.state,
},
});
assert.deepEqual(store.getFlatState(), {
expect(store.getFlatState()).toStrictEqual({
foo: 'foo',
bar: 'bar',
baz: 'baz',
});
});
it('should return empty flattened state when not configured', function () {
it('should return empty flattened state when not configured', () => {
const controllerMessenger = new ControllerMessenger();
const store = new ComposableObservableStore({ controllerMessenger });
assert.deepEqual(store.getFlatState(), {});
expect(store.getFlatState()).toStrictEqual({});
});
it('should throw if the controller messenger is omitted and the config includes a BaseControllerV2 controller', function () {
it('should throw if the controller messenger is omitted and the config includes a BaseControllerV2 controller', () => {
const controllerMessenger = new ControllerMessenger();
const exampleController = new ExampleController({
messenger: controllerMessenger,
});
assert.throws(
expect(
() =>
new ComposableObservableStore({
config: {
Example: exampleController,
},
}),
);
).toThrow(`Cannot read property 'subscribe' of undefined`);
});
it('should throw if the controller messenger is omitted and updateStructure called with a BaseControllerV2 controller', function () {
it('should throw if the controller messenger is omitted and updateStructure called with a BaseControllerV2 controller', () => {
const controllerMessenger = new ControllerMessenger();
const exampleController = new ExampleController({
messenger: controllerMessenger,
});
const store = new ComposableObservableStore({});
assert.throws(() => store.updateStructure({ Example: exampleController }));
expect(() => store.updateStructure({ Example: exampleController })).toThrow(
`Cannot read property 'subscribe' of undefined`,
);
});
it('should throw if initialized with undefined config entry', function () {
it('should throw if initialized with undefined config entry', () => {
const controllerMessenger = new ControllerMessenger();
assert.throws(
expect(
() =>
new ComposableObservableStore({
config: {
@ -189,6 +189,6 @@ describe('ComposableObservableStore', function () {
},
controllerMessenger,
}),
);
).toThrow(`Undefined 'Example'`);
});
});

@ -1,4 +1,3 @@
import { strict as assert } from 'assert';
import nock from 'nock';
import {
KOVAN_CHAIN_ID,
@ -27,8 +26,8 @@ const KOVAN = {
chainId: KOVAN_CHAIN_ID,
};
describe('buy-eth-url', function () {
it('returns Wyre url with an ETH address for Ethereum mainnet', async function () {
describe('buy-eth-url', () => {
it('returns Wyre url with an ETH address for Ethereum mainnet', async () => {
nock(SWAPS_API_V2_BASE_URL)
.get(
`/networks/1/fiatOnRampUrl?serviceName=wyre&destinationAddress=${ETH_ADDRESS}`,
@ -37,43 +36,40 @@ describe('buy-eth-url', function () {
url: `https://pay.sendwyre.com/purchase?accountId=${WYRE_ACCOUNT_ID}&utm_campaign=${WYRE_ACCOUNT_ID}&destCurrency=ETH&utm_medium=widget&paymentMethod=debit-card&reservation=MLZVUF8FMXZUMARJC23B&dest=ethereum%3A${ETH_ADDRESS}&utm_source=checkout`,
});
const wyreUrl = await getBuyEthUrl(MAINNET);
assert.equal(
wyreUrl,
expect(wyreUrl).toStrictEqual(
`https://pay.sendwyre.com/purchase?accountId=${WYRE_ACCOUNT_ID}&utm_campaign=${WYRE_ACCOUNT_ID}&destCurrency=ETH&utm_medium=widget&paymentMethod=debit-card&reservation=MLZVUF8FMXZUMARJC23B&dest=ethereum%3A${ETH_ADDRESS}&utm_source=checkout`,
);
nock.cleanAll();
});
it('returns a fallback Wyre url if /orders/reserve API call fails', async function () {
it('returns a fallback Wyre url if /orders/reserve API call fails', async () => {
const wyreUrl = await getBuyEthUrl(MAINNET);
assert.equal(
wyreUrl,
expect(wyreUrl).toStrictEqual(
`https://pay.sendwyre.com/purchase?dest=ethereum:${ETH_ADDRESS}&destCurrency=ETH&accountId=${WYRE_ACCOUNT_ID}&paymentMethod=debit-card`,
);
});
it('returns Transak url with an ETH address for Ethereum mainnet', async function () {
it('returns Transak url with an ETH address for Ethereum mainnet', async () => {
const transakUrl = await getBuyEthUrl({ ...MAINNET, service: 'transak' });
assert.equal(
transakUrl,
expect(transakUrl).toStrictEqual(
`https://global.transak.com/?apiKey=${TRANSAK_API_KEY}&hostURL=https%3A%2F%2Fmetamask.io&defaultCryptoCurrency=ETH&walletAddress=${ETH_ADDRESS}`,
);
});
it('returns metamask ropsten faucet for network 3', async function () {
it('returns metamask ropsten faucet for network 3', async () => {
const ropstenUrl = await getBuyEthUrl(ROPSTEN);
assert.equal(ropstenUrl, 'https://faucet.metamask.io/');
expect(ropstenUrl).toStrictEqual('https://faucet.metamask.io/');
});
it('returns rinkeby dapp for network 4', async function () {
it('returns rinkeby dapp for network 4', async () => {
const rinkebyUrl = await getBuyEthUrl(RINKEBY);
assert.equal(rinkebyUrl, 'https://www.rinkeby.io/');
expect(rinkebyUrl).toStrictEqual('https://www.rinkeby.io/');
});
it('returns kovan github test faucet for network 42', async function () {
it('returns kovan github test faucet for network 42', async () => {
const kovanUrl = await getBuyEthUrl(KOVAN);
assert.equal(kovanUrl, 'https://github.com/kovan-testnet/faucet');
expect(kovanUrl).toStrictEqual('https://github.com/kovan-testnet/faucet');
});
});

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

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

@ -79,9 +79,9 @@ export default class MessageManager extends EventEmitter {
* @returns {promise} after signature has been
*
*/
addUnapprovedMessageAsync(msgParams, req) {
return new Promise((resolve, reject) => {
const msgId = this.addUnapprovedMessage(msgParams, req);
async addUnapprovedMessageAsync(msgParams, req) {
const msgId = this.addUnapprovedMessage(msgParams, req);
return await new Promise((resolve, reject) => {
// await finished
this.once(`${msgId}:finished`, (data) => {
switch (data.status) {
@ -93,6 +93,10 @@ export default class MessageManager extends EventEmitter {
'MetaMask Message Signature: User denied message signature.',
),
);
case 'errored':
return reject(
new Error(`MetaMask Message Signature: ${data.error}`),
);
default:
return reject(
new Error(
@ -233,6 +237,19 @@ export default class MessageManager extends EventEmitter {
this._setMsgStatus(msgId, 'rejected');
}
/**
* Sets a Message status to 'errored' via a call to this._setMsgStatus.
*
* @param {number} msgId - The id of the Message to error
*
*/
errorMessage(msgId, error) {
const msg = this.getMsg(msgId);
msg.error = error;
this._updateMsg(msg);
this._setMsgStatus(msgId, 'errored');
}
/**
* Clears all unapproved messages from memory.
*/
@ -304,7 +321,7 @@ export default class MessageManager extends EventEmitter {
* @returns {string} A hex string conversion of the buffer data
*
*/
function normalizeMsgData(data) {
export function normalizeMsgData(data) {
if (data.slice(0, 2) === '0x') {
// data is already hex
return data;

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

Loading…
Cancel
Save