1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-12-22 09:23:21 +01:00

Permission System 2.0 (#12243)

# Permission System 2.0

## Background

This PR migrates the extension permission system to [the new `PermissionController`](https://github.com/MetaMask/snaps-skunkworks/tree/main/packages/controllers/src/permissions).
The original permission system, based on [`rpc-cap`](https://github.com/MetaMask/rpc-cap), introduced [`ZCAP-LD`](https://w3c-ccg.github.io/zcap-ld/)-like permissions to our JSON-RPC stack.
We used it to [implement](https://github.com/MetaMask/metamask-extension/pull/7004) what we called "LoginPerSite" in [version 7.7.0](https://github.com/MetaMask/metamask-extension/releases/tag/v7.7.0) of the extension, which enabled the user to choose which accounts, if any, should be exposed to each dapp.
While that was a worthwhile feature in and of itself, we wanted a permission _system_ in order to enable everything we are going to with Snaps.
Unfortunately, the original permission system was difficult to use, and necessitated the creation of the original `PermissionsController` (note the "s"), which was more or less a wrapper for `rpc-cap`.

With this PR, we shake off the yoke of the original permission system, in favor of the modular, self-contained, ergonomic, and more mature permission system 2.0.

Note that [the `PermissionController` readme](https://github.com/MetaMask/snaps-skunkworks/tree/main/packages/controllers/src/permissions/README.md) explains how the new permission system works.

The `PermissionController` and `SubjectMetadataController` are currently shipped via `@metamask/snap-controllers`. This is a temporary state of affairs, and we'll move them to `@metamask/controllers` once they've landed in prod.

## Changes in Detail

First, the changes in this PR are not as big as they seem. Roughly half of the additions in this PR are fixtures in the test for the new migration (number 68), and a significant portion of the remaining ~2500 lines are due to find-and-replace changes in other test fixtures and UI files.

- The extension `PermissionsController` has been deleted, and completely replaced with the new `PermissionController` from [`@metamask/snap-controllers`](https://www.npmjs.com/package/@metamask/snap-controllers).
- The original `PermissionsController` "domain metadata" functionality is now managed by the new `SubjectMetadataController`, also from [`@metamask/snap-controllers`](https://www.npmjs.com/package/@metamask/snap-controllers).
- The permission activity and history log controller has been renamed `PermissionLogController` and has its own top-level state key, but is otherwise functionally equivalent to the existing implementation.
- Migration number 68 has been added to account for the new state changes.
- The tests in `app/scripts/controllers/permissions` have been migrated from `mocha` to `jest`.

Reviewers should focus their attention on the following files:

- `app/scripts/`
  - `metamask-controller.js`
    - This is where most of the integration work for the new `PermissionController` occurs.
      Some functions that were internal to the original controller were moved here.
  - `controllers/permissions/`
    - `selectors.js`
      - These selectors are for `ControllerMessenger` selector subscriptions. The actual subscriptions occur in `metamask-controller.js`. See the `ControllerMessenger` implementation for details.
    - `specifications.js`
      - The caveat and permission specifications are required by the new `PermissionController`, and are used to specify the `eth_accounts` permission and its JSON-RPC method implementation.
        See the `PermissionController` readme for details.
  - `migrations/068.js`
    - The new state should be cross-referenced with the controllers that manage it.
      The accompanying tests should also be thoroughly reviewed.

Some files may appear new but have just moved and/or been renamed:

- `app/scripts/lib/rpc-method-middleware/handlers/request-accounts.js`
  - This was previously implemented in `controllers/permissions/permissionsMethodMiddleware.js`.
- `test/mocks/permissions.js`
  - A truncated version of `test/mocks/permission-controller.js`.

Co-authored-by: Mark Stacey <markjstacey@gmail.com>
This commit is contained in:
Erik Marks 2021-12-06 19:16:49 -08:00 committed by GitHub
parent 3054991c9b
commit 31cf7c10a4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
82 changed files with 3718 additions and 5386 deletions

View File

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

View File

@ -22,6 +22,7 @@ module.exports = {
ignorePatterns: [
'!.eslintrc.js',
'!.mocharc.js',
'node_modules/**',
'dist/**',
'builds/**',
@ -136,9 +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/lib/**/*.test.js',
'app/scripts/controllers/permissions/*.test.js',
],
extends: ['@metamask/eslint-config-mocha'],
rules: {
@ -161,9 +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/lib/**/*.test.js',
'app/scripts/controllers/permissions/*.test.js',
],
extends: ['@metamask/eslint-config-jest'],
rules: {
@ -186,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',
@ -197,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',

View File

@ -2,10 +2,11 @@ module.exports = {
// TODO: Remove the `exit` setting, it can hide broken tests.
exit: true,
ignore: [
'./app/scripts/lib/**/*.test.js',
'./app/scripts/migrations/*.test.js',
'./app/scripts/platforms/*.test.js',
'./app/scripts/lib/**/*.test.js',
'./app/scripts/controllers/permissions/*.test.js',
],
recursive: true,
require: ['test/env.js', 'test/setup.js'],
}
};

View File

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

View File

@ -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",

View File

@ -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',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,11 +10,12 @@ const callbackNoop = function (err) {
* A generator that returns a function which, when passed a promise, can treat that promise as a node style callback.
* The prime advantage being that callbacks are better for error handling.
*
* @param {Function} fn - The function to handle as a callback
* @param {Object} context - The context in which the fn is to be called, most often a this reference
* @param {Function} fn - The function to handle as a callback.
* @param {Object} context - The context in which the function is to be called,
* most often a `this` reference.
*
*/
export default function nodeify(fn, context) {
export function nodeify(fn, context) {
return function (...args) {
const lastArg = args[args.length - 1];
const lastArgIsCallback = typeof lastArg === 'function';
@ -36,3 +37,19 @@ export default function nodeify(fn, context) {
promiseToCallback(result)(callback);
};
}
/**
* Returns a new object where every function property is nodeified, and every
* non-function property is unmodified.
*
* @param {Record<string, unknown>} obj - The object whose function values to
* `nodeify`.
* @param {Object} context - The context in which the function is to be called.
*/
export function nodeifyObject(obj, context) {
return Object.entries(obj).reduce((nodeified, [key, value]) => {
nodeified[key] =
typeof value === 'function' ? nodeify(value, context) : value;
return nodeified;
}, {});
}

View File

@ -1,15 +1,18 @@
import nodeify from './nodeify';
import { nodeify, nodeifyObject } from './nodeify';
describe('nodeify', () => {
const obj = {
foo: 'bar',
promiseFunc(a) {
const solution = this.foo + a;
return Promise.resolve(solution);
},
const getObject = () => {
return {
foo: 'bar',
promiseFunc(a) {
const solution = this.foo + a;
return Promise.resolve(solution);
},
};
};
it('should retain original context', () => {
const obj = getObject();
const nodified = nodeify(obj.promiseFunc, obj);
nodified('baz', (_, res) => {
expect(res).toStrictEqual('barbaz');
@ -17,6 +20,7 @@ describe('nodeify', () => {
});
it('no callback - should allow the last argument to not be a function', async () => {
const obj = getObject();
const nodified = nodeify(obj.promiseFunc, obj);
await expect(() => {
nodified('baz');
@ -38,4 +42,44 @@ describe('nodeify', () => {
expect(err.message).toStrictEqual('boom!');
});
});
describe('nodeifyObject', () => {
it('nodeifies every function of an object', async () => {
const obj = {
notFunction: 'bar',
syncFunction: () => 'hello',
asyncFunction: async () => 'goodbye',
};
const nodeified = nodeifyObject(obj, null);
expect(nodeified.notFunction).toStrictEqual(obj.notFunction);
await Promise.all([
new Promise((resolve, reject) => {
nodeified.syncFunction((err, result) => {
if (err) {
reject(
new Error(`should not have thrown any error: ${err.message}`),
);
return;
}
expect(result).toStrictEqual('hello');
resolve();
});
}),
new Promise((resolve, reject) => {
nodeified.asyncFunction((err, result) => {
if (err) {
reject(
new Error(`should not have thrown any error: ${err.message}`),
);
return;
}
expect(result).toStrictEqual('goodbye');
resolve();
});
}),
]);
});
});
});

View File

@ -1,8 +1,11 @@
import { permissionRpcMethods } from '@metamask/snap-controllers';
import { ethErrors } from 'eth-rpc-errors';
import { UNSUPPORTED_RPC_METHODS } from '../../../../shared/constants/network';
import handlers from './handlers';
import localHandlers from './handlers';
const handlerMap = handlers.reduce((map, handler) => {
const allHandlers = [...localHandlers, ...permissionRpcMethods.handlers];
const handlerMap = allHandlers.reduce((map, handler) => {
for (const methodName of handler.methodNames) {
map.set(methodName, handler);
}
@ -10,23 +13,17 @@ const handlerMap = handlers.reduce((map, handler) => {
}, new Map());
/**
* Returns a middleware that implements the RPC methods defined in the handlers
* directory.
*
* The purpose of this middleware is to create portable RPC method
* implementations that are decoupled from the rest of our background
* architecture.
* Creates a json-rpc-engine middleware of RPC method implementations.
*
* Handlers consume functions that hook into the background, and only depend
* on their signatures, not e.g. controller internals.
*
* Eventually, we'll want to extract this middleware into its own package.
*
* @param {Object} opts - The middleware options
* @param {Record<string, unknown>} hooks - Required "hooks" into our
* controllers.
* @returns {(req: Object, res: Object, next: Function, end: Function) => void}
*/
export default function createMethodMiddleware(opts) {
return function methodMiddleware(req, res, next, end) {
export default function createMethodMiddleware(hooks) {
return async function methodMiddleware(req, res, next, end) {
// Reject unsupported methods.
if (UNSUPPORTED_RPC_METHODS.has(req.method)) {
return end(ethErrors.rpc.methodNotSupported());
@ -35,7 +32,18 @@ export default function createMethodMiddleware(opts) {
const handler = handlerMap.get(req.method);
if (handler) {
const { implementation, hookNames } = handler;
return implementation(req, res, next, end, selectHooks(opts, hookNames));
try {
// Implementations may or may not be async, so we must await them.
return await implementation(
req,
res,
next,
end,
selectHooks(hooks, hookNames),
);
} catch (error) {
return end(error);
}
}
return next();

View File

@ -0,0 +1,33 @@
import { MESSAGE_TYPE } from '../../../../../shared/constants/app';
/**
* A wrapper for `eth_accounts` that returns an empty array when permission is denied.
*/
const requestEthereumAccounts = {
methodNames: [MESSAGE_TYPE.ETH_ACCOUNTS],
implementation: ethAccountsHandler,
hookNames: {
getAccounts: true,
},
};
export default requestEthereumAccounts;
/**
* @typedef {Record<string, Function>} EthAccountsOptions
* @property {Function} getAccounts - Gets the accounts for the requesting
* origin.
*/
/**
*
* @param {import('json-rpc-engine').JsonRpcRequest<unknown>} req - The JSON-RPC request object.
* @param {import('json-rpc-engine').JsonRpcResponse<true>} res - The JSON-RPC response object.
* @param {Function} _next - The json-rpc-engine 'next' callback.
* @param {Function} end - The json-rpc-engine 'end' callback.
* @param {EthAccountsOptions} options - The RPC method hooks.
*/
async function ethAccountsHandler(_req, res, _next, end, { getAccounts }) {
res.result = await getAccounts();
return end();
}

View File

@ -1,14 +1,20 @@
import addEthereumChain from './add-ethereum-chain';
import switchEthereumChain from './switch-ethereum-chain';
import ethAccounts from './eth-accounts';
import getProviderState from './get-provider-state';
import logWeb3ShimUsage from './log-web3-shim-usage';
import requestAccounts from './request-accounts';
import sendMetadata from './send-metadata';
import switchEthereumChain from './switch-ethereum-chain';
import watchAsset from './watch-asset';
const handlers = [
addEthereumChain,
switchEthereumChain,
ethAccounts,
getProviderState,
logWeb3ShimUsage,
requestAccounts,
sendMetadata,
switchEthereumChain,
watchAsset,
];
export default handlers;

View File

@ -0,0 +1,108 @@
import { ethErrors } from 'eth-rpc-errors';
import { MESSAGE_TYPE } from '../../../../../shared/constants/app';
/**
* This method attempts to retrieve the Ethereum accounts available to the
* requester, or initiate a request for account access if none are currently
* available. It is essentially a wrapper of wallet_requestPermissions that
* only errors if the user rejects the request. We maintain the method for
* backwards compatibility reasons.
*/
const requestEthereumAccounts = {
methodNames: [MESSAGE_TYPE.ETH_REQUEST_ACCOUNTS],
implementation: requestEthereumAccountsHandler,
hookNames: {
origin: true,
getAccounts: true,
getUnlockPromise: true,
hasPermission: true,
requestAccountsPermission: true,
},
};
export default requestEthereumAccounts;
// Used to rate-limit pending requests to one per origin
const locks = new Set();
/**
* @typedef {Record<string, string | Function>} RequestEthereumAccountsOptions
* @property {string} origin - The requesting origin.
* @property {Function} getAccounts - Gets the accounts for the requesting
* origin.
* @property {Function} getUnlockPromise - Gets a promise that resolves when
* the extension unlocks.
* @property {Function} hasPermission - Returns whether the requesting origin
* has the specified permission.
* @property {Function} requestAccountsPermission - Requests the `eth_accounts`
* permission for the requesting origin.
*/
/**
*
* @param {import('json-rpc-engine').JsonRpcRequest<unknown>} _req - The JSON-RPC request object.
* @param {import('json-rpc-engine').JsonRpcResponse<true>} res - The JSON-RPC response object.
* @param {Function} _next - The json-rpc-engine 'next' callback.
* @param {Function} end - The json-rpc-engine 'end' callback.
* @param {RequestEthereumAccountsOptions} options - The RPC method hooks.
*/
async function requestEthereumAccountsHandler(
_req,
res,
_next,
end,
{
origin,
getAccounts,
getUnlockPromise,
hasPermission,
requestAccountsPermission,
},
) {
if (locks.has(origin)) {
res.error = ethErrors.rpc.resourceUnavailable(
`Already processing ${MESSAGE_TYPE.ETH_REQUEST_ACCOUNTS}. Please wait.`,
);
return end();
}
if (hasPermission(MESSAGE_TYPE.ETH_ACCOUNTS)) {
// We wait for the extension to unlock in this case only, because permission
// requests are handled when the extension is unlocked, regardless of the
// lock state when they were received.
try {
locks.add(origin);
await getUnlockPromise();
res.result = await getAccounts();
end();
} catch (error) {
end(error);
} finally {
locks.delete(origin);
}
return undefined;
}
// If no accounts, request the accounts permission
try {
await requestAccountsPermission();
} catch (err) {
res.error = err;
return end();
}
// Get the approved accounts
const accounts = await getAccounts();
/* istanbul ignore else: too hard to induce, see below comment */
if (accounts.length > 0) {
res.result = accounts;
} else {
// This should never happen, because it should be caught in the
// above catch clause
res.error = ethErrors.rpc.internal(
'Accounts unexpectedly unavailable. Please report this bug.',
);
}
return end();
}

View File

@ -0,0 +1,43 @@
import { ethErrors } from 'eth-rpc-errors';
import { MESSAGE_TYPE } from '../../../../../shared/constants/app';
/**
* This internal method is used by our external provider to send metadata about
* permission subjects so that we can e.g. display a proper name and icon in
* our UI.
*/
const sendMetadata = {
methodNames: [MESSAGE_TYPE.SEND_METADATA],
implementation: sendMetadataHandler,
hookNames: {
addSubjectMetadata: true,
},
};
export default sendMetadata;
/**
* @typedef {Record<string, Function>} SendMetadataOptions
* @property {Function} addSubjectMetadata - A function that records subject
* metadata, bound to the requesting origin.
*/
/**
* @param {import('json-rpc-engine').JsonRpcRequest<unknown>} req - The JSON-RPC request object.
* @param {import('json-rpc-engine').JsonRpcResponse<true>} res - The JSON-RPC response object.
* @param {Function} _next - The json-rpc-engine 'next' callback.
* @param {Function} end - The json-rpc-engine 'end' callback.
* @param {SendMetadataOptions} options
*/
function sendMetadataHandler(req, res, _next, end, { addSubjectMetadata }) {
const { params } = req;
if (params && typeof params === 'object' && !Array.isArray(params)) {
const { icon = null, name = null, ...remainingParams } = params;
addSubjectMetadata({ ...remainingParams, iconUrl: icon, name });
} else {
return end(ethErrors.rpc.invalidParams({ data: params }));
}
res.result = true;
return end();
}

View File

@ -9,6 +9,7 @@ import createFilterMiddleware from 'eth-json-rpc-filters';
import createSubscriptionManager from 'eth-json-rpc-filters/subscriptionManager';
import { providerAsMiddleware } from 'eth-json-rpc-middleware';
import KeyringController from 'eth-keyring-controller';
import { errorCodes as rpcErrorCodes, ethErrors } from 'eth-rpc-errors';
import { Mutex } from 'await-semaphore';
import { stripHexPrefix } from 'ethereumjs-util';
import log from 'loglevel';
@ -18,7 +19,6 @@ import LatticeKeyring from 'eth-lattice-keyring';
import { MetaMaskKeyring as QRHardwareKeyring } from '@keystonehq/metamask-airgapped-keyring';
import EthQuery from 'eth-query';
import nanoid from 'nanoid';
import { ethErrors } from 'eth-rpc-errors';
import { captureException } from '@sentry/browser';
import {
AddressBookController,
@ -35,6 +35,11 @@ import {
AssetsContractController,
CollectibleDetectionController,
} from '@metamask/controllers';
import {
PermissionController,
SubjectMetadataController,
} from '@metamask/snap-controllers';
import { TRANSACTION_STATUSES } from '../../shared/constants/transaction';
import {
GAS_API_BASE_URL,
@ -46,6 +51,10 @@ import {
DEVICE_NAMES,
KEYRING_TYPES,
} from '../../shared/constants/hardware-wallets';
import {
CaveatTypes,
RestrictedMethods,
} from '../../shared/constants/permissions';
import { UI_NOTIFICATIONS } from '../../shared/notifications';
import { toChecksumHexAddress } from '../../shared/modules/hexstring-utils';
import { MILLISECOND } from '../../shared/constants/time';
@ -76,16 +85,24 @@ import PersonalMessageManager from './lib/personal-message-manager';
import TypedMessageManager from './lib/typed-message-manager';
import TransactionController from './controllers/transactions';
import DetectTokensController from './controllers/detect-tokens';
import PermissionLogController from './controllers/permissions/permission-log';
import SwapsController from './controllers/swaps';
import { PermissionsController } from './controllers/permissions';
import { NOTIFICATION_NAMES } from './controllers/permissions/enums';
import getRestrictedMethods from './controllers/permissions/restrictedMethods';
import nodeify from './lib/nodeify';
import { nodeify, nodeifyObject } from './lib/nodeify';
import accountImporter from './account-import-strategies';
import seedPhraseVerifier from './lib/seed-phrase-verifier';
import MetaMetricsController from './controllers/metametrics';
import { segment } from './lib/segment';
import createMetaRPCHandler from './lib/createMetaRPCHandler';
import {
CaveatMutatorFactories,
getCaveatSpecifications,
getChangedAccounts,
getPermissionBackgroundApiMethods,
getPermissionSpecifications,
getPermittedAccountsByOrigin,
unrestrictedMethods,
} from './controllers/permissions';
export const METAMASK_CONTROLLER_EVENTS = {
// Fired after state changes that impact the extension badge (unapproved msg count)
@ -454,24 +471,43 @@ export default class MetamaskController extends EventEmitter {
this.keyringController.on('unlock', () => this._onUnlock());
this.keyringController.on('lock', () => this._onLock());
this.permissionsController = new PermissionsController(
{
approvals: this.approvalController,
getKeyringAccounts: this.keyringController.getAccounts.bind(
const getIdentities = () =>
this.preferencesController.store.getState().identities;
this.permissionController = new PermissionController({
messenger: this.controllerMessenger.getRestricted({
name: 'PermissionController',
allowedActions: [
`${this.approvalController.name}:addRequest`,
`${this.approvalController.name}:hasRequest`,
`${this.approvalController.name}:acceptRequest`,
`${this.approvalController.name}:rejectRequest`,
],
}),
state: initState.PermissionController,
caveatSpecifications: getCaveatSpecifications({ getIdentities }),
permissionSpecifications: getPermissionSpecifications({
getIdentities,
getAllAccounts: this.keyringController.getAccounts.bind(
this.keyringController,
),
getRestrictedMethods,
getUnlockPromise: this.appStateController.getUnlockPromise.bind(
this.appStateController,
),
isUnlocked: this.isUnlocked.bind(this),
notifyDomain: this.notifyConnections.bind(this),
notifyAllDomains: this.notifyAllConnections.bind(this),
preferences: this.preferencesController.store,
},
initState.PermissionsController,
initState.PermissionsMetadata,
);
}),
unrestrictedMethods,
});
this.permissionLogController = new PermissionLogController({
restrictedMethods: new Set(Object.keys(RestrictedMethods)),
initState: initState.PermissionLogController,
});
this.subjectMetadataController = new SubjectMetadataController({
messenger: this.controllerMessenger.getRestricted({
name: 'SubjectMetadataController',
allowedActions: [`${this.permissionController.name}:hasPermissions`],
}),
state: initState.SubjectMetadataController,
subjectCacheLimit: 100,
});
this.detectTokensController = new DetectTokensController({
preferences: this.preferencesController,
@ -508,9 +544,7 @@ export default class MetamaskController extends EventEmitter {
this.txController = new TransactionController({
initState:
initState.TransactionController || initState.TransactionManager,
getPermittedAccounts: this.permissionsController.getAccounts.bind(
this.permissionsController,
),
getPermittedAccounts: this.getPermittedAccounts.bind(this),
getProviderConfig: this.networkController.getProviderConfig.bind(
this.networkController,
),
@ -670,8 +704,9 @@ export default class MetamaskController extends EventEmitter {
AlertController: this.alertController.store,
OnboardingController: this.onboardingController.store,
IncomingTransactionsController: this.incomingTransactionsController.store,
PermissionsController: this.permissionsController.permissions,
PermissionsMetadata: this.permissionsController.store,
PermissionController: this.permissionController,
PermissionLogController: this.permissionLogController.store,
SubjectMetadataController: this.subjectMetadataController,
ThreeBoxController: this.threeBoxController.store,
NotificationController: this.notificationController,
GasFeeController: this.gasFeeController,
@ -702,8 +737,9 @@ export default class MetamaskController extends EventEmitter {
OnboardingController: this.onboardingController.store,
IncomingTransactionsController: this.incomingTransactionsController
.store,
PermissionsController: this.permissionsController.permissions,
PermissionsMetadata: this.permissionsController.store,
PermissionController: this.permissionController,
PermissionLogController: this.permissionLogController.store,
SubjectMetadataController: this.subjectMetadataController,
ThreeBoxController: this.threeBoxController.store,
SwapsController: this.swapsController.store,
EnsController: this.ensController.store,
@ -738,10 +774,77 @@ export default class MetamaskController extends EventEmitter {
);
});
this.setupControllerEventSubscriptions();
// TODO:LegacyProvider: Delete
this.publicConfigStore = this.createPublicConfigStore();
}
/**
* Sets up BaseController V2 event subscriptions. Currently, this includes
* the subscriptions necessary to notify permission subjects of account
* changes.
*
* Some of the subscriptions in this method are ControllerMessenger selector
* event subscriptions. See the relevant @metamask/controllers documentation
* for more information.
*
* Note that account-related notifications emitted when the extension
* becomes unlocked are handled in MetaMaskController._onUnlock.
*/
setupControllerEventSubscriptions() {
const handleAccountsChange = async (origin, newAccounts) => {
if (this.isUnlocked()) {
this.notifyConnections(origin, {
method: NOTIFICATION_NAMES.accountsChanged,
// This should be the same as the return value of `eth_accounts`,
// namely an array of the current / most recently selected Ethereum
// account.
params:
newAccounts.length < 2
? // If the length is 1 or 0, the accounts are sorted by definition.
newAccounts
: // If the length is 2 or greater, we have to execute
// `eth_accounts` vi this method.
await this.getPermittedAccounts(origin),
});
}
this.permissionLogController.updateAccountsHistory(origin, newAccounts);
};
// This handles account changes whenever the selected address changes.
let lastSelectedAddress;
this.preferencesController.store.subscribe(async ({ selectedAddress }) => {
if (selectedAddress && selectedAddress !== lastSelectedAddress) {
lastSelectedAddress = selectedAddress;
const permittedAccountsMap = getPermittedAccountsByOrigin(
this.permissionController.state,
);
for (const [origin, accounts] of permittedAccountsMap.entries()) {
if (accounts.includes(selectedAddress)) {
handleAccountsChange(origin, accounts);
}
}
}
});
// This handles account changes every time relevant permission state
// changes, for any reason.
this.controllerMessenger.subscribe(
`${this.permissionController.name}:stateChange`,
async (currentValue, previousValue) => {
const changedAccounts = getChangedAccounts(currentValue, previousValue);
for (const [origin, accounts] of changedAccounts.entries()) {
handleAccountsChange(origin, accounts);
}
},
getPermittedAccountsByOrigin,
);
}
/**
* Constructor helper: initialize a provider.
*/
@ -759,7 +862,7 @@ export default class MetamaskController extends EventEmitter {
const selectedAddress = this.preferencesController.getSelectedAddress();
return selectedAddress ? [selectedAddress] : [];
} else if (this.isUnlocked()) {
return await this.permissionsController.getAccounts(origin);
return await this.getPermittedAccounts(origin);
}
return []; // changing this is a breaking change
},
@ -835,7 +938,7 @@ export default class MetamaskController extends EventEmitter {
return {
isUnlocked: this.isUnlocked(),
...this.getProviderNetworkState(),
accounts: await this.permissionsController.getAccounts(origin),
accounts: await this.getPermittedAccounts(origin),
};
}
@ -888,7 +991,7 @@ export default class MetamaskController extends EventEmitter {
metaMetricsController,
networkController,
onboardingController,
permissionsController,
permissionController,
preferencesController,
swapsController,
threeBoxController,
@ -1208,29 +1311,18 @@ export default class MetamaskController extends EventEmitter {
initializeThreeBox: nodeify(this.initializeThreeBox, this),
// permissions
removePermissionsFor: permissionController.revokePermissions.bind(
permissionController,
),
approvePermissionsRequest: nodeify(
permissionsController.approvePermissionsRequest,
permissionsController,
permissionController.acceptPermissionsRequest,
permissionController,
),
rejectPermissionsRequest: nodeify(
permissionsController.rejectPermissionsRequest,
permissionsController,
),
removePermissionsFor: permissionsController.removePermissionsFor.bind(
permissionsController,
),
addPermittedAccount: nodeify(
permissionsController.addPermittedAccount,
permissionsController,
),
removePermittedAccount: nodeify(
permissionsController.removePermittedAccount,
permissionsController,
),
requestAccountsPermissionWithId: nodeify(
permissionsController.requestAccountsPermissionWithId,
permissionsController,
permissionController.rejectPermissionsRequest,
permissionController,
),
...nodeifyObject(getPermissionBackgroundApiMethods(permissionController)),
// swaps
fetchAndSetQuotes: nodeify(
@ -1432,7 +1524,7 @@ export default class MetamaskController extends EventEmitter {
this.preferencesController.setAddresses([]);
// clear permissions
this.permissionsController.clearPermissions();
this.permissionController.clearState();
// clear accounts in accountTracker
this.accountTracker.clearAccounts();
@ -1925,6 +2017,48 @@ export default class MetamaskController extends EventEmitter {
return selectedAddress;
}
/**
* Gets the permitted accounts for the specified origin. Returns an empty
* array if no accounts are permitted.
*
* @param {string} origin - The origin whose exposed accounts to retrieve.
* @returns {Promise<string[]>} The origin's permitted accounts, or an empty
* array.
*/
async getPermittedAccounts(origin) {
try {
return await this.permissionController.executeRestrictedMethod(
origin,
RestrictedMethods.eth_accounts,
);
} catch (error) {
if (error.code === rpcErrorCodes.provider.unauthorized) {
return [];
}
throw error;
}
}
/**
* Stops exposing the account with the specified address to all third parties.
* Exposed accounts are stored in caveats of the eth_accounts permission. This
* method uses `PermissionController.updatePermissionsByCaveat` to
* remove the specified address from every eth_accounts permission. If a
* permission only included this address, the permission is revoked entirely.
*
* @param {string} targetAccount - The address of the account to stop exposing
* to third parties.
*/
removeAllAccountPermissions(targetAccount) {
this.permissionController.updatePermissionsByCaveat(
CaveatTypes.restrictReturnedAccounts,
(existingAccounts) =>
CaveatMutatorFactories[
CaveatTypes.restrictReturnedAccounts
].removeAccount(targetAccount, existingAccounts),
);
}
/**
* Removes an account from state / storage.
*
@ -1933,7 +2067,7 @@ export default class MetamaskController extends EventEmitter {
*/
async removeAccount(address) {
// Remove all associated permissions
await this.permissionsController.removeAllAccountPermissions(address);
this.removeAllAccountPermissions(address);
// Remove account from the preferences controller
this.preferencesController.removeAddress(address);
// Remove account from the account tracker controller
@ -2607,10 +2741,12 @@ export default class MetamaskController extends EventEmitter {
*/
setupProviderConnection(outStream, sender, isInternal) {
const origin = isInternal ? 'metamask' : new URL(sender.url).origin;
let extensionId;
if (sender.id !== this.extension.runtime.id) {
extensionId = sender.id;
this.subjectMetadataController.addSubjectMetadata(origin, {
extensionId: sender.id,
});
}
let tabId;
if (sender.tab && sender.tab.id) {
tabId = sender.tab.id;
@ -2619,7 +2755,6 @@ export default class MetamaskController extends EventEmitter {
const engine = this.setupProviderEngine({
origin,
location: sender.url,
extensionId,
tabId,
isInternal,
});
@ -2648,17 +2783,10 @@ export default class MetamaskController extends EventEmitter {
* @param {Object} options - Provider engine options
* @param {string} options.origin - The origin of the sender
* @param {string} options.location - The full URL of the sender
* @param {extensionId} [options.extensionId] - The extension ID of the sender, if the sender is an external extension
* @param {tabId} [options.tabId] - The tab ID of the sender - if the sender is within a tab
* @param {boolean} [options.isInternal] - True if called for a connection to an internal process
**/
setupProviderEngine({
origin,
location,
extensionId,
tabId,
isInternal = false,
}) {
setupProviderEngine({ origin, location, tabId, isInternal = false }) {
// setup json rpc engine stack
const engine = new JsonRpcEngine();
const { provider, blockTracker } = this;
@ -2689,40 +2817,47 @@ export default class MetamaskController extends EventEmitter {
registerOnboarding: this.onboardingController.registerOnboarding,
}),
);
engine.push(this.permissionLogController.createMiddleware());
engine.push(
createMethodMiddleware({
origin,
// Miscellaneous
addSubjectMetadata: this.subjectMetadataController.addSubjectMetadata.bind(
this.subjectMetadataController,
origin,
),
getProviderState: this.getProviderState.bind(this),
sendMetrics: this.metaMetricsController.trackEvent.bind(
this.metaMetricsController,
getUnlockPromise: this.appStateController.getUnlockPromise.bind(
this.appStateController,
),
handleWatchAssetRequest: this.tokensController.watchAsset.bind(
this.tokensController,
),
getWeb3ShimUsageState: this.alertController.getWeb3ShimUsageState.bind(
this.alertController,
),
setWeb3ShimUsageRecorded: this.alertController.setWeb3ShimUsageRecorded.bind(
this.alertController,
),
findCustomRpcBy: this.findCustomRpcBy.bind(this),
getCurrentChainId: this.networkController.getCurrentChainId.bind(
this.networkController,
),
requestUserApproval: this.approvalController.addAndShowApprovalRequest.bind(
this.approvalController,
),
updateRpcTarget: ({ rpcUrl, chainId, ticker, nickname }) => {
this.networkController.setRpcTarget(
rpcUrl,
chainId,
ticker,
nickname,
);
},
setProviderType: this.networkController.setProviderType.bind(
this.networkController,
sendMetrics: this.metaMetricsController.trackEvent.bind(
this.metaMetricsController,
),
// Permission-related
getAccounts: this.getPermittedAccounts.bind(this, origin),
getPermissionsForOrigin: this.permissionController.getPermissions.bind(
this.permissionController,
origin,
),
hasPermission: this.permissionController.hasPermission.bind(
this.permissionController,
origin,
),
requestAccountsPermission: this.permissionController.requestPermissions.bind(
this.permissionController,
{ origin },
{ eth_accounts: {} },
),
// Custom RPC-related
addCustomRpc: async ({
chainId,
blockExplorerUrl,
@ -2740,6 +2875,29 @@ export default class MetamaskController extends EventEmitter {
},
);
},
findCustomRpcBy: this.findCustomRpcBy.bind(this),
getCurrentChainId: this.networkController.getCurrentChainId.bind(
this.networkController,
),
setProviderType: this.networkController.setProviderType.bind(
this.networkController,
),
updateRpcTarget: ({ rpcUrl, chainId, ticker, nickname }) => {
this.networkController.setRpcTarget(
rpcUrl,
chainId,
ticker,
nickname,
);
},
// Web3 shim-related
getWeb3ShimUsageState: this.alertController.getWeb3ShimUsageState.bind(
this.alertController,
),
setWeb3ShimUsageRecorded: this.alertController.setWeb3ShimUsageRecorded.bind(
this.alertController,
),
}),
);
// filter and subscription polyfills
@ -2748,7 +2906,9 @@ export default class MetamaskController extends EventEmitter {
if (!isInternal) {
// permissions
engine.push(
this.permissionsController.createMiddleware({ origin, extensionId }),
this.permissionController.createPermissionMiddleware({
origin,
}),
);
}
// forward to metamask primary provider
@ -2835,7 +2995,7 @@ export default class MetamaskController extends EventEmitter {
* Ignores unknown origins.
*
* @param {string} origin - The connection's origin string.
* @param {any} payload - The event payload.
* @param {unknown} payload - The event payload.
*/
notifyConnections(origin, payload) {
const connections = this.connections[origin];
@ -2860,7 +3020,7 @@ export default class MetamaskController extends EventEmitter {
* The caller is responsible for ensuring that only permitted notifications
* are sent.
*
* @param {any} payload - The event payload, or payload getter function.
* @param {unknown} payload - The event payload, or payload getter function.
*/
notifyAllConnections(payload) {
const getPayload =
@ -2902,8 +3062,9 @@ export default class MetamaskController extends EventEmitter {
}
/**
* Handle global unlock, triggered by KeyringController unlock.
* Notifies all connections that the extension is unlocked.
* Handle global application unlock.
* Notifies all connections that the extension is unlocked, and which
* account(s) are currently accessible, if any.
*/
_onUnlock() {
this.notifyAllConnections(async (origin) => {
@ -2911,15 +3072,19 @@ export default class MetamaskController extends EventEmitter {
method: NOTIFICATION_NAMES.unlockStateChanged,
params: {
isUnlocked: true,
accounts: await this.permissionsController.getAccounts(origin),
accounts: await this.getPermittedAccounts(origin),
},
};
});
// In the current implementation, this handler is triggered by a
// KeyringController event. Other controllers subscribe to the 'unlock'
// event of the MetaMaskController itself.
this.emit('unlock');
}
/**
* Handle global lock, triggered by KeyringController lock.
* Handle global application lock.
* Notifies all connections that the extension is locked.
*/
_onLock() {
@ -2929,6 +3094,10 @@ export default class MetamaskController extends EventEmitter {
isUnlocked: false,
},
});
// In the current implementation, this handler is triggered by a
// KeyringController event. Other controllers subscribe to the 'lock'
// event of the MetaMaskController itself.
this.emit('lock');
}

View File

@ -768,10 +768,7 @@ describe('MetaMaskController', function () {
sinon.stub(metamaskController.preferencesController, 'removeAddress');
sinon.stub(metamaskController.accountTracker, 'removeAccount');
sinon.stub(metamaskController.keyringController, 'removeAccount');
sinon.stub(
metamaskController.permissionsController,
'removeAllAccountPermissions',
);
sinon.stub(metamaskController, 'removeAllAccountPermissions');
ret = await metamaskController.removeAccount(addressToRemove);
});
@ -780,7 +777,7 @@ describe('MetaMaskController', function () {
metamaskController.keyringController.removeAccount.restore();
metamaskController.accountTracker.removeAccount.restore();
metamaskController.preferencesController.removeAddress.restore();
metamaskController.permissionsController.removeAllAccountPermissions.restore();
metamaskController.removeAllAccountPermissions.restore();
});
it('should call preferencesController.removeAddress', async function () {
@ -804,9 +801,9 @@ describe('MetaMaskController', function () {
),
);
});
it('should call permissionsController.removeAllAccountPermissions', async function () {
it('should call metamaskController.removeAllAccountPermissions', async function () {
assert(
metamaskController.permissionsController.removeAllAccountPermissions.calledWith(
metamaskController.removeAllAccountPermissions.calledWith(
addressToRemove,
),
);

View File

@ -0,0 +1,161 @@
import { cloneDeep } from 'lodash';
const version = 68;
/**
* Transforms the PermissionsController and PermissionsMetadata substates
* to match the new permission system.
*/
export default {
version,
async migrate(originalVersionedData) {
const versionedData = cloneDeep(originalVersionedData);
versionedData.meta.version = version;
const state = versionedData.data;
const newState = transformState(state);
versionedData.data = newState;
return versionedData;
},
};
function transformState(state) {
const {
PermissionsController = {},
PermissionsMetadata = {},
...remainingState
} = state;
const {
domainMetadata = {},
permissionsHistory = {},
permissionsLog = [],
} = PermissionsMetadata;
return {
...remainingState,
PermissionController: getPermissionControllerState(PermissionsController),
PermissionLogController: {
permissionActivityLog: permissionsLog,
permissionHistory: permissionsHistory,
},
SubjectMetadataController: getSubjectMetadataControllerState(
domainMetadata,
),
};
}
function getPermissionControllerState(PermissionsController) {
const { domains = {} } = PermissionsController;
/**
* Example existing domain entry. Every existing domain will have a single
* eth_accounts permission, which simplifies the transform.
*
* 'https://metamask.github.io': {
* permissions: [
* {
* '@context': ['https://github.com/MetaMask/rpc-cap'],
* 'caveats': [
* {
* name: 'primaryAccountOnly',
* type: 'limitResponseLength',
* value: 1,
* },
* {
* name: 'exposedAccounts',
* type: 'filterResponse',
* value: ['0x0c97a5c81e50a02ff8be73cc3f0a0569e61f4ed8'],
* },
* ],
* 'date': 1616006369498,
* 'id': '3d0bdc27-e8e4-4fb0-a24b-340d61f6a3fa',
* 'invoker': 'https://metamask.github.io',
* 'parentCapability': 'eth_accounts',
* },
* ],
* },
*/
const ETH_ACCOUNTS = 'eth_accounts';
const NEW_CAVEAT_TYPE = 'restrictReturnedAccounts';
const OLD_CAVEAT_NAME = 'exposedAccounts';
const subjects = Object.entries(domains).reduce(
(transformed, [origin, domainEntry]) => {
const {
permissions: [ethAccountsPermission],
} = domainEntry;
// There are two caveats for each eth_accounts permission, but we only
// need the value of one of them in the new permission system.
const oldCaveat = ethAccountsPermission.caveats.find(
(caveat) => caveat.name === OLD_CAVEAT_NAME,
);
const newPermission = {
...ethAccountsPermission,
caveats: [{ type: NEW_CAVEAT_TYPE, value: oldCaveat.value }],
};
// We never used this, and just omit it in the new system.
delete newPermission['@context'];
transformed[origin] = {
origin,
permissions: {
[ETH_ACCOUNTS]: newPermission,
},
};
return transformed;
},
{},
);
return {
subjects,
};
}
function getSubjectMetadataControllerState(domainMetadata) {
/**
* Example existing domainMetadata entry.
*
* "https://www.youtube.com": {
* "host": "www.youtube.com",
* "icon": null,
* "lastUpdated": 1637697914908,
* "name": "YouTube"
* }
*/
const subjectMetadata = Object.entries(domainMetadata).reduce(
(transformed, [origin, metadata]) => {
const {
name = null,
icon = null,
extensionId = null,
...other
} = metadata;
// We're getting rid of these.
delete other.lastUpdated;
delete other.host;
if (origin) {
transformed[origin] = {
name,
iconUrl: icon,
extensionId,
...other,
origin,
};
}
return transformed;
},
{},
);
return {
subjectMetadata,
};
}

View File

@ -0,0 +1,450 @@
import migration68 from './068';
describe('migration #68', () => {
it('should update the version metadata', async () => {
const oldStorage = {
meta: {
version: 67,
},
data: {},
};
const newStorage = await migration68.migrate(oldStorage);
expect(newStorage.meta).toStrictEqual({
version: 68,
});
});
it('should migrate all data', async () => {
const oldStorage = {
meta: {
version: 67,
},
data: getOldState(),
};
const newStorage = await migration68.migrate(oldStorage);
expect(newStorage).toMatchObject({
meta: {
version: 68,
},
data: {
FooController: { a: 'b' },
PermissionController: { subjects: expect.any(Object) },
PermissionLogController: {
permissionActivityLog: expect.any(Object),
permissionHistory: expect.any(Object),
},
SubjectMetadataController: { subjectMetadata: expect.any(Object) },
},
});
expect(newStorage.PermissionsController).toBeUndefined();
expect(newStorage.PermissionsMetadata).toBeUndefined();
});
it('should migrate the PermissionsController state', async () => {
const oldStorage = {
meta: {},
data: {
PermissionsController: getOldState().PermissionsController,
},
};
const newStorage = await migration68.migrate(oldStorage);
const { PermissionController } = newStorage.data;
expect(PermissionController).toStrictEqual({
subjects: {
'https://faucet.metamask.io': {
origin: 'https://faucet.metamask.io',
permissions: {
eth_accounts: {
caveats: [
{
type: 'restrictReturnedAccounts',
value: ['0xc42edfcc21ed14dda456aa0756c153f7985d8813'],
},
],
date: 1597334833084,
id: 'e01bada4-ddc7-47b6-be67-d4603733e0e9',
invoker: 'https://faucet.metamask.io',
parentCapability: 'eth_accounts',
},
},
},
'https://metamask.github.io': {
origin: 'https://metamask.github.io',
permissions: {
eth_accounts: {
caveats: [
{
type: 'restrictReturnedAccounts',
value: ['0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'],
},
],
date: 1616006369498,
id: '3d0bdc27-e8e4-4fb0-a24b-340d61f6a3fa',
invoker: 'https://metamask.github.io',
parentCapability: 'eth_accounts',
},
},
},
'https://xdai.io': {
origin: 'https://xdai.io',
permissions: {
eth_accounts: {
caveats: [
{
type: 'restrictReturnedAccounts',
value: ['0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'],
},
],
date: 1605908022382,
id: '88c5de24-11a9-4f1e-9651-b072f4c11928',
invoker: 'https://xdai.io',
parentCapability: 'eth_accounts',
},
},
},
},
});
});
it('should migrate the PermissionsMetadata state', async () => {
const oldStorage = {
meta: {},
data: {
PermissionsMetadata: getOldState().PermissionsMetadata,
},
};
const newStorage = await migration68.migrate(oldStorage);
const {
PermissionLogController,
SubjectMetadataController,
} = newStorage.data;
const expected = getOldState().PermissionsMetadata;
expect(PermissionLogController.permissionHistory).toStrictEqual(
expected.permissionsHistory,
);
expect(PermissionLogController.permissionActivityLog).toStrictEqual(
expected.permissionsLog,
);
expect(SubjectMetadataController).toStrictEqual({
subjectMetadata: {
'https://1inch.exchange': {
iconUrl: 'https://1inch.exchange/assets/favicon/favicon-32x32.png',
name: 'DEX Aggregator - 1inch.exchange',
origin: 'https://1inch.exchange',
extensionId: null,
},
'https://ascii-tree-generator.com': {
iconUrl: 'https://ascii-tree-generator.com/favicon.ico',
name: 'ASCII Tree Generator',
origin: 'https://ascii-tree-generator.com',
extensionId: null,
},
'https://caniuse.com': {
iconUrl: 'https://caniuse.com/img/favicon-128.png',
name: 'Can I use... Support tables for HTML5, CSS3, etc',
origin: 'https://caniuse.com',
extensionId: null,
},
'https://core-geth.org': {
iconUrl: 'https://core-geth.org/icons/icon-48x48.png',
name: 'core-geth.org',
origin: 'https://core-geth.org',
extensionId: null,
},
'https://docs.npmjs.com': {
iconUrl: 'https://docs.npmjs.com/favicon-32x32.png',
name: 'package-locks | npm Docs',
origin: 'https://docs.npmjs.com',
extensionId: null,
},
},
});
});
it('should handle domain metadata edge cases', async () => {
const oldStorage = {
meta: {},
data: {
PermissionsMetadata: {
domainMetadata: {
'foo.bar': {
// no name
icon: 'fooIcon',
extensionId: 'fooExtension', // non-null
origin: null, // should get overwritten
extraProperty: 'bar', // should be preserved
},
},
},
},
};
const newStorage = await migration68.migrate(oldStorage);
expect(
newStorage.data.SubjectMetadataController.subjectMetadata,
).toStrictEqual({
'foo.bar': {
name: null, // replaced with null
iconUrl: 'fooIcon', // preserved value, changed name
extensionId: 'fooExtension', // preserved
origin: 'foo.bar', // overwritten with correct origin
extraProperty: 'bar', // preserved
},
});
});
});
function getOldState() {
return {
FooController: { a: 'b' }, // just to ensure it's not touched
PermissionsController: {
domains: {
'https://faucet.metamask.io': {
permissions: [
{
'@context': ['https://github.com/MetaMask/rpc-cap'],
'caveats': [
{
name: 'primaryAccountOnly',
type: 'limitResponseLength',
value: 1,
},
{
name: 'exposedAccounts',
type: 'filterResponse',
value: ['0xc42edfcc21ed14dda456aa0756c153f7985d8813'],
},
],
'date': 1597334833084,
'id': 'e01bada4-ddc7-47b6-be67-d4603733e0e9',
'invoker': 'https://faucet.metamask.io',
'parentCapability': 'eth_accounts',
},
],
},
'https://metamask.github.io': {
permissions: [
{
'@context': ['https://github.com/MetaMask/rpc-cap'],
'caveats': [
{
name: 'primaryAccountOnly',
type: 'limitResponseLength',
value: 1,
},
{
name: 'exposedAccounts',
type: 'filterResponse',
value: ['0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'],
},
],
'date': 1616006369498,
'id': '3d0bdc27-e8e4-4fb0-a24b-340d61f6a3fa',
'invoker': 'https://metamask.github.io',
'parentCapability': 'eth_accounts',
},
],
},
'https://xdai.io': {
permissions: [
{
'@context': ['https://github.com/MetaMask/rpc-cap'],
'caveats': [
{
name: 'primaryAccountOnly',
type: 'limitResponseLength',
value: 1,
},
{
name: 'exposedAccounts',
type: 'filterResponse',
value: ['0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'],
},
],
'date': 1605908022382,
'id': '88c5de24-11a9-4f1e-9651-b072f4c11928',
'invoker': 'https://xdai.io',
'parentCapability': 'eth_accounts',
},
],
},
},
permissionsDescriptions: {},
permissionsRequests: [],
},
PermissionsMetadata: {
domainMetadata: {
'https://1inch.exchange': {
host: '1inch.exchange',
icon: 'https://1inch.exchange/assets/favicon/favicon-32x32.png',
lastUpdated: 1605489265143,
name: 'DEX Aggregator - 1inch.exchange',
},
'https://ascii-tree-generator.com': {
host: 'ascii-tree-generator.com',
icon: 'https://ascii-tree-generator.com/favicon.ico',
lastUpdated: 1637721988618,
name: 'ASCII Tree Generator',
},
'https://caniuse.com': {
host: 'caniuse.com',
icon: 'https://caniuse.com/img/favicon-128.png',
lastUpdated: 1637692936599,
name: 'Can I use... Support tables for HTML5, CSS3, etc',
},
'https://core-geth.org': {
host: 'core-geth.org',
icon: 'https://core-geth.org/icons/icon-48x48.png',
lastUpdated: 1637692093173,
name: 'core-geth.org',
},
'https://docs.npmjs.com': {
host: 'docs.npmjs.com',
icon: 'https://docs.npmjs.com/favicon-32x32.png',
lastUpdated: 1637721451476,
name: 'package-locks | npm Docs',
},
},
permissionsHistory: {
'https://opensea.io': {
eth_accounts: {
accounts: {
'0xc42edfcc21ed14dda456aa0756c153f7985d8813': 1617399873696,
},
lastApproved: 1617399873696,
},
},
'https://faucet.metamask.io': {
eth_accounts: {
accounts: {
'0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': 1620369333736,
},
lastApproved: 1610405614031,
},
},
'https://metamask.github.io': {
eth_accounts: {
accounts: {
'0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': 1620759882723,
'0xf9eab18b7db3adf8cd6bd5f4aed9e1d5e0e7f926': 1616005950557,
},
lastApproved: 1620759882723,
},
},
'https://xdai.io': {
eth_accounts: {
accounts: {
'0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': 1620369333736,
},
lastApproved: 1605908022384,
},
},
},
permissionsLog: [
{
id: 3642448888,
method: 'eth_accounts',
methodType: 'restricted',
origin: 'https://metamask.github.io',
request: {
id: 3642448888,
jsonrpc: '2.0',
method: 'eth_accounts',
origin: 'https://metamask.github.io',
tabId: 489,
},
requestTime: 1615325885561,
response: {
id: 3642448888,
jsonrpc: '2.0',
result: [],
},
responseTime: 1615325885561,
success: true,
},
{
id: 2960964763,
method: 'wallet_getPermissions',
methodType: 'internal',
origin: 'https://metamask.github.io',
request: {
id: 2960964763,
jsonrpc: '2.0',
method: 'wallet_getPermissions',
origin: 'https://metamask.github.io',
tabId: 145,
},
requestTime: 1620759866273,
response: {
id: 2960964763,
jsonrpc: '2.0',
result: [
{
'@context': ['https://github.com/MetaMask/rpc-cap'],
'caveats': [
{
name: 'primaryAccountOnly',
type: 'limitResponseLength',
value: 1,
},
{
name: 'exposedAccounts',
type: 'filterResponse',
value: ['0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'],
},
],
'date': 1616006369498,
'id': '3d0bdc27-e8e4-4fb0-a24b-340d61f6a3fa',
'invoker': 'https://metamask.github.io',
'parentCapability': 'eth_accounts',
},
],
},
responseTime: 1620759866273,
success: true,
},
{
id: 2960964764,
method: 'eth_accounts',
methodType: 'restricted',
origin: 'https://metamask.github.io',
request: {
id: 2960964764,
jsonrpc: '2.0',
method: 'eth_accounts',
origin: 'https://metamask.github.io',
tabId: 145,
},
requestTime: 1620759866280,
response: {
id: 2960964764,
jsonrpc: '2.0',
result: [],
},
responseTime: 1620759866280,
success: true,
},
{
id: 519616456,
method: 'eth_accounts',
methodType: 'restricted',
origin: 'http://localhost:9011',
request:
'{\n "method": "eth_accounts",\n "jsonrpc": "2.0",\n "id": 519616456,\n "origin": "http://localhost:9011",\n "tabId": 1020\n}',
requestTime: 1636479612050,
response:
'{\n "id": 519616456,\n "jsonrpc": "2.0",\n "result": []\n}',
responseTime: 1636479612051,
success: true,
},
],
},
};
}

View File

@ -71,6 +71,7 @@ import m064 from './064';
import m065 from './065';
import m066 from './066';
import m067 from './067';
import m068 from './068';
const migrations = [
m002,
@ -139,6 +140,7 @@ const migrations = [
m065,
m066,
m067,
m068,
];
export default migrations;

View File

@ -1,15 +1,25 @@
module.exports = {
collectCoverageFrom: ['<rootDir>/ui/**/*.js', '<rootDir>/shared/**/*.js'],
collectCoverageFrom: [
'<rootDir>/app/scripts/controllers/permissions/*.js',
'<rootDir>/shared/**/*.js',
'<rootDir>/ui/**/*.js',
],
coverageDirectory: './jest-coverage/main',
coveragePathIgnorePatterns: ['.stories.js', '.snap'],
coverageReporters: ['html', 'text-summary'],
coverageThreshold: {
global: {
'global': {
branches: 35,
functions: 37,
lines: 43,
statements: 43,
},
'./app/scripts/controllers/permissions/*.js': {
branches: 100,
functions: 100,
lines: 100,
statements: 100,
},
},
// TODO: enable resetMocks
// resetMocks: true,
@ -19,9 +29,10 @@ module.exports = {
testMatch: [
'<rootDir>/ui/**/*.test.js',
'<rootDir>/shared/**/*.test.js',
'<rootDir>/app/scripts/lib/**/*.test.js',
'<rootDir>/app/scripts/migrations/*.test.js',
'<rootDir>/app/scripts/platforms/*.test.js',
'<rootDir>/app/scripts/lib/**/*.test.js',
'<rootDir>/app/scripts/controllers/permissions/*.test.js',
],
testTimeout: 2500,
transform: {

View File

@ -548,7 +548,6 @@
"@metamask/contract-metadata": true,
"abort-controller": true,
"async-mutex": true,
"await-semaphore": true,
"buffer": true,
"eth-ens-namehash": true,
"eth-json-rpc-infura": true,
@ -558,11 +557,9 @@
"eth-query": true,
"eth-rpc-errors": true,
"eth-sig-util": true,
"ethereumjs-tx": true,
"ethereumjs-util": true,
"ethereumjs-wallet": true,
"ethers": true,
"ethjs-query": true,
"ethjs-unit": true,
"ethjs-util": true,
"events": true,
@ -642,6 +639,16 @@
"gl-vec3": true
}
},
"@metamask/object-multiplex": {
"globals": {
"console.warn": true
},
"packages": {
"end-of-stream": true,
"once": true,
"readable-stream": true
}
},
"@metamask/obs-store": {
"globals": {
"localStorage": true
@ -652,6 +659,18 @@
"through2": true
}
},
"@metamask/post-message-stream": {
"globals": {
"addEventListener": true,
"location.origin": true,
"onmessage": "write",
"postMessage": true,
"removeEventListener": true
},
"packages": {
"readable-stream": true
}
},
"@metamask/safe-event-emitter": {
"globals": {
"setTimeout": true
@ -672,6 +691,34 @@
"sha.js": true
}
},
"@metamask/snap-controllers": {
"globals": {
"URL": true,
"Worker": true,
"clearTimeout": true,
"console.error": true,
"console.log": true,
"console.warn": true,
"fetch": true,
"setTimeout": true
},
"packages": {
"@metamask/controllers": true,
"@metamask/object-multiplex": true,
"@metamask/obs-store": true,
"@metamask/post-message-stream": true,
"@metamask/safe-event-emitter": true,
"@metamask/snap-workers": true,
"deep-freeze-strict": true,
"eth-rpc-errors": true,
"fast-deep-equal": true,
"immer": true,
"json-rpc-engine": true,
"json-rpc-middleware-stream": true,
"nanoid": true,
"pump": true
}
},
"@popperjs/core": {
"globals": {
"Element": true,
@ -1769,13 +1816,6 @@
"ethereumjs-util": true
}
},
"ethereumjs-tx": {
"packages": {
"buffer": true,
"ethereum-common": true,
"ethereumjs-util": true
}
},
"ethereumjs-util": {
"packages": {
"assert": true,
@ -2677,7 +2717,11 @@
}
},
"json-rpc-middleware-stream": {
"globals": {
"setTimeout": true
},
"packages": {
"@metamask/safe-event-emitter": true,
"readable-stream": true
}
},
@ -4291,15 +4335,6 @@
"console": true
}
},
"rpc-cap": {
"packages": {
"@metamask/controllers": true,
"eth-rpc-errors": true,
"is-subset": true,
"json-rpc-engine": true,
"uuid": true
}
},
"safe-buffer": {
"packages": {
"buffer": true

View File

@ -548,7 +548,6 @@
"@metamask/contract-metadata": true,
"abort-controller": true,
"async-mutex": true,
"await-semaphore": true,
"buffer": true,
"eth-ens-namehash": true,
"eth-json-rpc-infura": true,
@ -558,11 +557,9 @@
"eth-query": true,
"eth-rpc-errors": true,
"eth-sig-util": true,
"ethereumjs-tx": true,
"ethereumjs-util": true,
"ethereumjs-wallet": true,
"ethers": true,
"ethjs-query": true,
"ethjs-unit": true,
"ethjs-util": true,
"events": true,
@ -642,6 +639,16 @@
"gl-vec3": true
}
},
"@metamask/object-multiplex": {
"globals": {
"console.warn": true
},
"packages": {
"end-of-stream": true,
"once": true,
"readable-stream": true
}
},
"@metamask/obs-store": {
"globals": {
"localStorage": true
@ -652,6 +659,18 @@
"through2": true
}
},
"@metamask/post-message-stream": {
"globals": {
"addEventListener": true,
"location.origin": true,
"onmessage": "write",
"postMessage": true,
"removeEventListener": true
},
"packages": {
"readable-stream": true
}
},
"@metamask/safe-event-emitter": {
"globals": {
"setTimeout": true
@ -672,6 +691,34 @@
"sha.js": true
}
},
"@metamask/snap-controllers": {
"globals": {
"URL": true,
"Worker": true,
"clearTimeout": true,
"console.error": true,
"console.log": true,
"console.warn": true,
"fetch": true,
"setTimeout": true
},
"packages": {
"@metamask/controllers": true,
"@metamask/object-multiplex": true,
"@metamask/obs-store": true,
"@metamask/post-message-stream": true,
"@metamask/safe-event-emitter": true,
"@metamask/snap-workers": true,
"deep-freeze-strict": true,
"eth-rpc-errors": true,
"fast-deep-equal": true,
"immer": true,
"json-rpc-engine": true,
"json-rpc-middleware-stream": true,
"nanoid": true,
"pump": true
}
},
"@popperjs/core": {
"globals": {
"Element": true,
@ -1769,13 +1816,6 @@
"ethereumjs-util": true
}
},
"ethereumjs-tx": {
"packages": {
"buffer": true,
"ethereum-common": true,
"ethereumjs-util": true
}
},
"ethereumjs-util": {
"packages": {
"assert": true,
@ -2677,7 +2717,11 @@
}
},
"json-rpc-middleware-stream": {
"globals": {
"setTimeout": true
},
"packages": {
"@metamask/safe-event-emitter": true,
"readable-stream": true
}
},
@ -4291,15 +4335,6 @@
"console": true
}
},
"rpc-cap": {
"packages": {
"@metamask/controllers": true,
"eth-rpc-errors": true,
"is-subset": true,
"json-rpc-engine": true,
"uuid": true
}
},
"safe-buffer": {
"packages": {
"buffer": true

View File

@ -548,7 +548,6 @@
"@metamask/contract-metadata": true,
"abort-controller": true,
"async-mutex": true,
"await-semaphore": true,
"buffer": true,
"eth-ens-namehash": true,
"eth-json-rpc-infura": true,
@ -558,11 +557,9 @@
"eth-query": true,
"eth-rpc-errors": true,
"eth-sig-util": true,
"ethereumjs-tx": true,
"ethereumjs-util": true,
"ethereumjs-wallet": true,
"ethers": true,
"ethjs-query": true,
"ethjs-unit": true,
"ethjs-util": true,
"events": true,
@ -642,6 +639,16 @@
"gl-vec3": true
}
},
"@metamask/object-multiplex": {
"globals": {
"console.warn": true
},
"packages": {
"end-of-stream": true,
"once": true,
"readable-stream": true
}
},
"@metamask/obs-store": {
"globals": {
"localStorage": true
@ -652,6 +659,18 @@
"through2": true
}
},
"@metamask/post-message-stream": {
"globals": {
"addEventListener": true,
"location.origin": true,
"onmessage": "write",
"postMessage": true,
"removeEventListener": true
},
"packages": {
"readable-stream": true
}
},
"@metamask/safe-event-emitter": {
"globals": {
"setTimeout": true
@ -672,6 +691,34 @@
"sha.js": true
}
},
"@metamask/snap-controllers": {
"globals": {
"URL": true,
"Worker": true,
"clearTimeout": true,
"console.error": true,
"console.log": true,
"console.warn": true,
"fetch": true,
"setTimeout": true
},
"packages": {
"@metamask/controllers": true,
"@metamask/object-multiplex": true,
"@metamask/obs-store": true,
"@metamask/post-message-stream": true,
"@metamask/safe-event-emitter": true,
"@metamask/snap-workers": true,
"deep-freeze-strict": true,
"eth-rpc-errors": true,
"fast-deep-equal": true,
"immer": true,
"json-rpc-engine": true,
"json-rpc-middleware-stream": true,
"nanoid": true,
"pump": true
}
},
"@popperjs/core": {
"globals": {
"Element": true,
@ -1769,13 +1816,6 @@
"ethereumjs-util": true
}
},
"ethereumjs-tx": {
"packages": {
"buffer": true,
"ethereum-common": true,
"ethereumjs-util": true
}
},
"ethereumjs-util": {
"packages": {
"assert": true,
@ -2677,7 +2717,11 @@
}
},
"json-rpc-middleware-stream": {
"globals": {
"setTimeout": true
},
"packages": {
"@metamask/safe-event-emitter": true,
"readable-stream": true
}
},
@ -4291,15 +4335,6 @@
"console": true
}
},
"rpc-cap": {
"packages": {
"@metamask/controllers": true,
"eth-rpc-errors": true,
"is-subset": true,
"json-rpc-engine": true,
"uuid": true
}
},
"safe-buffer": {
"packages": {
"buffer": true

View File

@ -1045,6 +1045,16 @@
"buffer-equal": true
}
},
"are-we-there-yet": {
"builtin": {
"events.EventEmitter": true,
"util.inherits": true
},
"packages": {
"delegates": true,
"readable-stream": true
}
},
"arr-diff": {
"packages": {
"arr-flatten": true,
@ -1453,6 +1463,7 @@
"anymatch": true,
"async-each": true,
"braces": true,
"fsevents": true,
"glob-parent": true,
"inherits": true,
"is-binary-path": true,
@ -1719,6 +1730,16 @@
"through2": true
}
},
"detect-libc": {
"builtin": {
"child_process.spawnSync": true,
"fs.readdirSync": true,
"os.platform": true
},
"globals": {
"process.env": true
}
},
"detective": {
"packages": {
"acorn-node": true,
@ -2409,6 +2430,45 @@
"process.version": true
}
},
"fsevents": {
"builtin": {
"events.EventEmitter": true,
"fs.stat": true,
"path.join": true,
"util.inherits": true
},
"globals": {
"__dirname": true,
"process.nextTick": true,
"process.platform": true,
"setImmediate": true
},
"native": true,
"packages": {
"node-pre-gyp": true
}
},
"gauge": {
"builtin": {
"util.format": true
},
"globals": {
"clearInterval": true,
"process": true,
"setImmediate": true,
"setInterval": true
},
"packages": {
"aproba": true,
"console-control-strings": true,
"has-unicode": true,
"object-assign": true,
"signal-exit": true,
"string-width": true,
"strip-ansi": true,
"wide-align": true
}
},
"get-assigned-identifiers": {
"builtin": {
"assert.equal": true
@ -2782,6 +2842,16 @@
"process.argv": true
}
},
"has-unicode": {
"builtin": {
"os.type": true
},
"globals": {
"process.env.LANG": true,
"process.env.LC_ALL": true,
"process.env.LC_CTYPE": true
}
},
"has-value": {
"packages": {
"get-value": true,
@ -2953,6 +3023,11 @@
"is-plain-object": true
}
},
"is-fullwidth-code-point": {
"packages": {
"number-is-nan": true
}
},
"is-glob": {
"packages": {
"is-extglob": true
@ -3478,6 +3553,56 @@
"setTimeout": true
}
},
"node-pre-gyp": {
"builtin": {
"events.EventEmitter": true,
"fs.existsSync": true,
"fs.readFileSync": true,
"fs.renameSync": true,
"path.dirname": true,
"path.existsSync": true,
"path.join": true,
"path.resolve": true,
"url.parse": true,
"url.resolve": true,
"util.inherits": true
},
"globals": {
"__dirname": true,
"console.log": true,
"process.arch": true,
"process.cwd": true,
"process.env": true,
"process.platform": true,
"process.version.substr": true,
"process.versions": true
},
"packages": {
"detect-libc": true,
"nopt": true,
"npmlog": true,
"rimraf": true,
"semver": true
}
},
"nopt": {
"builtin": {
"path": true,
"stream.Stream": true,
"url": true
},
"globals": {
"console": true,
"process.argv": true,
"process.env.DEBUG_NOPT": true,
"process.env.NOPT_DEBUG": true,
"process.platform": true
},
"packages": {
"abbrev": true,
"osenv": true
}
},
"normalize-package-data": {
"builtin": {
"url.parse": true,
@ -3505,6 +3630,22 @@
"once": true
}
},
"npmlog": {
"builtin": {
"events.EventEmitter": true,
"util": true
},
"globals": {
"process.nextTick": true,
"process.stderr": true
},
"packages": {
"are-we-there-yet": true,
"console-control-strings": true,
"gauge": true,
"set-blocking": true
}
},
"object-copy": {
"packages": {
"copy-descriptor": true,
@ -3586,6 +3727,54 @@
"readable-stream": true
}
},
"os-homedir": {
"builtin": {
"os.homedir": true
},
"globals": {
"process.env": true,
"process.getuid": true,
"process.platform": true
}
},
"os-tmpdir": {
"globals": {
"process.env.SystemRoot": true,
"process.env.TEMP": true,
"process.env.TMP": true,
"process.env.TMPDIR": true,
"process.env.windir": true,
"process.platform": true
}
},
"osenv": {
"builtin": {
"child_process.exec": true,
"path": true
},
"globals": {
"process.env.COMPUTERNAME": true,
"process.env.ComSpec": true,
"process.env.EDITOR": true,
"process.env.HOSTNAME": true,
"process.env.PATH": true,
"process.env.PROMPT": true,
"process.env.PS1": true,
"process.env.Path": true,
"process.env.SHELL": true,
"process.env.USER": true,
"process.env.USERDOMAIN": true,
"process.env.USERNAME": true,
"process.env.VISUAL": true,
"process.env.path": true,
"process.nextTick": true,
"process.platform": true
},
"packages": {
"os-homedir": true,
"os-tmpdir": true
}
},
"p-limit": {
"packages": {
"p-try": true
@ -4290,6 +4479,12 @@
"lru-cache": true
}
},
"set-blocking": {
"globals": {
"process.stderr": true,
"process.stdout": true
}
},
"set-value": {
"packages": {
"extend-shallow": true,
@ -4549,6 +4744,7 @@
},
"string-width": {
"packages": {
"code-point-at": true,
"emoji-regex": true,
"is-fullwidth-code-point": true,
"strip-ansi": true
@ -5201,6 +5397,11 @@
"isexe": true
}
},
"wide-align": {
"packages": {
"string-width": true
}
},
"write": {
"builtin": {
"fs.createWriteStream": true,

View File

@ -27,18 +27,14 @@
"test:unit": "./test/test-unit-combined.sh",
"test:unit:jest": "./test/test-unit-jest.sh",
"test:unit:global": "mocha test/unit-global/*.test.js",
"test:unit:mocha": "mocha --config '.mocharc.js' './app/**/*.test.js'",
"test:unit:lax": "mocha --config '.mocharc.lax.js' './app/**/*.test.js'",
"test:unit:strict": "mocha './app/scripts/controllers/permissions/*.test.js'",
"test:unit:mocha": "mocha './app/**/*.test.js'",
"test:e2e:chrome": "SELENIUM_BROWSER=chrome node test/e2e/run-all.js",
"test:e2e:chrome:metrics": "SELENIUM_BROWSER=chrome node test/e2e/run-e2e-test.js test/e2e/metrics.spec.js",
"test:e2e:firefox": "SELENIUM_BROWSER=firefox node test/e2e/run-all.js",
"test:e2e:firefox:metrics": "SELENIUM_BROWSER=firefox node test/e2e/run-e2e-test.js test/e2e/metrics.spec.js",
"test:e2e:single": "node test/e2e/run-e2e-test.js",
"test:coverage": "nyc --silent --check-coverage yarn test:unit:strict && nyc --silent --no-clean yarn test:unit:lax && nyc report --reporter=text --reporter=html",
"test:coverage:mocha": "nyc --reporter=text --reporter=html yarn test:unit:mocha",
"test:coverage:jest": "yarn test:unit:jest --coverage --maxWorkers=2",
"test:coverage:strict": "nyc --check-coverage yarn test:unit:strict",
"test:coverage:path": "nyc --check-coverage yarn test:unit:path",
"ganache:start": "./development/run-ganache.sh",
"sentry:publish": "node ./development/sentry-publish.js",
"lint:prettier": "prettier '**/*.json'",
@ -120,6 +116,7 @@
"@metamask/obs-store": "^5.0.0",
"@metamask/post-message-stream": "^4.0.0",
"@metamask/providers": "^8.1.1",
"@metamask/snap-controllers": "^0.4.0",
"@ngraveio/bc-ur": "^1.1.6",
"@popperjs/core": "^2.4.0",
"@reduxjs/toolkit": "^1.6.2",
@ -207,7 +204,6 @@
"redux": "^4.0.5",
"redux-thunk": "^2.3.0",
"reselect": "^3.0.1",
"rpc-cap": "^3.2.1",
"safe-event-emitter": "^1.0.1",
"ses": "^0.12.4",
"single-call-balance-checker-abi": "^1.0.0",

View File

@ -29,17 +29,20 @@ export const PLATFORM_FIREFOX = 'Firefox';
export const PLATFORM_OPERA = 'Opera';
export const MESSAGE_TYPE = {
ADD_ETHEREUM_CHAIN: 'wallet_addEthereumChain',
ETH_ACCOUNTS: 'eth_accounts',
ETH_DECRYPT: 'eth_decrypt',
ETH_GET_ENCRYPTION_PUBLIC_KEY: 'eth_getEncryptionPublicKey',
ETH_REQUEST_ACCOUNTS: 'eth_requestAccounts',
ETH_SIGN: 'eth_sign',
ETH_SIGN_TYPED_DATA: 'eth_signTypedData',
GET_PROVIDER_STATE: 'metamask_getProviderState',
LOG_WEB3_SHIM_USAGE: 'metamask_logWeb3ShimUsage',
PERSONAL_SIGN: 'personal_sign',
SEND_METADATA: 'metamask_sendDomainMetadata',
SWITCH_ETHEREUM_CHAIN: 'wallet_switchEthereumChain',
WATCH_ASSET: 'wallet_watchAsset',
WATCH_ASSET_LEGACY: 'metamask_watchAsset',
ADD_ETHEREUM_CHAIN: 'wallet_addEthereumChain',
SWITCH_ETHEREUM_CHAIN: 'wallet_switchEthereumChain',
};
export const POLLING_TOKEN_ENVIRONMENT_TYPES = {

View File

@ -1,4 +1,7 @@
export const CAVEAT_NAMES = {
exposedAccounts: 'exposedAccounts',
primaryAccountOnly: 'primaryAccountOnly',
};
export const CaveatTypes = Object.freeze({
restrictReturnedAccounts: 'restrictReturnedAccounts',
});
export const RestrictedMethods = Object.freeze({
eth_accounts: 'eth_accounts',
});

View File

@ -62,11 +62,11 @@ describe('Permissions', function () {
tag: 'h2',
});
await driver.waitForSelector({
css: '.connected-sites-list__domain-name',
css: '.connected-sites-list__subject-name',
text: '127.0.0.1:8080',
});
const domains = await driver.findClickableElements(
'.connected-sites-list__domain-name',
'.connected-sites-list__subject-name',
);
assert.equal(domains.length, 1);

View File

@ -28,7 +28,6 @@ describe('Personal sign', function () {
await driver.clickElement('#personalSign');
await driver.waitUntilXWindowHandles(3);
const windowHandles = await driver.getAllWindowHandles();
await driver.switchToWindowWithTitle(
'MetaMask Notification',

View File

@ -1,123 +0,0 @@
import { strict as assert } from 'assert';
import stringify from 'fast-safe-stringify';
import { noop } from '../mocks/permission-controller';
/**
* Grants the given permissions to the given origin, using the given permissions
* controller.
*
* Just a wrapper for an rpc-cap middleware function.
*
* @param {PermissionsController} permController - The permissions controller.
* @param {string} origin - The origin to grant permissions to.
* @param {Object} permissions - The permissions to grant.
*/
export function grantPermissions(permController, origin, permissions) {
permController.permissions.grantNewPermissions(origin, permissions, {}, noop);
}
/**
* Returns a wrapper for the given permissions controller's requestUserApproval
* function, so we don't have to worry about its internals.
*
* @param {PermissionsController} permController - The permissions controller.
* @return {Function} A convenient wrapper for the requestUserApproval function.
*/
export function getRequestUserApprovalHelper(permController) {
/**
* Returns a request object that can be passed to requestUserApproval.
*
* @param {string} id - The internal permissions request ID (not the RPC request ID).
* @param {string} [origin] - The origin of the request, if necessary.
* @returns {Object} The corresponding request object.
*/
return (id, origin = 'defaultOrigin') => {
return permController.permissions.requestUserApproval({
metadata: { id, origin, type: 'NO_TYPE' },
});
};
}
/**
* Returns a Promise that resolves once a pending user approval has been set.
* Calls the underlying requestUserApproval function as normal, and restores it
* once the Promise is resolved.
*
* This function must be called on the permissions controller for each request.
*
* @param {PermissionsController} permController - A permissions controller.
* @returns {Promise<void>} A Promise that resolves once a pending approval
* has been set.
*/
export function getUserApprovalPromise(permController) {
const originalFunction = permController.permissions.requestUserApproval;
return new Promise((resolveHelperPromise) => {
permController.permissions.requestUserApproval = (req) => {
const userApprovalPromise = originalFunction(req);
permController.permissions.requestUserApproval = originalFunction;
resolveHelperPromise();
return userApprovalPromise;
};
});
}
/**
* Validates an activity log entry with respect to a request, response, and
* relevant metadata.
*
* @param {Object} entry - The activity log entry to validate.
* @param {Object} req - The request that generated the entry.
* @param {Object} [res] - The response for the request, if any.
* @param {'restricted'|'internal'} methodType - The method log controller method type of the request.
* @param {boolean} success - Whether the request succeeded or not.
*/
export function validateActivityEntry(entry, req, res, methodType, success) {
assert.doesNotThrow(() => {
_validateActivityEntry(entry, req, res, methodType, success);
}, 'should have expected activity entry');
}
function _validateActivityEntry(entry, req, res, methodType, success) {
assert.ok(entry, 'entry should exist');
assert.equal(entry.id, req.id);
assert.equal(entry.method, req.method);
assert.equal(entry.origin, req.origin);
assert.equal(entry.methodType, methodType);
assert.equal(
entry.request,
stringify(req, null, 2),
'entry.request should equal the request',
);
if (res) {
assert.ok(
Number.isInteger(entry.requestTime) &&
Number.isInteger(entry.responseTime),
'request and response times should be numbers',
);
assert.ok(
entry.requestTime <= entry.responseTime,
'request time should be less than response time',
);
assert.equal(entry.success, success);
assert.deepEqual(
entry.response,
stringify(res, null, 2),
'entry.response should equal the response',
);
} else {
assert.ok(
Number.isInteger(entry.requestTime) && entry.requestTime > 0,
'entry should have non-zero request time',
);
assert.ok(
entry.success === null &&
entry.responseTime === null &&
entry.response === null,
'entry response values should be null',
);
}
}

View File

@ -1,736 +0,0 @@
import { ethErrors, errorCodes } from 'eth-rpc-errors';
import deepFreeze from 'deep-freeze-strict';
import { ApprovalController, ControllerMessenger } from '@metamask/controllers';
import _getRestrictedMethods from '../../app/scripts/controllers/permissions/restrictedMethods';
import { CAVEAT_NAMES } from '../../shared/constants/permissions';
import {
CAVEAT_TYPES,
NOTIFICATION_NAMES,
} from '../../app/scripts/controllers/permissions/enums';
/**
* README
* This file contains three primary kinds of mocks:
* - Mocks for initializing a permissions controller and getting a permissions
* middleware
* - Functions for getting various mock objects consumed or produced by
* permissions controller methods
* - Immutable mock values like Ethereum accounts and expected states
*/
export const noop = () => undefined;
/**
* Mock Permissions Controller and Middleware
*/
const keyringAccounts = deepFreeze([
'0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc',
'0xc42edfcc21ed14dda456aa0756c153f7985d8813',
'0x7ae1cdd37bcbdb0e1f491974da8022bfdbf9c2bf',
'0xcc74c7a59194e5d9268476955650d1e285be703c',
]);
const getIdentities = () => {
return keyringAccounts.reduce((identities, address, index) => {
identities[address] = { address, name: `Account ${index}` };
return identities;
}, {});
};
// perm controller initialization helper
const getRestrictedMethods = (permController) => {
return {
// the actual, production restricted methods
..._getRestrictedMethods(permController),
// our own dummy method for testing
test_method: {
description: `This method is only for testing.`,
method: (req, res, __, end) => {
if (req.params[0]) {
res.result = 1;
} else {
res.result = 0;
}
end();
},
},
};
};
/**
* Gets default mock constructor options for a permissions controller.
*
* @returns {Object} A PermissionsController constructor options object.
*/
export function getPermControllerOpts() {
return {
approvals: new ApprovalController({
messenger: new ControllerMessenger(),
showApprovalRequest: noop,
}),
getKeyringAccounts: async () => [...keyringAccounts],
getUnlockPromise: () => Promise.resolve(),
getRestrictedMethods,
isUnlocked: () => true,
notifyDomain: noop,
notifyAllDomains: noop,
preferences: {
getState: () => {
return {
identities: getIdentities(),
selectedAddress: keyringAccounts[0],
};
},
subscribe: noop,
},
showPermissionRequest: noop,
};
}
/**
* Gets a Promise-wrapped permissions controller middleware function.
*
* @param {PermissionsController} permController - The permissions controller to get a
* middleware for.
* @param {string} origin - The origin for the middleware.
* @param {string} extensionId - The extension id for the middleware.
* @returns {Function} A Promise-wrapped middleware function with convenient default args.
*/
export function getPermissionsMiddleware(permController, origin, extensionId) {
const middleware = permController.createMiddleware({ origin, extensionId });
return (req, res = {}, next = noop, end) => {
return new Promise((resolve, reject) => {
// eslint-disable-next-line no-param-reassign
end = end || _end;
middleware(req, res, next, end);
// emulates json-rpc-engine error handling
function _end(err) {
if (err || res.error) {
reject(err || res.error);
} else {
resolve(res);
}
}
});
};
}
/**
* @param {Object} notifications - An object that will store notifications produced
* by the permissions controller.
* @returns {Function} A function passed to the permissions controller at initialization,
* for recording notifications.
*/
export const getNotifyDomain = (notifications = {}) => (
origin,
notification,
) => {
notifications[origin].push(notification);
};
/**
* @param {Object} notifications - An object that will store notifications produced
* by the permissions controller.
* @returns {Function} A function passed to the permissions controller at initialization,
* for recording notifications.
*/
export const getNotifyAllDomains = (notifications = {}) => (notification) => {
Object.keys(notifications).forEach((origin) => {
notifications[origin].push(notification);
});
};
/**
* Constants and Mock Objects
* - e.g. permissions, caveats, and permission requests
*/
const DOMAINS = {
a: { origin: 'https://foo.xyz', host: 'foo.xyz' },
b: { origin: 'https://bar.abc', host: 'bar.abc' },
c: { origin: 'https://baz.def', host: 'baz.def' },
};
const PERM_NAMES = {
eth_accounts: 'eth_accounts',
test_method: 'test_method',
does_not_exist: 'does_not_exist',
};
const ACCOUNTS = {
a: {
permitted: keyringAccounts.slice(0, 3),
primary: keyringAccounts[0],
},
b: {
permitted: [keyringAccounts[0]],
primary: keyringAccounts[0],
},
c: {
permitted: [keyringAccounts[1]],
primary: keyringAccounts[1],
},
};
/**
* Helpers for getting mock caveats.
*/
const CAVEATS = {
/**
* Gets a correctly formatted eth_accounts exposedAccounts caveat.
*
* @param {Array<string>} accounts - The accounts for the caveat
* @returns {Object} An eth_accounts exposedAccounts caveats
*/
eth_accounts: (accounts) => {
return [
{
type: CAVEAT_TYPES.limitResponseLength,
value: 1,
name: CAVEAT_NAMES.primaryAccountOnly,
},
{
type: CAVEAT_TYPES.filterResponse,
value: accounts,
name: CAVEAT_NAMES.exposedAccounts,
},
];
},
};
/**
* Each function here corresponds to what would be a type or interface consumed
* by permissions controller functions if we used TypeScript.
*/
const PERMS = {
/**
* The argument to approvePermissionsRequest
* @param {string} id - The rpc-cap permissions request id.
* @param {Object} permissions - The approved permissions, request-formatted.
*/
approvedRequest: (id, permissions = {}) => {
return {
permissions: { ...permissions },
metadata: { id },
};
},
/**
* Requested permissions objects, as passed to wallet_requestPermissions.
*/
requests: {
/**
* @returns {Object} A permissions request object with eth_accounts
*/
eth_accounts: () => {
return { eth_accounts: {} };
},
/**
* @returns {Object} A permissions request object with test_method
*/
test_method: () => {
return { test_method: {} };
},
/**
* @returns {Object} A permissions request object with does_not_exist
*/
does_not_exist: () => {
return { does_not_exist: {} };
},
},
/**
* Finalized permission requests, as returned by finalizePermissionsRequest
*/
finalizedRequests: {
/**
* @param {Array<string>} accounts - The accounts for the eth_accounts permission caveat
* @returns {Object} A finalized permissions request object with eth_accounts and its caveat
*/
eth_accounts: (accounts) => {
return {
eth_accounts: {
caveats: CAVEATS.eth_accounts(accounts),
},
};
},
/**
* @returns {Object} A finalized permissions request object with test_method
*/
test_method: () => {
return {
test_method: {},
};
},
},
/**
* Partial members of res.result for successful:
* - wallet_requestPermissions
* - wallet_getPermissions
*/
granted: {
/**
* @param {Array<string>} accounts - The accounts for the eth_accounts permission caveat
* @returns {Object} A granted permissions object with eth_accounts and its caveat
*/
eth_accounts: (accounts) => {
return {
parentCapability: PERM_NAMES.eth_accounts,
caveats: CAVEATS.eth_accounts(accounts),
};
},
/**
* @returns {Object} A granted permissions object with test_method
*/
test_method: () => {
return {
parentCapability: PERM_NAMES.test_method,
};
},
},
};
/**
* Objects with function values for getting correctly formatted permissions,
* caveats, errors, permissions requests etc.
*/
export const getters = deepFreeze({
CAVEATS,
PERMS,
/**
* Getters for errors by the method or workflow that throws them.
*/
ERRORS: {
validatePermittedAccounts: {
invalidParam: () => {
return {
name: 'Error',
message: 'Must provide non-empty array of account(s).',
};
},
nonKeyringAccount: (account) => {
return {
name: 'Error',
message: `Unknown account: ${account}`,
};
},
},
finalizePermissionsRequest: {
grantEthAcountsFailure: (origin) => {
return {
// name: 'EthereumRpcError',
message: `Failed to add 'eth_accounts' to '${origin}'.`,
code: errorCodes.rpc.internal,
};
},
},
addPermittedAccount: {
alreadyPermitted: () => {
return {
message: 'Account is already permitted for origin',
};
},
invalidOrigin: () => {
return {
message: 'Unrecognized domain',
};
},
noEthAccountsPermission: () => {
return {
message: `Origin does not have 'eth_accounts' permission`,
};
},
},
removePermittedAccount: {
notPermitted: () => {
return {
message: 'Account is not permitted for origin',
};
},
invalidOrigin: () => {
return {
message: 'Unrecognized domain',
};
},
noEthAccountsPermission: () => {
return {
message: `Origin does not have 'eth_accounts' permission`,
};
},
},
_handleAccountSelected: {
invalidParams: () => {
return {
name: 'Error',
message: 'Selected account should be a non-empty string.',
};
},
},
approvePermissionsRequest: {
noPermsRequested: () => {
return {
message: 'Must request at least one permission.',
};
},
},
rejectPermissionsRequest: {
rejection: () => {
return {
message: ethErrors.provider.userRejectedRequest().message,
};
},
methodNotFound: (methodName) => {
return {
message: `The method '${methodName}' does not exist / is not available.`,
};
},
},
createMiddleware: {
badOrigin: () => {
return {
message: 'Must provide non-empty string origin.',
};
},
},
rpcCap: {
unauthorized: () => {
return {
code: 4100,
};
},
},
pendingApprovals: {
duplicateOriginOrId: (id, origin) => {
return {
message: `Pending approval with id '${id}' or origin '${origin}' already exists.`,
};
},
requestAlreadyPending: (origin) => {
return {
message: `Request of type 'wallet_requestPermissions' already pending for origin ${origin}. Please wait.`,
};
},
},
eth_requestAccounts: {
requestAlreadyPending: () => {
return {
message: 'Already processing eth_requestAccounts. Please wait.',
};
},
},
notifyAccountsChanged: {
invalidOrigin: (origin) => {
return {
message: `Invalid origin: '${origin}'`,
};
},
invalidAccounts: () => {
return {
message: 'Invalid accounts',
};
},
},
},
/**
* Getters for notifications produced by the permissions controller.
*/
NOTIFICATIONS: {
/**
* Gets a removed accounts notification.
*
* @returns {Object} An accountsChanged notification with an empty array as its result
*/
removedAccounts: () => {
return {
method: NOTIFICATION_NAMES.accountsChanged,
params: [],
};
},
/**
* Gets a new accounts notification.
*
* @param {Array<string>} accounts - The accounts added to the notification.
* @returns {Object} An accountsChanged notification with the given accounts as its result
*/
newAccounts: (accounts) => {
return {
method: NOTIFICATION_NAMES.accountsChanged,
params: accounts,
};
},
},
/**
* Getters for mock RPC request objects.
*/
RPC_REQUESTS: {
/**
* Gets an arbitrary RPC request object.
*
* @param {string} origin - The origin of the request
* @param {string} method - The request method
* @param {Array<any>} params - The request parameters
* @param {string} [id] - The request id
* @returns {Object} An RPC request object
*/
custom: (origin, method, params = [], id) => {
const req = {
origin,
method,
params,
};
if (id !== undefined) {
req.id = id;
}
return req;
},
/**
* Gets an eth_accounts RPC request object.
*
* @param {string} origin - The origin of the request
* @returns {Object} An RPC request object
*/
eth_accounts: (origin) => {
return {
origin,
method: 'eth_accounts',
params: [],
};
},
/**
* Gets a test_method RPC request object.
*
* @param {string} origin - The origin of the request
* @param {boolean} param - The request param
* @returns {Object} An RPC request object
*/
test_method: (origin, param = false) => {
return {
origin,
method: 'test_method',
params: [param],
};
},
/**
* Gets an eth_requestAccounts RPC request object.
*
* @param {string} origin - The origin of the request
* @returns {Object} An RPC request object
*/
eth_requestAccounts: (origin) => {
return {
origin,
method: 'eth_requestAccounts',
params: [],
};
},
/**
* Gets a wallet_requestPermissions RPC request object,
* for a single permission.
*
* @param {string} origin - The origin of the request
* @param {string} permissionName - The name of the permission to request
* @returns {Object} An RPC request object
*/
requestPermission: (origin, permissionName) => {
return {
origin,
method: 'wallet_requestPermissions',
params: [PERMS.requests[permissionName]()],
};
},
/**
* Gets a wallet_requestPermissions RPC request object,
* for multiple permissions.
*
* @param {string} origin - The origin of the request
* @param {Object} permissions - A permission request object
* @returns {Object} An RPC request object
*/
requestPermissions: (origin, permissions = {}) => {
return {
origin,
method: 'wallet_requestPermissions',
params: [permissions],
};
},
/**
* Gets a metamask_sendDomainMetadata RPC request object.
*
* @param {string} origin - The origin of the request
* @param {Object} name - The domainMetadata name
* @param {Array<any>} [args] - Any other data for the request's domainMetadata
* @returns {Object} An RPC request object
*/
metamask_sendDomainMetadata: (origin, name, ...args) => {
return {
origin,
method: 'metamask_sendDomainMetadata',
params: {
...args,
name,
},
};
},
},
});
/**
* Objects with immutable mock values.
*/
export const constants = deepFreeze({
ALL_ACCOUNTS: keyringAccounts,
DUMMY_ACCOUNT: '0xabc',
EXTRA_ACCOUNT: keyringAccounts[3],
REQUEST_IDS: {
a: '1',
b: '2',
c: '3',
},
DOMAINS: { ...DOMAINS },
ACCOUNTS: { ...ACCOUNTS },
PERM_NAMES: { ...PERM_NAMES },
RESTRICTED_METHODS: ['eth_accounts', 'test_method'],
/**
* Mock permissions history objects.
*/
EXPECTED_HISTORIES: {
case1: [
{
[DOMAINS.a.origin]: {
[PERM_NAMES.eth_accounts]: {
lastApproved: 1,
accounts: {
[ACCOUNTS.a.permitted[0]]: 1,
[ACCOUNTS.a.permitted[1]]: 1,
[ACCOUNTS.a.permitted[2]]: 1,
},
},
},
},
{
[DOMAINS.a.origin]: {
[PERM_NAMES.eth_accounts]: {
lastApproved: 2,
accounts: {
[ACCOUNTS.a.permitted[0]]: 2,
[ACCOUNTS.a.permitted[1]]: 1,
[ACCOUNTS.a.permitted[2]]: 1,
},
},
},
},
],
case2: [
{
[DOMAINS.a.origin]: {
[PERM_NAMES.eth_accounts]: {
lastApproved: 1,
accounts: {},
},
},
},
],
case3: [
{
[DOMAINS.a.origin]: {
[PERM_NAMES.test_method]: { lastApproved: 1 },
},
[DOMAINS.b.origin]: {
[PERM_NAMES.eth_accounts]: {
lastApproved: 1,
accounts: {
[ACCOUNTS.b.permitted[0]]: 1,
},
},
},
[DOMAINS.c.origin]: {
[PERM_NAMES.test_method]: { lastApproved: 1 },
[PERM_NAMES.eth_accounts]: {
lastApproved: 1,
accounts: {
[ACCOUNTS.c.permitted[0]]: 1,
},
},
},
},
{
[DOMAINS.a.origin]: {
[PERM_NAMES.test_method]: { lastApproved: 2 },
},
[DOMAINS.b.origin]: {
[PERM_NAMES.eth_accounts]: {
lastApproved: 1,
accounts: {
[ACCOUNTS.b.permitted[0]]: 1,
},
},
},
[DOMAINS.c.origin]: {
[PERM_NAMES.test_method]: { lastApproved: 1 },
[PERM_NAMES.eth_accounts]: {
lastApproved: 2,
accounts: {
[ACCOUNTS.c.permitted[0]]: 1,
[ACCOUNTS.b.permitted[0]]: 2,
},
},
},
},
],
case4: [
{
[DOMAINS.a.origin]: {
[PERM_NAMES.test_method]: {
lastApproved: 1,
},
},
},
],
},
});

369
test/mocks/permissions.js Normal file
View File

@ -0,0 +1,369 @@
import deepFreeze from 'deep-freeze-strict';
import { CaveatTypes } from '../../shared/constants/permissions';
/**
* This file contains mocks for the PermissionLogController tests.
*/
export const noop = () => undefined;
const keyringAccounts = deepFreeze([
'0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc',
'0xc42edfcc21ed14dda456aa0756c153f7985d8813',
'0x7ae1cdd37bcbdb0e1f491974da8022bfdbf9c2bf',
'0xcc74c7a59194e5d9268476955650d1e285be703c',
]);
const SUBJECTS = {
a: { origin: 'https://foo.xyz' },
b: { origin: 'https://bar.abc' },
c: { origin: 'https://baz.def' },
};
const PERM_NAMES = {
eth_accounts: 'eth_accounts',
test_method: 'test_method',
does_not_exist: 'does_not_exist',
};
const ACCOUNTS = {
a: {
permitted: keyringAccounts.slice(0, 3),
primary: keyringAccounts[0],
},
b: {
permitted: [keyringAccounts[0]],
primary: keyringAccounts[0],
},
c: {
permitted: [keyringAccounts[1]],
primary: keyringAccounts[1],
},
};
/**
* Helpers for getting mock caveats.
*/
const CAVEATS = {
/**
* Gets a correctly formatted eth_accounts restrictReturnedAccounts caveat.
*
* @param {Array<string>} accounts - The accounts for the caveat
* @returns {Object} An eth_accounts restrictReturnedAccounts caveats
*/
eth_accounts: (accounts) => {
return [
{
type: CaveatTypes.restrictReturnedAccounts,
value: accounts,
},
];
},
};
/**
* Each function here corresponds to what would be a type or interface consumed
* by permissions controller functions if we used TypeScript.
*/
const PERMS = {
/**
* Requested permissions objects, as passed to wallet_requestPermissions.
*/
requests: {
/**
* @returns {Object} A permissions request object with eth_accounts
*/
eth_accounts: () => {
return { eth_accounts: {} };
},
/**
* @returns {Object} A permissions request object with test_method
*/
test_method: () => {
return { test_method: {} };
},
/**
* @returns {Object} A permissions request object with does_not_exist
*/
does_not_exist: () => {
return { does_not_exist: {} };
},
},
/**
* Partial members of res.result for successful:
* - wallet_requestPermissions
* - wallet_getPermissions
*/
granted: {
/**
* @param {Array<string>} accounts - The accounts for the eth_accounts permission caveat
* @returns {Object} A granted permissions object with eth_accounts and its caveat
*/
eth_accounts: (accounts) => {
return {
parentCapability: PERM_NAMES.eth_accounts,
caveats: CAVEATS.eth_accounts(accounts),
};
},
/**
* @returns {Object} A granted permissions object with test_method
*/
test_method: () => {
return {
parentCapability: PERM_NAMES.test_method,
};
},
},
};
/**
* Objects with function values for getting correctly formatted permissions,
* caveats, errors, permissions requests etc.
*/
export const getters = deepFreeze({
PERMS,
/**
* Getters for mock RPC request objects.
*/
RPC_REQUESTS: {
/**
* Gets an arbitrary RPC request object.
*
* @param {string} origin - The origin of the request
* @param {string} method - The request method
* @param {Array<any>} params - The request parameters
* @param {string} [id] - The request id
* @returns {Object} An RPC request object
*/
custom: (origin, method, params = [], id) => {
const req = {
origin,
method,
params,
};
if (id !== undefined) {
req.id = id;
}
return req;
},
/**
* Gets an eth_accounts RPC request object.
*
* @param {string} origin - The origin of the request
* @returns {Object} An RPC request object
*/
eth_accounts: (origin) => {
return {
origin,
method: 'eth_accounts',
params: [],
};
},
/**
* Gets a test_method RPC request object.
*
* @param {string} origin - The origin of the request
* @param {boolean} param - The request param
* @returns {Object} An RPC request object
*/
test_method: (origin, param = false) => {
return {
origin,
method: 'test_method',
params: [param],
};
},
/**
* Gets an eth_requestAccounts RPC request object.
*
* @param {string} origin - The origin of the request
* @returns {Object} An RPC request object
*/
eth_requestAccounts: (origin) => {
return {
origin,
method: 'eth_requestAccounts',
params: [],
};
},
/**
* Gets a wallet_requestPermissions RPC request object,
* for a single permission.
*
* @param {string} origin - The origin of the request
* @param {string} permissionName - The name of the permission to request
* @returns {Object} An RPC request object
*/
requestPermission: (origin, permissionName) => {
return {
origin,
method: 'wallet_requestPermissions',
params: [PERMS.requests[permissionName]()],
};
},
/**
* Gets a wallet_requestPermissions RPC request object,
* for multiple permissions.
*
* @param {string} origin - The origin of the request
* @param {Object} permissions - A permission request object
* @returns {Object} An RPC request object
*/
requestPermissions: (origin, permissions = {}) => {
return {
origin,
method: 'wallet_requestPermissions',
params: [permissions],
};
},
/**
* Gets a metamask_sendDomainMetadata RPC request object.
*
* @param {string} origin - The origin of the request
* @param {Object} name - The subjectMetadata name
* @param {Array<any>} [args] - Any other data for the request's subjectMetadata
* @returns {Object} An RPC request object
*/
metamask_sendDomainMetadata: (origin, name, ...args) => {
return {
origin,
method: 'metamask_sendDomainMetadata',
params: {
...args,
name,
},
};
},
},
});
/**
* Objects with immutable mock values.
*/
export const constants = deepFreeze({
REQUEST_IDS: {
a: '1',
b: '2',
c: '3',
},
SUBJECTS: { ...SUBJECTS },
ACCOUNTS: { ...ACCOUNTS },
PERM_NAMES: { ...PERM_NAMES },
RESTRICTED_METHODS: new Set(['eth_accounts', 'test_method']),
/**
* Mock permissions history objects.
*/
EXPECTED_HISTORIES: {
case1: [
{
[SUBJECTS.a.origin]: {
[PERM_NAMES.eth_accounts]: {
lastApproved: 1,
accounts: {
[ACCOUNTS.a.permitted[0]]: 1,
[ACCOUNTS.a.permitted[1]]: 1,
[ACCOUNTS.a.permitted[2]]: 1,
},
},
},
},
{
[SUBJECTS.a.origin]: {
[PERM_NAMES.eth_accounts]: {
lastApproved: 2,
accounts: {
[ACCOUNTS.a.permitted[0]]: 2,
[ACCOUNTS.a.permitted[1]]: 1,
[ACCOUNTS.a.permitted[2]]: 1,
},
},
},
},
],
case2: [
{
[SUBJECTS.a.origin]: {
[PERM_NAMES.eth_accounts]: {
lastApproved: 1,
accounts: {},
},
},
},
],
case3: [
{
[SUBJECTS.a.origin]: {
[PERM_NAMES.test_method]: { lastApproved: 1 },
},
[SUBJECTS.b.origin]: {
[PERM_NAMES.eth_accounts]: {
lastApproved: 1,
accounts: {
[ACCOUNTS.b.permitted[0]]: 1,
},
},
},
[SUBJECTS.c.origin]: {
[PERM_NAMES.test_method]: { lastApproved: 1 },
[PERM_NAMES.eth_accounts]: {
lastApproved: 1,
accounts: {
[ACCOUNTS.c.permitted[0]]: 1,
},
},
},
},
{
[SUBJECTS.a.origin]: {
[PERM_NAMES.test_method]: { lastApproved: 2 },
},
[SUBJECTS.b.origin]: {
[PERM_NAMES.eth_accounts]: {
lastApproved: 1,
accounts: {
[ACCOUNTS.b.permitted[0]]: 1,
},
},
},
[SUBJECTS.c.origin]: {
[PERM_NAMES.test_method]: { lastApproved: 1 },
[PERM_NAMES.eth_accounts]: {
lastApproved: 2,
accounts: {
[ACCOUNTS.c.permitted[0]]: 1,
[ACCOUNTS.b.permitted[0]]: 2,
},
},
},
},
],
case4: [
{
[SUBJECTS.a.origin]: {
[PERM_NAMES.test_method]: {
lastApproved: 1,
},
},
},
],
},
});

View File

@ -70,7 +70,7 @@ export default class AccountMenu extends Component {
selectedAddress: PropTypes.string,
showAccountDetail: PropTypes.func,
toggleAccountMenu: PropTypes.func,
addressConnectedDomainMap: PropTypes.object,
addressConnectedSubjectMap: PropTypes.object,
originOfCurrentTab: PropTypes.string,
};
@ -147,7 +147,7 @@ export default class AccountMenu extends Component {
selectedAddress,
keyrings,
showAccountDetail,
addressConnectedDomainMap,
addressConnectedSubjectMap,
originOfCurrentTab,
} = this.props;
const { searchQuery } = this.state;
@ -177,8 +177,9 @@ export default class AccountMenu extends Component {
kr.accounts.includes(identity.address)
);
});
const addressDomains = addressConnectedDomainMap[identity.address] || {};
const iconAndNameForOpenDomain = addressDomains[originOfCurrentTab];
const addressSubjects =
addressConnectedSubjectMap[identity.address] || {};
const iconAndNameForOpenSubject = addressSubjects[originOfCurrentTab];
return (
<div
@ -210,11 +211,11 @@ export default class AccountMenu extends Component {
/>
</div>
{this.renderKeyringType(keyring)}
{iconAndNameForOpenDomain ? (
{iconAndNameForOpenSubject ? (
<div className="account-menu__icon-list">
<SiteIcon
icon={iconAndNameForOpenDomain.icon}
name={iconAndNameForOpenDomain.name}
icon={iconAndNameForOpenSubject.icon}
name={iconAndNameForOpenSubject.name}
size={32}
/>
</div>

View File

@ -8,7 +8,7 @@ import {
hideWarning,
} from '../../../store/actions';
import {
getAddressConnectedDomainMap,
getAddressConnectedSubjectMap,
getMetaMaskAccountsOrdered,
getMetaMaskKeyrings,
getOriginOfCurrentTab,
@ -31,7 +31,7 @@ function mapStateToProps(state) {
return {
isAccountMenuOpen,
addressConnectedDomainMap: getAddressConnectedDomainMap(state),
addressConnectedSubjectMap: getAddressConnectedSubjectMap(state),
originOfCurrentTab: origin,
selectedAddress,
keyrings: getMetaMaskKeyrings(state),

View File

@ -24,7 +24,7 @@ describe('Account Menu', () => {
const props = {
isAccountMenuOpen: true,
addressConnectedDomainMap: {},
addressConnectedSubjectMap: {},
accounts: [
{
address: '0x00',

View File

@ -65,7 +65,7 @@ describe('Unconnected Account Alert', () => {
provider: {
chainId: KOVAN_CHAIN_ID,
},
permissionsHistory: {
permissionHistory: {
'https://test.dapp': {
eth_accounts: {
accounts: {
@ -74,26 +74,20 @@ describe('Unconnected Account Alert', () => {
},
},
},
domains: {
subjects: {
'https://test.dapp': {
permissions: [
{
permissions: {
eth_accounts: {
caveats: [
{
name: 'primaryAccountOnly',
type: 'limitResponseLength',
value: 1,
},
{
name: 'exposedAccounts',
type: 'filterResponse',
type: 'restrictReturnedAccounts',
value: ['0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'],
},
],
invoker: 'https://test.dapp',
parentCapability: 'eth_accounts',
},
],
},
},
},
},

View File

@ -1,7 +1,7 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import SiteIcon from '../../ui/site-icon';
import { stripHttpSchemes } from '../../../helpers/utils/util';
import { stripHttpsSchemeWithoutPort } from '../../../helpers/utils/util';
export default class ConnectedSitesList extends Component {
static contextTypes = {
@ -9,42 +9,40 @@ export default class ConnectedSitesList extends Component {
};
static propTypes = {
connectedDomains: PropTypes.arrayOf(
connectedSubjects: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string,
icon: PropTypes.string,
iconUrl: PropTypes.string,
origin: PropTypes.string,
host: PropTypes.string,
}),
).isRequired,
onDisconnect: PropTypes.func.isRequired,
domainHostCount: PropTypes.objectOf(PropTypes.number).isRequired,
};
render() {
const { connectedDomains, onDisconnect } = this.props;
const { connectedSubjects, onDisconnect } = this.props;
const { t } = this.context;
return (
<main className="connected-sites-list__content-rows">
{connectedDomains.map((domain) => (
{connectedSubjects.map((subject) => (
<div
key={domain.origin}
key={subject.origin}
className="connected-sites-list__content-row"
>
<div className="connected-sites-list__domain-info">
<SiteIcon icon={domain.icon} name={domain.name} size={32} />
<div className="connected-sites-list__subject-info">
<SiteIcon icon={subject.iconUrl} name={subject.name} size={32} />
<span
className="connected-sites-list__domain-name"
title={domain.extensionId || domain.origin}
className="connected-sites-list__subject-name"
title={subject.extensionId || subject.origin}
>
{this.getDomainDisplayName(domain)}
{this.getSubjectDisplayName(subject)}
</span>
</div>
<i
className="fas fa-trash-alt connected-sites-list__trash"
title={t('disconnect')}
onClick={() => onDisconnect(domain.origin)}
onClick={() => onDisconnect(subject.origin)}
/>
</div>
))}
@ -52,13 +50,12 @@ export default class ConnectedSitesList extends Component {
);
}
getDomainDisplayName(domain) {
if (domain.extensionId) {
getSubjectDisplayName(subject) {
if (subject.extensionId) {
return this.context.t('externalExtension');
}
return this.props.domainHostCount[domain.host] > 1
? domain.origin
: stripHttpSchemes(domain.origin);
// We strip https schemes only, and only if the URL has no port.
return stripHttpsSchemeWithoutPort(subject.origin);
}
}

View File

@ -15,7 +15,7 @@
padding: 16px 24px;
}
&__domain-info {
&__subject-info {
@include H7;
display: flex;
@ -24,7 +24,7 @@
min-width: 0;
}
&__domain-name {
&__subject-name {
max-width: 215px;
white-space: nowrap;
overflow: hidden;

View File

@ -11,7 +11,7 @@ import ColorIndicator from '../../ui/color-indicator';
import { COLORS } from '../../../helpers/constants/design-system';
import { useI18nContext } from '../../../hooks/useI18nContext';
import {
getAddressConnectedDomainMap,
getAddressConnectedSubjectMap,
getOriginOfCurrentTab,
getSelectedAddress,
} from '../../../selectors';
@ -20,17 +20,17 @@ export default function ConnectedStatusIndicator({ onClick }) {
const t = useI18nContext();
const selectedAddress = useSelector(getSelectedAddress);
const addressConnectedDomainMap = useSelector(getAddressConnectedDomainMap);
const addressConnectedSubjectMap = useSelector(getAddressConnectedSubjectMap);
const originOfCurrentTab = useSelector(getOriginOfCurrentTab);
const selectedAddressDomainMap = addressConnectedDomainMap[selectedAddress];
const selectedAddressSubjectMap = addressConnectedSubjectMap[selectedAddress];
const currentTabIsConnectedToSelectedAddress = Boolean(
selectedAddressDomainMap && selectedAddressDomainMap[originOfCurrentTab],
selectedAddressSubjectMap && selectedAddressSubjectMap[originOfCurrentTab],
);
let status;
if (currentTabIsConnectedToSelectedAddress) {
status = STATUS_CONNECTED;
} else if (findKey(addressConnectedDomainMap, originOfCurrentTab)) {
} else if (findKey(addressConnectedSubjectMap, originOfCurrentTab)) {
status = STATUS_CONNECTED_TO_ANOTHER_ACCOUNT;
} else {
status = STATUS_NOT_CONNECTED;

View File

@ -6,10 +6,9 @@ import PermissionsConnectPermissionList from '../../permissions-connect-permissi
export default class PermissionPageContainerContent extends PureComponent {
static propTypes = {
domainMetadata: PropTypes.shape({
subjectMetadata: PropTypes.shape({
extensionId: PropTypes.string,
icon: PropTypes.string,
host: PropTypes.string.isRequired,
iconUrl: PropTypes.string,
name: PropTypes.string.isRequired,
origin: PropTypes.string.isRequired,
}),
@ -68,14 +67,14 @@ export default class PermissionPageContainerContent extends PureComponent {
getTitle() {
const {
domainMetadata,
subjectMetadata,
selectedIdentities,
allIdentitiesSelected,
} = this.props;
const { t } = this.context;
if (domainMetadata.extensionId) {
return t('externalExtension', [domainMetadata.extensionId]);
if (subjectMetadata.extensionId) {
return t('externalExtension', [subjectMetadata.extensionId]);
} else if (allIdentitiesSelected) {
return t('connectToAll', [
this.renderAccountTooltip(t('connectToAllAccounts')),
@ -91,7 +90,7 @@ export default class PermissionPageContainerContent extends PureComponent {
}
render() {
const { domainMetadata } = this.props;
const { subjectMetadata } = this.props;
const { t } = this.context;
const title = this.getTitle();
@ -100,15 +99,15 @@ export default class PermissionPageContainerContent extends PureComponent {
<div className="permission-approval-container__content">
<div className="permission-approval-container__content-container">
<PermissionsConnectHeader
icon={domainMetadata.icon}
iconName={domainMetadata.name}
icon={subjectMetadata.iconUrl}
iconName={subjectMetadata.name}
headerTitle={title}
headerText={
domainMetadata.extensionId
? t('allowExternalExtensionTo', [domainMetadata.extensionId])
subjectMetadata.extensionId
? t('allowExternalExtensionTo', [subjectMetadata.extensionId])
: t('allowThisSiteTo')
}
siteOrigin={domainMetadata.origin}
siteOrigin={subjectMetadata.origin}
/>
<section className="permission-approval-container__permissions-container">
{this.renderRequestedPermissions()}

View File

@ -13,10 +13,9 @@ export default class PermissionPageContainer extends Component {
allIdentitiesSelected: PropTypes.bool,
request: PropTypes.object,
requestMetadata: PropTypes.object,
targetDomainMetadata: PropTypes.shape({
targetSubjectMetadata: PropTypes.shape({
extensionId: PropTypes.string,
icon: PropTypes.string,
host: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
origin: PropTypes.string.isRequired,
}),
@ -88,6 +87,9 @@ export default class PermissionPageContainer extends Component {
const request = {
..._request,
permissions: { ..._request.permissions },
approvedAccounts: selectedIdentities.map(
(selectedIdentity) => selectedIdentity.address,
),
};
Object.keys(this.state.selectedPermissions).forEach((key) => {
@ -97,10 +99,7 @@ export default class PermissionPageContainer extends Component {
});
if (Object.keys(request.permissions).length > 0) {
approvePermissionsRequest(
request,
selectedIdentities.map((selectedIdentity) => selectedIdentity.address),
);
approvePermissionsRequest(request);
} else {
rejectPermissionsRequest(request.metadata.id);
}
@ -109,7 +108,7 @@ export default class PermissionPageContainer extends Component {
render() {
const {
requestMetadata,
targetDomainMetadata,
targetSubjectMetadata,
selectedIdentities,
allIdentitiesSelected,
} = this.props;
@ -118,7 +117,7 @@ export default class PermissionPageContainer extends Component {
<div className="page-container permission-approval-container">
<PermissionPageContainerContent
requestMetadata={requestMetadata}
domainMetadata={targetDomainMetadata}
subjectMetadata={targetSubjectMetadata}
selectedPermissions={this.state.selectedPermissions}
selectedIdentities={selectedIdentities}
allIdentitiesSelected={allIdentitiesSelected}

View File

@ -6,6 +6,7 @@ import { ObjectInspector } from 'react-inspector';
import LedgerInstructionField from '../ledger-instruction-field';
import { MESSAGE_TYPE } from '../../../../shared/constants/app';
import { getURLHostName } from '../../../helpers/utils/util';
import Identicon from '../../ui/identicon';
import AccountListItem from '../account-list-item';
import { conversionUtil } from '../../../../shared/modules/conversion.utils';
@ -32,7 +33,7 @@ export default class SignatureRequestOriginal extends Component {
requesterAddress: PropTypes.string,
sign: PropTypes.func.isRequired,
txData: PropTypes.object.isRequired,
domainMetadata: PropTypes.object,
subjectMetadata: PropTypes.object,
hardwareWalletRequiresConnection: PropTypes.bool,
isLedgerWallet: PropTypes.bool,
nativeCurrency: PropTypes.string.isRequired,
@ -121,11 +122,11 @@ export default class SignatureRequestOriginal extends Component {
};
renderOriginInfo = () => {
const { txData, domainMetadata } = this.props;
const { txData, subjectMetadata } = this.props;
const { t } = this.context;
const originMetadata = txData.msgParams.origin
? domainMetadata?.[txData.msgParams.origin]
const targetSubjectMetadata = txData.msgParams.origin
? subjectMetadata?.[txData.msgParams.origin]
: null;
return (
@ -133,10 +134,13 @@ export default class SignatureRequestOriginal extends Component {
<div className="request-signature__origin-label">
{`${t('origin')}:`}
</div>
{originMetadata?.icon ? (
{targetSubjectMetadata?.iconUrl ? (
<SiteIcon
icon={originMetadata.icon}
name={originMetadata.hostname}
icon={targetSubjectMetadata.iconUrl}
name={
getURLHostName(targetSubjectMetadata.origin) ||
targetSubjectMetadata.origin
}
size={24}
/>
) : null}

View File

@ -7,7 +7,7 @@ import { goHome } from '../../../store/actions';
import {
accountsWithSendEtherInfoSelector,
conversionRateSelector,
getDomainMetadata,
getSubjectMetadata,
doesAddressRequireLedgerHidConnection,
} from '../../../selectors';
import { getAccountByAddress } from '../../../helpers/utils/util';
@ -40,7 +40,7 @@ function mapStateToProps(state, ownProps) {
nativeCurrency: getNativeCurrency(state),
// not passed to component
allAccounts: accountsWithSendEtherInfoSelector(state),
domainMetadata: getDomainMetadata(state),
subjectMetadata: getSubjectMetadata(state),
};
}

View File

@ -261,6 +261,21 @@ export function stripHttpsScheme(urlString) {
return urlString.replace(/^https:\/\//u, '');
}
/**
* Strips `https` schemes from URL strings, if the URL does not have a port.
* This is useful
*
* @param {string} urlString - The URL string to strip the scheme from.
* @returns {string} The URL string, without the scheme, if it was stripped.
*/
export function stripHttpsSchemeWithoutPort(urlString) {
if (getURL(urlString).port) {
return urlString;
}
return stripHttpsScheme(urlString);
}
/**
* Checks whether a URL-like value (object or string) is an extension URL.
*

View File

@ -1,14 +1,11 @@
import { useSelector } from 'react-redux';
import { getDomainMetadata } from '../selectors';
import { getSubjectMetadata } from '../selectors';
/**
* @typedef {Object} OriginMetadata
* @property {string} host - The host of the origin
* @property {string} hostname - The hostname of the origin (host + port)
* @property {string} origin - The original origin string itself
* @property {string} [icon] - The origin's site icon if available
* @property {number} [lastUpdated] - Timestamp of the last update to the
* origin's metadata
* @property {string} [iconUrl] - The origin's site icon URL, if available
* @property {string} [name] - The registered name of the origin if available
*/
@ -20,7 +17,7 @@ import { getDomainMetadata } from '../selectors';
* current origin
*/
export function useOriginMetadata(origin) {
const domainMetaData = useSelector(getDomainMetadata);
const subjectMetadata = useSelector(getSubjectMetadata);
if (!origin) {
return null;
}
@ -32,10 +29,10 @@ export function useOriginMetadata(origin) {
origin,
};
if (domainMetaData?.[origin]) {
if (subjectMetadata?.[origin]) {
return {
...minimumOriginMetadata,
...domainMetaData[origin],
...subjectMetadata[origin],
};
}
return minimumOriginMetadata;

View File

@ -27,7 +27,7 @@ import {
transactionFeeSelector,
txDataSelector,
getCurrentCurrency,
getDomainMetadata,
getSubjectMetadata,
getUseNonceField,
getCustomNonceValue,
getNextSuggestedNonce,
@ -65,7 +65,7 @@ export default function ConfirmApprove() {
const currentCurrency = useSelector(getCurrentCurrency);
const nativeCurrency = useSelector(getNativeCurrency);
const currentNetworkTxList = useSelector(currentNetworkTxListSelector);
const domainMetadata = useSelector(getDomainMetadata);
const subjectMetadata = useSelector(getSubjectMetadata);
const tokens = useSelector(getTokens);
const useNonceField = useSelector(getUseNonceField);
const nextNonce = useSelector(getNextSuggestedNonce);
@ -163,7 +163,7 @@ export default function ConfirmApprove() {
const { origin } = transaction;
const formattedOrigin = origin || '';
const { icon: siteImage = '' } = domainMetadata[origin] || {};
const { iconUrl: siteImage = '' } = subjectMetadata[origin] || {};
const tokensText = `${Number(tokenAmount)} ${tokenSymbol}`;
const tokenBalance = tokenTrackerBalance

View File

@ -7,7 +7,7 @@ import { updateMetamaskState } from '../../store/actions';
import { currentNetworkTxListSelector } from '../../selectors/transactions';
import { store, getNewState } from '../../../.storybook/preview';
import { domainMetadata } from '../../../.storybook/initial-states/approval-screens/token-approval';
import { subjectMetadata } from '../../../.storybook/initial-states/approval-screens/token-approval';
import ConfirmApprove from '.';
export default {
@ -20,7 +20,7 @@ const txId = 7900715443136469;
const PageSet = ({ children }) => {
const origin = text('Origin', 'https://metamask.github.io');
const domainIconUrl = text(
const subjectIconUrl = text(
'Icon URL',
'https://metamask.github.io/test-dapp/metamask-fox.svg',
);
@ -43,15 +43,15 @@ const PageSet = ({ children }) => {
store.dispatch(
updateMetamaskState(
getNewState(state.metamask, {
domainMetadata: {
subjectMetadata: {
[origin]: {
icon: domainIconUrl,
iconUrl: subjectIconUrl,
},
},
}),
),
);
}, [domainIconUrl, origin, state.metamask]);
}, [subjectIconUrl, origin, state.metamask]);
const params = useParams();
params.id = txId;
@ -63,7 +63,7 @@ export const DefaultStory = () => {
store.dispatch(
updateMetamaskState(
getNewState(state.metamask, {
domainMetadata,
subjectMetadata,
}),
),
);

View File

@ -33,7 +33,7 @@ export default class ConfirmDecryptMessage extends Component {
mostRecentOverviewPage: PropTypes.string.isRequired,
requesterAddress: PropTypes.string,
txData: PropTypes.object,
domainMetadata: PropTypes.object,
subjectMetadata: PropTypes.object,
};
state = {
@ -136,11 +136,11 @@ export default class ConfirmDecryptMessage extends Component {
};
renderBody = () => {
const { decryptMessageInline, domainMetadata, txData } = this.props;
const { decryptMessageInline, subjectMetadata, txData } = this.props;
const { t } = this.context;
const originMetadata = domainMetadata[txData.msgParams.origin];
const name = originMetadata?.hostname || txData.msgParams.origin;
const targetSubjectMetadata = subjectMetadata[txData.msgParams.origin];
const name = targetSubjectMetadata?.name || txData.msgParams.origin;
const notice = t('decryptMessageNotice', [txData.msgParams.origin]);
const {
@ -157,10 +157,10 @@ export default class ConfirmDecryptMessage extends Component {
{this.renderAccountInfo()}
<div className="request-decrypt-message__visual">
<section>
{originMetadata?.icon ? (
{targetSubjectMetadata?.iconUrl ? (
<img
className="request-decrypt-message__visual-identicon"
src={originMetadata.icon}
src={targetSubjectMetadata.iconUrl}
alt=""
/>
) : (

View File

@ -19,7 +19,7 @@ import ConfirmDecryptMessage from './confirm-decrypt-message.component';
function mapStateToProps(state) {
const {
metamask: { domainMetadata = {} },
metamask: { subjectMetadata = {} },
} = state;
const unconfirmedTransactions = unconfirmedTransactionsListSelector(state);
@ -34,7 +34,7 @@ function mapStateToProps(state) {
return {
txData,
domainMetadata,
subjectMetadata,
fromAccount,
requester: null,
requesterAddress: null,

View File

@ -26,7 +26,7 @@ export default class ConfirmEncryptionPublicKey extends Component {
history: PropTypes.object.isRequired,
requesterAddress: PropTypes.string,
txData: PropTypes.object,
domainMetadata: PropTypes.object,
subjectMetadata: PropTypes.object,
mostRecentOverviewPage: PropTypes.string.isRequired,
nativeCurrency: PropTypes.string.isRequired,
};
@ -113,22 +113,22 @@ export default class ConfirmEncryptionPublicKey extends Component {
};
renderBody = () => {
const { domainMetadata, txData } = this.props;
const { subjectMetadata, txData } = this.props;
const { t } = this.context;
const originMetadata = domainMetadata[txData.origin];
const targetSubjectMetadata = subjectMetadata[txData.origin];
const notice = t('encryptionPublicKeyNotice', [txData.origin]);
const name = originMetadata?.hostname || txData.origin;
const name = targetSubjectMetadata?.hostname || txData.origin;
return (
<div className="request-encryption-public-key__body">
{this.renderAccountInfo()}
<div className="request-encryption-public-key__visual">
<section>
{originMetadata?.icon ? (
{targetSubjectMetadata?.iconUrl ? (
<img
className="request-encryption-public-key__visual-identicon"
src={originMetadata.icon}
src={targetSubjectMetadata.iconUrl}
alt=""
/>
) : (

View File

@ -21,7 +21,7 @@ import ConfirmEncryptionPublicKey from './confirm-encryption-public-key.componen
function mapStateToProps(state) {
const {
metamask: { domainMetadata = {} },
metamask: { subjectMetadata = {} },
} = state;
const unconfirmedTransactions = unconfirmedTransactionsListSelector(state);
@ -34,7 +34,7 @@ function mapStateToProps(state) {
return {
txData,
domainMetadata,
subjectMetadata,
fromAccount,
requester: null,
requesterAddress: null,

View File

@ -196,7 +196,7 @@ export default function ConfirmationPage() {
label={stripHttpsScheme(originMetadata.origin)}
leftIcon={
<SiteIcon
icon={originMetadata.icon}
icon={originMetadata.iconUrl}
name={originMetadata.hostname}
size={32}
/>

View File

@ -16,8 +16,7 @@ export default class ConnectedSites extends Component {
static propTypes = {
accountLabel: PropTypes.string.isRequired,
closePopover: PropTypes.func.isRequired,
connectedDomains: PropTypes.arrayOf(PropTypes.object).isRequired,
domainHostCount: PropTypes.objectOf(PropTypes.number).isRequired,
connectedSubjects: PropTypes.arrayOf(PropTypes.object).isRequired,
disconnectAllAccounts: PropTypes.func.isRequired,
disconnectAccount: PropTypes.func.isRequired,
getOpenMetamaskTabsIds: PropTypes.func.isRequired,
@ -37,10 +36,10 @@ export default class ConnectedSites extends Component {
getOpenMetamaskTabsIds();
}
setPendingDisconnect = (domainKey) => {
setPendingDisconnect = (subjectKey) => {
this.setState({
sitePendingDisconnect: {
domainKey,
subjectKey,
},
});
};
@ -55,7 +54,7 @@ export default class ConnectedSites extends Component {
const { disconnectAccount } = this.props;
const { sitePendingDisconnect } = this.state;
disconnectAccount(sitePendingDisconnect.domainKey);
disconnectAccount(sitePendingDisconnect.subjectKey);
this.clearPendingDisconnect();
};
@ -63,15 +62,14 @@ export default class ConnectedSites extends Component {
const { disconnectAllAccounts } = this.props;
const { sitePendingDisconnect } = this.state;
disconnectAllAccounts(sitePendingDisconnect.domainKey);
disconnectAllAccounts(sitePendingDisconnect.subjectKey);
this.clearPendingDisconnect();
};
renderConnectedSitesList() {
return (
<ConnectedSitesList
domainHostCount={this.props.domainHostCount}
connectedDomains={this.props.connectedDomains}
connectedSubjects={this.props.connectedSubjects}
onDisconnect={this.setPendingDisconnect}
/>
);
@ -81,7 +79,7 @@ export default class ConnectedSites extends Component {
const {
accountLabel,
closePopover,
connectedDomains,
connectedSubjects,
tabToConnect,
requestAccountsPermission,
} = this.props;
@ -92,7 +90,7 @@ export default class ConnectedSites extends Component {
className="connected-sites"
title={t('connectedSites')}
subtitle={
connectedDomains.length
connectedSubjects.length
? t('connectedSitesDescription', [accountLabel])
: t('connectedSitesEmptyDescription', [accountLabel])
}
@ -118,15 +116,15 @@ export default class ConnectedSites extends Component {
const { closePopover, permittedAccountsByOrigin } = this.props;
const { t } = this.context;
const {
sitePendingDisconnect: { domainKey },
sitePendingDisconnect: { subjectKey },
} = this.state;
const numPermittedAccounts = permittedAccountsByOrigin[domainKey].length;
const numPermittedAccounts = permittedAccountsByOrigin[subjectKey].length;
return (
<Popover
className="connected-sites"
title={t('disconnectPrompt', [domainKey])}
title={t('disconnectPrompt', [subjectKey])}
subtitle={t('disconnectAllAccountsConfirmationDescription')}
onClose={closePopover}
footer={

View File

@ -6,11 +6,10 @@ import {
removePermittedAccount,
} from '../../store/actions';
import {
getConnectedDomainsForSelectedAddress,
getConnectedSubjectsForSelectedAddress,
getCurrentAccountWithSendEtherInfo,
getOriginOfCurrentTab,
getPermissionDomains,
getPermissionsMetadataHostCounts,
getPermissionSubjects,
getPermittedAccountsByOrigin,
getSelectedAddress,
} from '../../selectors';
@ -21,7 +20,7 @@ import ConnectedSites from './connected-sites.component';
const mapStateToProps = (state) => {
const { openMetaMaskTabs } = state.appState;
const { id } = state.activeTab;
const connectedDomains = getConnectedDomainsForSelectedAddress(state);
const connectedSubjects = getConnectedSubjectsForSelectedAddress(state);
const originOfCurrentTab = getOriginOfCurrentTab(state);
const permittedAccountsByOrigin = getPermittedAccountsByOrigin(state);
const selectedAddress = getSelectedAddress(state);
@ -38,9 +37,8 @@ const mapStateToProps = (state) => {
return {
accountLabel: getCurrentAccountWithSendEtherInfo(state).name,
connectedDomains,
domains: getPermissionDomains(state),
domainHostCount: getPermissionsMetadataHostCounts(state),
connectedSubjects,
subjects: getPermissionSubjects(state),
mostRecentOverviewPage: getMostRecentOverviewPage(state),
permittedAccountsByOrigin,
selectedAddress,
@ -51,16 +49,16 @@ const mapStateToProps = (state) => {
const mapDispatchToProps = (dispatch) => {
return {
getOpenMetamaskTabsIds: () => dispatch(getOpenMetamaskTabsIds()),
disconnectAccount: (domainKey, address) => {
dispatch(removePermittedAccount(domainKey, address));
disconnectAccount: (subjectKey, address) => {
dispatch(removePermittedAccount(subjectKey, address));
},
disconnectAllAccounts: (domainKey, domain) => {
const permissionMethodNames = domain.permissions.map(
disconnectAllAccounts: (subjectKey, subject) => {
const permissionMethodNames = subject.permissions.map(
({ parentCapability }) => parentCapability,
);
dispatch(
removePermissionsFor({
[domainKey]: permissionMethodNames,
[subjectKey]: permissionMethodNames,
}),
);
},
@ -71,8 +69,8 @@ const mapDispatchToProps = (dispatch) => {
const mergeProps = (stateProps, dispatchProps, ownProps) => {
const {
connectedDomains,
domains,
connectedSubjects,
subjects,
mostRecentOverviewPage,
selectedAddress,
tabToConnect,
@ -92,15 +90,15 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => {
...stateProps,
...dispatchProps,
closePopover,
disconnectAccount: (domainKey) => {
disconnectAccount(domainKey, selectedAddress);
if (connectedDomains.length === 1) {
disconnectAccount: (subjectKey) => {
disconnectAccount(subjectKey, selectedAddress);
if (connectedSubjects.length === 1) {
closePopover();
}
},
disconnectAllAccounts: (domainKey) => {
disconnectAllAccounts(domainKey, domains[domainKey]);
if (connectedDomains.length === 1) {
disconnectAllAccounts: (subjectKey) => {
disconnectAllAccounts(subjectKey, subjects[subjectKey]);
if (connectedSubjects.length === 1) {
closePopover();
}
},

View File

@ -75,9 +75,7 @@ const mapStateToProps = (state) => {
const firstPermissionsRequest = getFirstPermissionRequest(state);
const firstPermissionsRequestId =
firstPermissionsRequest && firstPermissionsRequest.metadata
? firstPermissionsRequest.metadata.id
: null;
firstPermissionsRequest?.metadata.id || null;
const originOfCurrentTab = getOriginOfCurrentTab(state);
const shouldShowWeb3ShimUsageNotification =

View File

@ -31,10 +31,9 @@ export default class ChooseAccount extends Component {
cancelPermissionsRequest: PropTypes.func.isRequired,
permissionsRequestId: PropTypes.string.isRequired,
selectedAccountAddresses: PropTypes.object.isRequired,
targetDomainMetadata: PropTypes.shape({
targetSubjectMetadata: PropTypes.shape({
extensionId: PropTypes.string,
icon: PropTypes.string,
host: PropTypes.string.isRequired,
iconUrl: PropTypes.string,
name: PropTypes.string.isRequired,
origin: PropTypes.string.isRequired,
}),
@ -199,7 +198,7 @@ export default class ChooseAccount extends Component {
selectAccounts,
permissionsRequestId,
cancelPermissionsRequest,
targetDomainMetadata,
targetSubjectMetadata,
accounts,
} = this.props;
const { selectedAccounts } = this.state;
@ -207,15 +206,15 @@ export default class ChooseAccount extends Component {
return (
<div className="permissions-connect-choose-account">
<PermissionsConnectHeader
icon={targetDomainMetadata.icon}
iconName={targetDomainMetadata.name}
icon={targetSubjectMetadata.iconUrl}
iconName={targetSubjectMetadata.name}
headerTitle={t('connectWithMetaMask')}
headerText={
accounts.length > 0
? t('selectAccounts')
: t('connectAccountOrCreate')
}
siteOrigin={targetDomainMetadata.origin}
siteOrigin={targetSubjectMetadata.origin}
/>
{this.renderAccountsListHeader()}
{this.renderAccountsList()}

View File

@ -31,10 +31,9 @@ export default class PermissionConnect extends Component {
connectPath: PropTypes.string.isRequired,
confirmPermissionPath: PropTypes.string.isRequired,
page: PropTypes.string.isRequired,
targetDomainMetadata: PropTypes.shape({
targetSubjectMetadata: PropTypes.shape({
extensionId: PropTypes.string,
icon: PropTypes.string,
host: PropTypes.string.isRequired,
iconUrl: PropTypes.string,
name: PropTypes.string.isRequired,
origin: PropTypes.string.isRequired,
}),
@ -56,7 +55,7 @@ export default class PermissionConnect extends Component {
selectedAccountAddresses: new Set([this.props.currentAddress]),
permissionsApproved: null,
origin: this.props.origin,
targetDomainMetadata: this.props.targetDomainMetadata || {},
targetSubjectMetadata: this.props.targetSubjectMetadata || {},
};
beforeUnload = () => {
@ -97,14 +96,14 @@ export default class PermissionConnect extends Component {
}
static getDerivedStateFromProps(props, state) {
const { permissionsRequest, targetDomainMetadata } = props;
const { targetDomainMetadata: savedMetadata } = state;
const { permissionsRequest, targetSubjectMetadata } = props;
const { targetSubjectMetadata: savedMetadata } = state;
if (
permissionsRequest &&
savedMetadata.origin !== targetDomainMetadata?.origin
savedMetadata.origin !== targetSubjectMetadata?.origin
) {
return { targetDomainMetadata };
return { targetSubjectMetadata };
}
return null;
}
@ -202,14 +201,14 @@ export default class PermissionConnect extends Component {
selectedAccountAddresses,
permissionsApproved,
redirecting,
targetDomainMetadata,
targetSubjectMetadata,
} = this.state;
return (
<div className="permissions-connect">
{this.renderTopBar()}
{redirecting && permissionsApproved ? (
<PermissionsRedirect domainMetadata={targetDomainMetadata} />
<PermissionsRedirect subjectMetadata={targetSubjectMetadata} />
) : (
<Switch>
<Route
@ -233,7 +232,7 @@ export default class PermissionConnect extends Component {
}
permissionsRequestId={permissionsRequestId}
selectedAccountAddresses={selectedAccountAddresses}
targetDomainMetadata={targetDomainMetadata}
targetSubjectMetadata={targetSubjectMetadata}
/>
)}
/>
@ -253,7 +252,7 @@ export default class PermissionConnect extends Component {
selectedIdentities={accounts.filter((account) =>
selectedAccountAddresses.has(account.address),
)}
targetDomainMetadata={targetDomainMetadata}
targetSubjectMetadata={targetSubjectMetadata}
/>
)}
/>

View File

@ -4,7 +4,7 @@ import {
getPermissionsRequests,
getAccountsWithLabels,
getLastConnectedInfo,
getDomainMetadata,
getSubjectMetadata,
getSelectedAddress,
} from '../../selectors';
import { getNativeCurrency } from '../../ducks/metamask/metamask';
@ -41,15 +41,15 @@ const mapStateToProps = (state, ownProps) => {
const { origin } = metadata;
const nativeCurrency = getNativeCurrency(state);
const domainMetadata = getDomainMetadata(state);
const subjectMetadata = getSubjectMetadata(state);
let targetDomainMetadata = null;
let targetSubjectMetadata = null;
if (origin) {
if (domainMetadata[origin]) {
targetDomainMetadata = { ...domainMetadata[origin], origin };
if (subjectMetadata[origin]) {
targetSubjectMetadata = { ...subjectMetadata[origin], origin };
} else {
const targetUrl = new URL(origin);
targetDomainMetadata = {
targetSubjectMetadata = {
host: targetUrl.host,
name: targetUrl.hostname,
origin,
@ -94,14 +94,14 @@ const mapStateToProps = (state, ownProps) => {
connectPath,
confirmPermissionPath,
page,
targetDomainMetadata,
targetSubjectMetadata,
};
};
const mapDispatchToProps = (dispatch) => {
return {
approvePermissionsRequest: (request, accounts) =>
dispatch(approvePermissionsRequest(request, accounts)),
approvePermissionsRequest: (request) =>
dispatch(approvePermissionsRequest(request)),
rejectPermissionsRequest: (requestId) =>
dispatch(rejectPermissionsRequest(requestId)),
showNewAccountModal: ({ onCreateNewAccount, newAccountNumber }) => {

View File

@ -21,10 +21,8 @@ export const ChooseAccountComponent = () => {
'0xbe0eb53f46cd790cd13851d5eff43d12404d33e8',
])
}
targetDomainMetadata={{
host: 'gnosis-safe.io',
icon: './gnosis.svg',
lastUpdated: 1627423550860,
targetSubjectMetadata={{
iconUrl: './gnosis.svg',
name: 'Gnosis - Manage Digital Assets',
origin: 'https://gnosis-safe.io',
}}
@ -56,11 +54,9 @@ export const PermissionPageContainerComponent = () => {
return (
<div className="page-container permission-approval-container">
<PermissionPageContainerContent
domainMetadata={{
subjectMetadata={{
extensionId: '1',
host: 'gnosis-safe.io',
icon: './gnosis.svg',
lastUpdated: 1627423550860,
iconUrl: './gnosis.svg',
name: 'Gnosis - Manage Digital Assets',
origin: 'https://gnosis-safe.io',
}}

View File

@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import SiteIcon from '../../../components/ui/site-icon';
import { I18nContext } from '../../../contexts/i18n';
export default function PermissionsRedirect({ domainMetadata }) {
export default function PermissionsRedirect({ subjectMetadata }) {
const t = useContext(I18nContext);
return (
@ -12,8 +12,8 @@ export default function PermissionsRedirect({ domainMetadata }) {
{t('connecting')}
<div className="permissions-redirect__icons">
<SiteIcon
icon={domainMetadata.icon}
name={domainMetadata.name}
icon={subjectMetadata.iconUrl}
name={subjectMetadata.name}
size={64}
/>
<div className="permissions-redirect__center-icon">
@ -47,10 +47,9 @@ export default function PermissionsRedirect({ domainMetadata }) {
}
PermissionsRedirect.propTypes = {
domainMetadata: PropTypes.shape({
subjectMetadata: PropTypes.shape({
extensionId: PropTypes.string,
icon: PropTypes.string,
host: PropTypes.string.isRequired,
iconUrl: PropTypes.string,
name: PropTypes.string.isRequired,
origin: PropTypes.string.isRequired,
}),

View File

@ -1,5 +1,4 @@
import { forOwn } from 'lodash';
import { CAVEAT_NAMES } from '../../shared/constants/permissions';
import { CaveatTypes } from '../../shared/constants/permissions';
import {
getMetaMaskAccountsOrdered,
getOriginOfCurrentTab,
@ -9,23 +8,23 @@ import {
// selectors
/**
* Get the permission domains object.
* Get the permission subjects object.
*
* @param {Object} state - The current state.
* @returns {Object} The permissions domains object.
* @returns {Object} The permissions subjects object.
*/
export function getPermissionDomains(state) {
return state.metamask.domains || {};
export function getPermissionSubjects(state) {
return state.metamask.subjects || {};
}
/**
* Get the permission domains metadata object.
* Get the permission subjects metadata object.
*
* @param {Object} state - The current state.
* @returns {Object} The permission domains metadata object.
* @returns {Object} The permission subjects metadata object.
*/
export function getPermissionDomainsMetadata(state) {
return state.metamask.domainMetadata || {};
export function getPermissionSubjectsMetadata(state) {
return state.metamask.subjectMetadata || {};
}
/**
@ -33,12 +32,12 @@ export function getPermissionDomainsMetadata(state) {
* and an origin.
*
* @param {Object} state - The current state.
* @param {string} origin - The origin/domain to get the permitted accounts for.
* @param {string} origin - The origin/subject to get the permitted accounts for.
* @returns {Array<string>} An empty array or an array of accounts.
*/
export function getPermittedAccounts(state, origin) {
return getAccountsFromPermission(
getAccountsPermissionFromDomain(domainSelector(state, origin)),
getAccountsPermissionFromSubject(subjectSelector(state, origin)),
);
}
@ -60,82 +59,79 @@ export function getPermittedAccountsForCurrentTab(state) {
* @returns {Object} Permitted accounts by origin.
*/
export function getPermittedAccountsByOrigin(state) {
const domains = getPermissionDomains(state);
return Object.keys(domains).reduce((acc, domainKey) => {
const accounts = getAccountsFromPermission(
getAccountsPermissionFromDomain(domains[domainKey]),
);
const subjects = getPermissionSubjects(state);
return Object.keys(subjects).reduce((acc, subjectKey) => {
const accounts = getAccountsFromSubject(subjects[subjectKey]);
if (accounts.length > 0) {
acc[domainKey] = accounts;
acc[subjectKey] = accounts;
}
return acc;
}, {});
}
/**
* Returns an array of connected domain objects, with the following properties:
* Returns an array of connected subject objects, with the following properties:
* - extensionId
* - key (i.e. origin)
* - name
* - icon
*
* @param {Object} state - The current state.
* @returns {Array<Object>} An array of connected domain objects.
* @returns {Array<Object>} An array of connected subject objects.
*/
export function getConnectedDomainsForSelectedAddress(state) {
export function getConnectedSubjectsForSelectedAddress(state) {
const { selectedAddress } = state.metamask;
const domains = getPermissionDomains(state);
const domainMetadata = getPermissionDomainsMetadata(state);
const subjects = getPermissionSubjects(state);
const subjectMetadata = getPermissionSubjectsMetadata(state);
const connectedDomains = [];
const connectedSubjects = [];
forOwn(domains, (domainValue, domainKey) => {
const exposedAccounts = getAccountsFromDomain(domainValue);
Object.entries(subjects).forEach(([subjectKey, subjectValue]) => {
const exposedAccounts = getAccountsFromSubject(subjectValue);
if (!exposedAccounts.includes(selectedAddress)) {
return;
}
const { extensionId, name, icon, host } = domainMetadata[domainKey] || {};
const { extensionId, name, iconUrl } = subjectMetadata[subjectKey] || {};
connectedDomains.push({
connectedSubjects.push({
extensionId,
origin: domainKey,
origin: subjectKey,
name,
icon,
host,
iconUrl,
});
});
return connectedDomains;
return connectedSubjects;
}
/**
* Returns an object mapping addresses to objects mapping origins to connected
* domain info. Domain info objects have the following properties:
* - icon
* subject info. Subject info objects have the following properties:
* - iconUrl
* - name
*
* @param {Object} state - The current state.
* @returns {Object} A mapping of addresses to a mapping of origins to
* connected domain info.
* connected subject info.
*/
export function getAddressConnectedDomainMap(state) {
const domainMetadata = getPermissionDomainsMetadata(state);
export function getAddressConnectedSubjectMap(state) {
const subjectMetadata = getPermissionSubjectsMetadata(state);
const accountsMap = getPermittedAccountsByOrigin(state);
const addressConnectedIconMap = {};
Object.keys(accountsMap).forEach((domainKey) => {
const { icon, name } = domainMetadata[domainKey] || {};
Object.keys(accountsMap).forEach((subjectKey) => {
const { iconUrl, name } = subjectMetadata[subjectKey] || {};
accountsMap[domainKey].forEach((address) => {
const nameToRender = name || domainKey;
accountsMap[subjectKey].forEach((address) => {
const nameToRender = name || subjectKey;
addressConnectedIconMap[address] = addressConnectedIconMap[address]
? {
...addressConnectedIconMap[address],
[domainKey]: { icon, name: nameToRender },
[subjectKey]: { iconUrl, name: nameToRender },
}
: { [domainKey]: { icon, name: nameToRender } };
: { [subjectKey]: { iconUrl, name: nameToRender } };
});
});
@ -144,16 +140,12 @@ export function getAddressConnectedDomainMap(state) {
// selector helpers
function getAccountsFromDomain(domain) {
return getAccountsFromPermission(getAccountsPermissionFromDomain(domain));
function getAccountsFromSubject(subject) {
return getAccountsFromPermission(getAccountsPermissionFromSubject(subject));
}
function getAccountsPermissionFromDomain(domain = {}) {
return Array.isArray(domain.permissions)
? domain.permissions.find(
(perm) => perm.parentCapability === 'eth_accounts',
)
: {};
function getAccountsPermissionFromSubject(subject = {}) {
return subject.permissions?.eth_accounts || {};
}
function getAccountsFromPermission(accountsPermission) {
@ -167,13 +159,13 @@ function getAccountsCaveatFromPermission(accountsPermission = {}) {
return (
Array.isArray(accountsPermission.caveats) &&
accountsPermission.caveats.find(
(c) => c.name === CAVEAT_NAMES.exposedAccounts,
(caveat) => caveat.type === CaveatTypes.restrictReturnedAccounts,
)
);
}
function domainSelector(state, origin) {
return origin && state.metamask.domains?.[origin];
function subjectSelector(state, origin) {
return origin && state.metamask.subjects?.[origin];
}
export function getAccountToConnectToActiveTab(state) {
@ -203,12 +195,12 @@ export function getAccountToConnectToActiveTab(state) {
export function getOrderedConnectedAccountsForActiveTab(state) {
const {
activeTab,
metamask: { permissionsHistory },
metamask: { permissionHistory },
} = state;
const permissionsHistoryByAccount =
const permissionHistoryByAccount =
// eslint-disable-next-line camelcase
permissionsHistory[activeTab.origin]?.eth_accounts?.accounts;
permissionHistory[activeTab.origin]?.eth_accounts?.accounts;
const orderedAccounts = getMetaMaskAccountsOrdered(state);
const connectedAccounts = getPermittedAccountsForCurrentTab(state);
@ -216,7 +208,7 @@ export function getOrderedConnectedAccountsForActiveTab(state) {
.filter((account) => connectedAccounts.includes(account.address))
.map((account) => ({
...account,
lastActive: permissionsHistoryByAccount?.[account.address],
lastActive: permissionHistoryByAccount?.[account.address],
}))
.sort(
({ lastSelected: lastSelectedA }, { lastSelected: lastSelectedB }) => {
@ -235,27 +227,31 @@ export function getOrderedConnectedAccountsForActiveTab(state) {
export function getPermissionsForActiveTab(state) {
const { activeTab, metamask } = state;
const { domains = {} } = metamask;
const { subjects = {} } = metamask;
return domains[activeTab.origin]?.permissions?.map(({ parentCapability }) => {
return {
key: parentCapability,
};
});
return Object.keys(subjects[activeTab.origin]?.permissions || {}).map(
(parentCapability) => {
return {
key: parentCapability,
};
},
);
}
export function activeTabHasPermissions(state) {
const { activeTab, metamask } = state;
const { domains = {} } = metamask;
const { subjects = {} } = metamask;
return Boolean(domains[activeTab.origin]?.permissions?.length > 0);
return Boolean(
Object.keys(subjects[activeTab.origin]?.permissions || {}).length > 0,
);
}
export function getLastConnectedInfo(state) {
const { permissionsHistory = {} } = state.metamask;
return Object.keys(permissionsHistory).reduce((acc, origin) => {
const { permissionHistory = {} } = state.metamask;
return Object.keys(permissionHistory).reduce((acc, origin) => {
const ethAccountsHistory = JSON.parse(
JSON.stringify(permissionsHistory[origin].eth_accounts),
JSON.stringify(permissionHistory[origin].eth_accounts),
);
return {
...acc,
@ -264,22 +260,10 @@ export function getLastConnectedInfo(state) {
}, {});
}
export function getPermissionsMetadataHostCounts(state) {
const metadata = getPermissionDomainsMetadata(state);
return Object.values(metadata).reduce((counts, { host }) => {
if (host) {
if (counts[host]) {
counts[host] += 1;
} else {
counts[host] = 1;
}
}
return counts;
}, {});
}
export function getPermissionsRequests(state) {
return state.metamask.permissionsRequests || [];
return Object.values(state.metamask.pendingApprovals)
.filter(({ type }) => type === 'wallet_requestPermissions')
.map(({ requestData }) => requestData);
}
export function getFirstPermissionRequest(state) {

View File

@ -1,154 +1,139 @@
import { KOVAN_CHAIN_ID } from '../../shared/constants/network';
import {
getConnectedDomainsForSelectedAddress,
getConnectedSubjectsForSelectedAddress,
getOrderedConnectedAccountsForActiveTab,
getPermissionsForActiveTab,
} from './permissions';
describe('selectors', () => {
describe('getConnectedDomainsForSelectedAddress', () => {
it('should return the list of connected domains when there is 1 connected account', () => {
describe('getConnectedSubjectsForSelectedAddress', () => {
it('should return the list of connected subjects when there is 1 connected account', () => {
const mockState = {
metamask: {
selectedAddress: '0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5',
domainMetadata: {
subjectMetadata: {
'peepeth.com': {
icon: 'https://peepeth.com/favicon-32x32.png',
iconUrl: 'https://peepeth.com/favicon-32x32.png',
name: 'Peepeth',
host: 'peepeth.com',
},
'https://remix.ethereum.org': {
icon: 'https://remix.ethereum.org/icon.png',
iconUrl: 'https://remix.ethereum.org/icon.png',
name: 'Remix - Ethereum IDE',
host: 'remix.ethereum.org',
},
},
domains: {
subjects: {
'peepeth.com': {
permissions: [
{
'@context': ['https://github.com/MetaMask/rpc-cap'],
'caveats': [
permissions: {
eth_accounts: {
caveats: [
{
name: 'exposedAccounts',
type: 'filterResponse',
type: 'restrictReturnedAccounts',
value: ['0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5'],
},
],
'date': 1585676177970,
'id': '840d72a0-925f-449f-830a-1aa1dd5ce151',
'invoker': 'peepeth.com',
'parentCapability': 'eth_accounts',
date: 1585676177970,
id: '840d72a0-925f-449f-830a-1aa1dd5ce151',
invoker: 'peepeth.com',
parentCapability: 'eth_accounts',
},
],
},
},
'https://remix.ethereum.org': {
permissions: [
{
'@context': ['https://github.com/MetaMask/rpc-cap'],
'caveats': [
permissions: {
eth_accounts: {
caveats: [
{
type: 'filterResponse',
type: 'restrictReturnedAccounts',
value: ['0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5'],
name: 'exposedAccounts',
},
],
'date': 1585685128948,
'id': '6b9615cc-64e4-4317-afab-3c4f8ee0244a',
'invoker': 'https://remix.ethereum.org',
'parentCapability': 'eth_accounts',
date: 1585685128948,
id: '6b9615cc-64e4-4317-afab-3c4f8ee0244a',
invoker: 'https://remix.ethereum.org',
parentCapability: 'eth_accounts',
},
],
},
},
},
},
};
const extensionId = undefined;
expect(getConnectedDomainsForSelectedAddress(mockState)).toStrictEqual([
expect(getConnectedSubjectsForSelectedAddress(mockState)).toStrictEqual([
{
extensionId,
icon: 'https://peepeth.com/favicon-32x32.png',
iconUrl: 'https://peepeth.com/favicon-32x32.png',
origin: 'peepeth.com',
name: 'Peepeth',
host: 'peepeth.com',
},
{
extensionId,
name: 'Remix - Ethereum IDE',
icon: 'https://remix.ethereum.org/icon.png',
iconUrl: 'https://remix.ethereum.org/icon.png',
origin: 'https://remix.ethereum.org',
host: 'remix.ethereum.org',
},
]);
});
it('should return the list of connected domains when there are 2 connected accounts', () => {
it('should return the list of connected subjects when there are 2 connected accounts', () => {
const mockState = {
metamask: {
selectedAddress: '0x7250739de134d33ec7ab1ee592711e15098c9d2d',
domainMetadata: {
subjectMetadata: {
'peepeth.com': {
icon: 'https://peepeth.com/favicon-32x32.png',
iconUrl: 'https://peepeth.com/favicon-32x32.png',
name: 'Peepeth',
host: 'peepeth.com',
},
'https://remix.ethereum.org': {
icon: 'https://remix.ethereum.org/icon.png',
iconUrl: 'https://remix.ethereum.org/icon.png',
name: 'Remix - Ethereum IDE',
host: 'remix.ethereum.com',
},
},
domains: {
subjects: {
'peepeth.com': {
permissions: [
{
'@context': ['https://github.com/MetaMask/rpc-cap'],
'caveats': [
permissions: {
eth_accounts: {
caveats: [
{
name: 'exposedAccounts',
type: 'filterResponse',
type: 'restrictReturnedAccounts',
value: ['0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5'],
},
],
'date': 1585676177970,
'id': '840d72a0-925f-449f-830a-1aa1dd5ce151',
'invoker': 'peepeth.com',
'parentCapability': 'eth_accounts',
date: 1585676177970,
id: '840d72a0-925f-449f-830a-1aa1dd5ce151',
invoker: 'peepeth.com',
parentCapability: 'eth_accounts',
},
],
},
},
'https://remix.ethereum.org': {
permissions: [
{
'@context': ['https://github.com/MetaMask/rpc-cap'],
'caveats': [
permissions: {
eth_accounts: {
caveats: [
{
type: 'filterResponse',
type: 'restrictReturnedAccounts',
value: [
'0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5',
'0x7250739de134d33ec7ab1ee592711e15098c9d2d',
],
name: 'exposedAccounts',
},
],
'date': 1585685128948,
'id': '6b9615cc-64e4-4317-afab-3c4f8ee0244a',
'invoker': 'https://remix.ethereum.org',
'parentCapability': 'eth_accounts',
date: 1585685128948,
id: '6b9615cc-64e4-4317-afab-3c4f8ee0244a',
invoker: 'https://remix.ethereum.org',
parentCapability: 'eth_accounts',
},
],
},
},
},
},
};
const extensionId = undefined;
expect(getConnectedDomainsForSelectedAddress(mockState)).toStrictEqual([
expect(getConnectedSubjectsForSelectedAddress(mockState)).toStrictEqual([
{
extensionId,
name: 'Remix - Ethereum IDE',
icon: 'https://remix.ethereum.org/icon.png',
iconUrl: 'https://remix.ethereum.org/icon.png',
origin: 'https://remix.ethereum.org',
host: 'remix.ethereum.com',
},
]);
});
@ -184,15 +169,13 @@ describe('selectors', () => {
},
},
cachedBalances: {},
domains: {
subjects: {
'https://remix.ethereum.org': {
permissions: [
{
'@context': ['https://github.com/MetaMask/rpc-cap'],
'caveats': [
permissions: {
eth_accounts: {
caveats: [
{
name: 'exposedAccounts',
type: 'filterResponse',
type: 'restrictReturnedAccounts',
value: [
'0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5',
'0x7250739de134d33ec7ab1ee592711e15098c9d2d',
@ -202,30 +185,28 @@ describe('selectors', () => {
],
},
],
'date': 1586359844177,
'id': '3aa65a8b-3bcb-4944-941b-1baa5fe0ed8b',
'invoker': 'https://remix.ethereum.org',
'parentCapability': 'eth_accounts',
date: 1586359844177,
id: '3aa65a8b-3bcb-4944-941b-1baa5fe0ed8b',
invoker: 'https://remix.ethereum.org',
parentCapability: 'eth_accounts',
},
],
},
},
'peepeth.com': {
permissions: [
{
'@context': ['https://github.com/MetaMask/rpc-cap'],
'caveats': [
permissions: {
eth_accounts: {
caveats: [
{
name: 'exposedAccounts',
type: 'filterResponse',
type: 'restrictReturnedAccounts',
value: ['0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5'],
},
],
'date': 1585676177970,
'id': '840d72a0-925f-449f-830a-1aa1dd5ce151',
'invoker': 'peepeth.com',
'parentCapability': 'eth_accounts',
date: 1585676177970,
id: '840d72a0-925f-449f-830a-1aa1dd5ce151',
invoker: 'peepeth.com',
parentCapability: 'eth_accounts',
},
],
},
},
},
identities: {
@ -264,7 +245,7 @@ describe('selectors', () => {
],
},
],
permissionsHistory: {
permissionHistory: {
'https://remix.ethereum.org': {
eth_accounts: {
accounts: {
@ -338,72 +319,66 @@ describe('selectors', () => {
name: 'Account 2',
},
},
domains: {
subjects: {
'https://remix.ethereum.org': {
permissions: [
{
'@context': ['https://github.com/MetaMask/rpc-cap'],
'caveats': [
permissions: {
eth_accounts: {
caveats: [
{
name: 'exposedAccounts',
type: 'filterResponse',
type: 'restrictReturnedAccounts',
value: [
'0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5',
'0x7250739de134d33ec7ab1ee592711e15098c9d2d',
],
},
],
'date': 1586359844177,
'id': '3aa65a8b-3bcb-4944-941b-1baa5fe0ed8b',
'invoker': 'https://remix.ethereum.org',
'parentCapability': 'eth_accounts',
date: 1586359844177,
id: '3aa65a8b-3bcb-4944-941b-1baa5fe0ed8b',
invoker: 'https://remix.ethereum.org',
parentCapability: 'eth_accounts',
},
],
},
},
'peepeth.com': {
permissions: [
{
'@context': ['https://github.com/MetaMask/rpc-cap'],
'caveats': [
permissions: {
eth_accounts: {
caveats: [
{
name: 'exposedAccounts',
type: 'filterResponse',
type: 'restrictReturnedAccounts',
value: ['0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5'],
},
],
'date': 1585676177970,
'id': '840d72a0-925f-449f-830a-1aa1dd5ce151',
'invoker': 'peepeth.com',
'parentCapability': 'eth_accounts',
date: 1585676177970,
id: '840d72a0-925f-449f-830a-1aa1dd5ce151',
invoker: 'peepeth.com',
parentCapability: 'eth_accounts',
},
],
},
},
'uniswap.exchange': {
permissions: [
{
'@context': ['https://github.com/MetaMask/rpc-cap'],
'caveats': [
permissions: {
eth_accounts: {
caveats: [
{
name: 'exposedAccounts',
type: 'filterResponse',
type: 'restrictReturnedAccounts',
value: ['0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5'],
},
],
'date': 1585616816623,
'id': 'ce625215-f2e9-48e7-93ca-21ba193244ff',
'invoker': 'uniswap.exchange',
'parentCapability': 'eth_accounts',
date: 1585616816623,
id: 'ce625215-f2e9-48e7-93ca-21ba193244ff',
invoker: 'uniswap.exchange',
parentCapability: 'eth_accounts',
},
],
},
},
},
domainMetadata: {
subjectMetadata: {
'https://remix.ethereum.org': {
icon: 'https://remix.ethereum.org/icon.png',
iconUrl: 'https://remix.ethereum.org/icon.png',
name: 'Remix - Ethereum IDE',
},
},
permissionsHistory: {
permissionHistory: {
'https://remix.ethereum.org': {
eth_accounts: {
accounts: {

View File

@ -508,8 +508,8 @@ export function getCustomNonceValue(state) {
return String(state.metamask.customNonceValue);
}
export function getDomainMetadata(state) {
return state.metamask.domainMetadata;
export function getSubjectMetadata(state) {
return state.metamask.subjectMetadata;
}
export function getRpcPrefsForCurrentProvider(state) {

View File

@ -2477,12 +2477,11 @@ export function requestAccountsPermissionWithId(origin) {
/**
* Approves the permissions request.
* @param {Object} request - The permissions request to approve
* @param {string[]} accounts - The accounts to expose, if any.
* @param {Object} request - The permissions request to approve.
*/
export function approvePermissionsRequest(request, accounts) {
export function approvePermissionsRequest(request) {
return (dispatch) => {
background.approvePermissionsRequest(request, accounts, (err) => {
background.approvePermissionsRequest(request, (err) => {
if (err) {
dispatch(displayWarning(err.message));
}
@ -2512,9 +2511,9 @@ export function rejectPermissionsRequest(requestId) {
/**
* Clears the given permissions for the given origin.
*/
export function removePermissionsFor(domains) {
export function removePermissionsFor(subjects) {
return (dispatch) => {
background.removePermissionsFor(domains, (err) => {
background.removePermissionsFor(subjects, (err) => {
if (err) {
dispatch(displayWarning(err.message));
}

138
yarn.lock
View File

@ -2601,11 +2601,48 @@
semver "^7.3.5"
yargs "^17.0.1"
"@metamask/contract-metadata@^1.19.0", "@metamask/contract-metadata@^1.30.0", "@metamask/contract-metadata@^1.31.0":
"@metamask/contract-metadata@^1.29.0", "@metamask/contract-metadata@^1.30.0", "@metamask/contract-metadata@^1.31.0":
version "1.31.0"
resolved "https://registry.yarnpkg.com/@metamask/contract-metadata/-/contract-metadata-1.31.0.tgz#9e3e46de7a955ea1ca61f7db20d9a17b5e91d3d0"
integrity sha512-4FBJkg/vDiYp/thIiZknxrJ0lfsj2eWIPenwlNZmoqOhoL4VqhK5eKWxi+EuGMvv9taP+QBRk6Key7wC1uL78A==
"@metamask/controllers@^17.0.0":
version "17.0.0"
resolved "https://registry.yarnpkg.com/@metamask/controllers/-/controllers-17.0.0.tgz#7ef00b4f7583d8075115e8a2f074d7b66646bbe8"
integrity sha512-myPlAk8SpNm5SwHHKGgm2XDLP4bxNR2UsKoQlYtV7bJq3l8FV1agSFwHBwDhg61/52Xvqdqy+1YDVdV3kOwPgg==
dependencies:
"@ethereumjs/common" "^2.3.1"
"@ethereumjs/tx" "^3.2.1"
"@metamask/contract-metadata" "^1.29.0"
"@types/uuid" "^8.3.0"
abort-controller "^3.0.0"
async-mutex "^0.2.6"
babel-runtime "^6.26.0"
eth-ens-namehash "^2.0.8"
eth-json-rpc-infura "^5.1.0"
eth-keyring-controller "^6.2.1"
eth-method-registry "1.1.0"
eth-phishing-detect "^1.1.14"
eth-query "^2.1.2"
eth-rpc-errors "^4.0.0"
eth-sig-util "^3.0.0"
ethereumjs-util "^7.0.10"
ethereumjs-wallet "^1.0.1"
ethers "^5.4.1"
ethjs-unit "^0.1.6"
ethjs-util "^0.1.6"
human-standard-collectible-abi "^1.0.2"
human-standard-token-abi "^2.0.0"
immer "^9.0.6"
isomorphic-fetch "^3.0.0"
jsonschema "^1.2.4"
nanoid "^3.1.12"
punycode "^2.1.1"
single-call-balance-checker-abi "^1.0.0"
uuid "^8.3.2"
web3 "^0.20.7"
web3-provider-engine "^16.0.3"
"@metamask/controllers@^20.1.0":
version "20.1.0"
resolved "https://registry.yarnpkg.com/@metamask/controllers/-/controllers-20.1.0.tgz#1d8386dc22d14f9fd9feb8b3cc8314d663587550"
@ -2644,34 +2681,6 @@
web3 "^0.20.7"
web3-provider-engine "^16.0.3"
"@metamask/controllers@^5.0.0":
version "5.1.0"
resolved "https://registry.yarnpkg.com/@metamask/controllers/-/controllers-5.1.0.tgz#02c1957295bcb6db1655a716d165665d170e7f34"
integrity sha512-4piqkIrpphe+9nEy68WH+yBw9wsXZyCMVeBZeRtliVHAJFXUdz+KZDUi/R1Y+568JBzqAvsOtOzbUIU4btD3Fw==
dependencies:
"@metamask/contract-metadata" "^1.19.0"
await-semaphore "^0.1.3"
eth-ens-namehash "^2.0.8"
eth-json-rpc-infura "^5.1.0"
eth-keyring-controller "^6.1.0"
eth-method-registry "1.1.0"
eth-phishing-detect "^1.1.13"
eth-query "^2.1.2"
eth-rpc-errors "^4.0.0"
eth-sig-util "^3.0.0"
ethereumjs-util "^6.1.0"
ethereumjs-wallet "^0.6.4"
ethjs-query "^0.3.8"
human-standard-collectible-abi "^1.0.2"
human-standard-token-abi "^2.0.0"
isomorphic-fetch "^3.0.0"
jsonschema "^1.2.4"
nanoid "^3.1.12"
single-call-balance-checker-abi "^1.0.0"
uuid "^3.3.2"
web3 "^0.20.7"
web3-provider-engine "^16.0.1"
"@metamask/eslint-config-jest@^6.0.0":
version "6.0.0"
resolved "https://registry.yarnpkg.com/@metamask/eslint-config-jest/-/eslint-config-jest-6.0.0.tgz#9e10cfbca31236afd7be2058be70365084e540d6"
@ -2759,6 +2768,15 @@
readable-stream "^2.2.2"
through2 "^2.0.3"
"@metamask/obs-store@^6.0.2":
version "6.0.2"
resolved "https://registry.yarnpkg.com/@metamask/obs-store/-/obs-store-6.0.2.tgz#1fbc458cc617557a4557f9ab58e6676c474df2b1"
integrity sha512-MjnP+xNZGBx46YZrR8ZYPb+ScPfxJUbs09MTByuQKxMsf7Lxz17oBTI5ZMkOZOTSBBxhknKdjJg+nAM8mMopwg==
dependencies:
"@metamask/safe-event-emitter" "^2.0.0"
readable-stream "^2.2.2"
through2 "^2.0.3"
"@metamask/obs-store@^7.0.0":
version "7.0.0"
resolved "https://registry.yarnpkg.com/@metamask/obs-store/-/obs-store-7.0.0.tgz#6cae5f28306bb3e83a381bc9ae22682316095bd3"
@ -2767,7 +2785,7 @@
"@metamask/safe-event-emitter" "^2.0.0"
through2 "^2.0.3"
"@metamask/post-message-stream@^4.0.0":
"@metamask/post-message-stream@4.0.0", "@metamask/post-message-stream@^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@metamask/post-message-stream/-/post-message-stream-4.0.0.tgz#72f120e562346ca86ccc9b3684023ad44265f0df"
integrity sha512-r0JcoWXNuHycProx8ClxiIElJY/GVb/0/WWXTMsZu7qDejLo52VNXlwfydCdVjbMXeoT2nK1Yt3d5gjmHy5BWw==
@ -2797,6 +2815,32 @@
resolved "https://registry.yarnpkg.com/@metamask/safe-event-emitter/-/safe-event-emitter-2.0.0.tgz#af577b477c683fad17c619a78208cede06f9605c"
integrity sha512-/kSXhY692qiV1MXu6EeOZvg5nECLclxNXcKCxJ3cXQgYuRymRHpdx/t7JXfsK+JLjwA1e1c1/SBrlQYpusC29Q==
"@metamask/snap-controllers@^0.4.0":
version "0.4.0"
resolved "https://registry.yarnpkg.com/@metamask/snap-controllers/-/snap-controllers-0.4.0.tgz#20647f061e20263f462347c2bf0d59f7aedd6898"
integrity sha512-pZ9S72Y9u2KBrMdzFauz6t4LIxJBqcT6uzxstxdY8y0qroeeTJum6Z0L9HFkVSsTLP3JMyVSDD6FwRsHIwXewg==
dependencies:
"@metamask/controllers" "^17.0.0"
"@metamask/object-multiplex" "^1.1.0"
"@metamask/obs-store" "^6.0.2"
"@metamask/post-message-stream" "4.0.0"
"@metamask/safe-event-emitter" "^2.0.0"
"@metamask/snap-workers" "^0.4.0"
"@types/deep-freeze-strict" "^1.1.0"
deep-freeze-strict "^1.1.1"
eth-rpc-errors "^4.0.2"
fast-deep-equal "^3.1.3"
immer "^9.0.6"
json-rpc-engine "^6.1.0"
json-rpc-middleware-stream "^3.0.0"
nanoid "^3.1.28"
pump "^3.0.0"
"@metamask/snap-workers@^0.4.0":
version "0.4.0"
resolved "https://registry.yarnpkg.com/@metamask/snap-workers/-/snap-workers-0.4.0.tgz#ba561eb15a7b7e7b353738ad5635a68c03cf64b0"
integrity sha512-usPEnwRXIwaDc06f8Jis4/CxXzmZJpPOLucOMqkxGAAz3hepA/T5fbfus12sibo5h6QsG0VTqBQ5AqKFlTr0zQ==
"@metamask/test-dapp@^4.0.1":
version "4.0.1"
resolved "https://registry.yarnpkg.com/@metamask/test-dapp/-/test-dapp-4.0.1.tgz#fbc66069687f0502ebb4c6ac0fa7c9862ea6563c"
@ -4105,6 +4149,11 @@
resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0"
integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==
"@types/deep-freeze-strict@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@types/deep-freeze-strict/-/deep-freeze-strict-1.1.0.tgz#447a6a2576191344aa42310131dd3df5c41492c4"
integrity sha1-RHpqJXYZE0SqQjEBMd099cQUksQ=
"@types/estree@^0.0.48":
version "0.0.48"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.48.tgz#18dc8091b285df90db2f25aa7d906cfc394b7f74"
@ -9494,7 +9543,7 @@ deep-extend@^0.6.0, deep-extend@~0.6.0:
resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac"
integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==
deep-freeze-strict@1.1.1:
deep-freeze-strict@1.1.1, deep-freeze-strict@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/deep-freeze-strict/-/deep-freeze-strict-1.1.1.tgz#77d0583ca24a69be4bbd9ac2fae415d55523e5b0"
integrity sha1-d9BYPKJKab5LvZrC+uQV1VUj5bA=
@ -11331,7 +11380,7 @@ eth-json-rpc-middleware@^8.0.0:
node-fetch "^2.6.1"
pify "^3.0.0"
eth-keyring-controller@^6.1.0, eth-keyring-controller@^6.2.0, eth-keyring-controller@^6.2.1:
eth-keyring-controller@^6.2.0, eth-keyring-controller@^6.2.1:
version "6.2.1"
resolved "https://registry.yarnpkg.com/eth-keyring-controller/-/eth-keyring-controller-6.2.1.tgz#61901071fc74059ed37cb5ae93870fdcae6e3781"
integrity sha512-x2gTM1iHp2Kbvdtd9Eslysw0qzVZiqOzpVB3AU/ni2Xiit+rlcv2H80zYKjrEwlfWFDj4YILD3bOqlnEMmRJOA==
@ -11393,7 +11442,7 @@ eth-method-registry@^2.0.0:
dependencies:
ethjs "^0.4.0"
eth-phishing-detect@^1.1.13, eth-phishing-detect@^1.1.14:
eth-phishing-detect@^1.1.14:
version "1.1.15"
resolved "https://registry.yarnpkg.com/eth-phishing-detect/-/eth-phishing-detect-1.1.15.tgz#c42e1aad6cd1c5eeee41c6bf932dcfd0e523d499"
integrity sha512-RVNSGMVIuO6VZ1Uv4v8dljjj0ephW+APVAU5QL5mBu3VEqfBluPMNb6jw66kxYrIFrSNalnb/pMeDpAA+W3cvg==
@ -11636,7 +11685,7 @@ ethereumjs-tx@^1.1.1, ethereumjs-tx@^1.2.0, ethereumjs-tx@^1.2.2, ethereumjs-tx@
ethereum-common "^0.0.18"
ethereumjs-util "^5.0.0"
ethereumjs-util@6.2.1, ethereumjs-util@^6.0.0, ethereumjs-util@^6.1.0, ethereumjs-util@^6.2.0:
ethereumjs-util@6.2.1, ethereumjs-util@^6.0.0, ethereumjs-util@^6.2.0:
version "6.2.1"
resolved "https://registry.yarnpkg.com/ethereumjs-util/-/ethereumjs-util-6.2.1.tgz#fcb4e4dd5ceacb9d2305426ab1a5cd93e3163b69"
integrity sha512-W2Ktez4L01Vexijrm5EB6w7dg4n/TgpoYU4avuT5T3Vmnw/eCRtiBrJfQYS/DCSvDIOLn2k57GcHdeBcgVxAqw==
@ -19884,10 +19933,10 @@ nanoid@^2.0.0, nanoid@^2.1.6:
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-2.1.11.tgz#ec24b8a758d591561531b4176a01e3ab4f0f0280"
integrity sha512-s/snB+WGm6uwi0WjsZdaVcuf3KJXlfGl2LcxgwkEwJF0D/BWzVWAZW/XY4bFaiR7s0Jk3FPvlnepg1H1b1UwlA==
nanoid@^3.1.12, nanoid@^3.1.23:
version "3.1.23"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.23.tgz#f744086ce7c2bc47ee0a8472574d5c78e4183a81"
integrity sha512-FiB0kzdP0FFVGDKlRLEQ1BgDzU87dy5NnzjeW9YZNt+/c3+q82EQDUwniSAUxp/F0gFNI1ZhKU1FqYsMuqZVnw==
nanoid@^3.1.12, nanoid@^3.1.23, nanoid@^3.1.28:
version "3.1.30"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.30.tgz#63f93cc548d2a113dc5dfbc63bfa09e2b9b64362"
integrity sha512-zJpuPDwOv8D2zq2WRoMe1HsfZthVewpel9CAvTfc/2mBD1uUT/agc5f7GHGWXlYkFvi1mVxe4IjvP2HNrop7nQ==
nanomatch@^1.2.9:
version "1.2.9"
@ -24538,17 +24587,6 @@ roarr@^2.15.3:
semver-compare "^1.0.0"
sprintf-js "^1.1.2"
rpc-cap@^3.2.1:
version "3.2.1"
resolved "https://registry.yarnpkg.com/rpc-cap/-/rpc-cap-3.2.1.tgz#95aba886c60562626261667d3637f9b4fe05b078"
integrity sha512-aNevLIJ/jIGR3by0b3pd7MWBUSJOkbOkjOCZ13Kk2ddToGZV1TaxclBsasLIdomFbxl0lyUQ8EeoMwYrS7yuBA==
dependencies:
"@metamask/controllers" "^5.0.0"
eth-rpc-errors "^3.0.0"
is-subset "^0.1.1"
json-rpc-engine "^5.3.0"
uuid "^3.3.2"
rsa-pem-to-jwk@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/rsa-pem-to-jwk/-/rsa-pem-to-jwk-1.1.3.tgz#245e76bdb7e7234cfee7ca032d31b54c38fab98e"
@ -28379,7 +28417,7 @@ web3-provider-engine@14.2.1:
xhr "^2.2.0"
xtend "^4.0.1"
web3-provider-engine@^16.0.1, web3-provider-engine@^16.0.3:
web3-provider-engine@^16.0.3:
version "16.0.3"
resolved "https://registry.yarnpkg.com/web3-provider-engine/-/web3-provider-engine-16.0.3.tgz#8ff93edf3a8da2f70d7f85c5116028c06a0d9f07"
integrity sha512-Q3bKhGqLfMTdLvkd4TtkGYJHcoVQ82D1l8jTIwwuJp/sAp7VHnRYb9YJ14SW/69VMWoOhSpPLZV2tWb9V0WJoA==