* origin/develop: (100 commits) Switch Flask horizontal logos (#13113) Update `@babel/runtime` patch to fix lockdown error (#13109) Use promisified background in setUseNonceField (#13107) Fix account name duplicates (#12867) Choose accounts refactor (#13039) Fix permissions-connect-footer "learn more" link (#13092) Feat/collectibles the return (#12970) Subject metadata cleanup (#13090) Fix merge conflict typo Bump just-safe-set from 2.1.0 to 2.2.3 (#13049) Fix typo in German translation (#13040) Using EIP-1559 V2 for swaps (#12966) Make restore vault a form so an user can submit via keyboard (#12989) Remove legacy node parent detection (#12814) Add stories for Home notification component (#13035) Update Redux DevTools README instructions (#13038) Jestify app/scripts/controller/network/**/*.test.js (#12985) Fix order of account list (#12999) Changes in gas loading animation in EIP-1559 V2 (#13016) Add crowdin configuration and github action (#12552) ...feature/default_network_editable
@ -0,0 +1,31 @@ |
|||||||
|
name: Crowdin Action |
||||||
|
|
||||||
|
permissions: |
||||||
|
contents: write |
||||||
|
pull-requests: write |
||||||
|
|
||||||
|
on: |
||||||
|
push: |
||||||
|
branches: |
||||||
|
- develop |
||||||
|
schedule: |
||||||
|
- cron: "0 */12 * * *" |
||||||
|
|
||||||
|
jobs: |
||||||
|
synchronize-with-crowdin: |
||||||
|
runs-on: ubuntu-latest |
||||||
|
|
||||||
|
steps: |
||||||
|
|
||||||
|
- name: Checkout |
||||||
|
uses: actions/checkout@v2 |
||||||
|
|
||||||
|
- name: crowdin action |
||||||
|
uses: crowdin/github-action@d0622816ed4f4744db27d04374b2cef6867f7bed |
||||||
|
with: |
||||||
|
upload_translations: true |
||||||
|
download_translations: true |
||||||
|
env: |
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
||||||
|
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} |
||||||
|
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} |
@ -1,7 +1,13 @@ |
|||||||
module.exports = { |
module.exports = { |
||||||
// TODO: Remove the `exit` setting, it can hide broken tests.
|
// TODO: Remove the `exit` setting, it can hide broken tests.
|
||||||
exit: true, |
exit: true, |
||||||
ignore: ['./app/scripts/migrations/*.test.js', './app/scripts/platforms/*.test.js'], |
ignore: [ |
||||||
|
'./app/scripts/lib/**/*.test.js', |
||||||
|
'./app/scripts/migrations/*.test.js', |
||||||
|
'./app/scripts/platforms/*.test.js', |
||||||
|
'./app/scripts/controllers/network/**/*.test.js', |
||||||
|
'./app/scripts/controllers/permissions/*.test.js', |
||||||
|
], |
||||||
recursive: true, |
recursive: true, |
||||||
require: ['test/env.js', 'test/setup.js'], |
require: ['test/env.js', 'test/setup.js'], |
||||||
} |
}; |
||||||
|
@ -1,5 +0,0 @@ |
|||||||
const baseConfig = require('./.mocharc'); |
|
||||||
|
|
||||||
module.exports = Object.assign({}, baseConfig, { |
|
||||||
ignore: [...baseConfig.ignore, './app/scripts/controllers/permissions/*.test.js'] |
|
||||||
}); |
|
After Width: | Height: | Size: 59 KiB |
@ -0,0 +1,6 @@ |
|||||||
|
import { addons } from '@storybook/addons'; |
||||||
|
import MetaMaskStorybookTheme from './metamask-storybook-theme'; |
||||||
|
|
||||||
|
addons.setConfig({ |
||||||
|
theme: MetaMaskStorybookTheme, |
||||||
|
}); |
@ -0,0 +1,14 @@ |
|||||||
|
// .storybook/YourTheme.js
|
||||||
|
|
||||||
|
import { create } from '@storybook/theming'; |
||||||
|
import logo from '../app/images/logo/metamask-logo-horizontal.svg'; |
||||||
|
|
||||||
|
export default create({ |
||||||
|
base: 'light', |
||||||
|
brandTitle: 'MetaMask Storybook', |
||||||
|
brandImage: logo, |
||||||
|
|
||||||
|
// Typography
|
||||||
|
fontBase: 'Euclid, Roboto, Helvetica, Arial, sans-serif', |
||||||
|
fontCode: 'Inconsolata, monospace', |
||||||
|
}); |
@ -0,0 +1,27 @@ |
|||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" /> |
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> |
||||||
|
<link |
||||||
|
href="https://fonts.googleapis.com/css2?family=Inconsolata&display=swap" |
||||||
|
rel="stylesheet" |
||||||
|
/> |
||||||
|
|
||||||
|
<style> |
||||||
|
* { |
||||||
|
--gray-pre-bg: #f8f8f8; |
||||||
|
--font-family-monospace: Inconsolata, monospace; |
||||||
|
--font-size-code: 0.875rem; |
||||||
|
} |
||||||
|
|
||||||
|
.docblock-source { |
||||||
|
background: var(--gray-pre-bg) !important; |
||||||
|
} |
||||||
|
|
||||||
|
.docblock-source code { |
||||||
|
font-family: var(--font-family-monospace) !important; |
||||||
|
font-size: var(--font-size-code) !important; |
||||||
|
} |
||||||
|
.docblock-source code * { |
||||||
|
font-family: var(--font-family-monospace) !important; |
||||||
|
font-size: var(--font-size-code) !important; |
||||||
|
} |
||||||
|
</style> |
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
After Width: | Height: | Size: 3.4 KiB |
After Width: | Height: | Size: 1.5 KiB |
After Width: | Height: | Size: 3.0 KiB |
@ -0,0 +1,71 @@ |
|||||||
|
import { |
||||||
|
CaveatTypes, |
||||||
|
RestrictedMethods, |
||||||
|
} from '../../../../shared/constants/permissions'; |
||||||
|
|
||||||
|
export function getPermissionBackgroundApiMethods(permissionController) { |
||||||
|
return { |
||||||
|
addPermittedAccount: (origin, account) => { |
||||||
|
const existing = permissionController.getCaveat( |
||||||
|
origin, |
||||||
|
RestrictedMethods.eth_accounts, |
||||||
|
CaveatTypes.restrictReturnedAccounts, |
||||||
|
); |
||||||
|
|
||||||
|
if (existing.value.includes(account)) { |
||||||
|
throw new Error( |
||||||
|
`eth_accounts permission for origin "${origin}" already permits account "${account}".`, |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
permissionController.updateCaveat( |
||||||
|
origin, |
||||||
|
RestrictedMethods.eth_accounts, |
||||||
|
CaveatTypes.restrictReturnedAccounts, |
||||||
|
[...existing.value, account], |
||||||
|
); |
||||||
|
}, |
||||||
|
|
||||||
|
removePermittedAccount: (origin, account) => { |
||||||
|
const existing = permissionController.getCaveat( |
||||||
|
origin, |
||||||
|
RestrictedMethods.eth_accounts, |
||||||
|
CaveatTypes.restrictReturnedAccounts, |
||||||
|
); |
||||||
|
|
||||||
|
if (!existing.value.includes(account)) { |
||||||
|
throw new Error( |
||||||
|
`eth_accounts permission for origin "${origin}" already does not permit account "${account}".`, |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
const remainingAccounts = existing.value.filter( |
||||||
|
(existingAccount) => existingAccount !== account, |
||||||
|
); |
||||||
|
|
||||||
|
if (remainingAccounts.length === 0) { |
||||||
|
permissionController.revokePermission( |
||||||
|
origin, |
||||||
|
RestrictedMethods.eth_accounts, |
||||||
|
); |
||||||
|
} else { |
||||||
|
permissionController.updateCaveat( |
||||||
|
origin, |
||||||
|
RestrictedMethods.eth_accounts, |
||||||
|
CaveatTypes.restrictReturnedAccounts, |
||||||
|
remainingAccounts, |
||||||
|
); |
||||||
|
} |
||||||
|
}, |
||||||
|
|
||||||
|
requestAccountsPermissionWithId: async (origin) => { |
||||||
|
const [, { id }] = await permissionController.requestPermissions( |
||||||
|
{ origin }, |
||||||
|
{ |
||||||
|
eth_accounts: {}, |
||||||
|
}, |
||||||
|
); |
||||||
|
return id; |
||||||
|
}, |
||||||
|
}; |
||||||
|
} |
@ -0,0 +1,181 @@ |
|||||||
|
import { |
||||||
|
CaveatTypes, |
||||||
|
RestrictedMethods, |
||||||
|
} from '../../../../shared/constants/permissions'; |
||||||
|
import { getPermissionBackgroundApiMethods } from './background-api'; |
||||||
|
|
||||||
|
describe('permission background API methods', () => { |
||||||
|
describe('addPermittedAccount', () => { |
||||||
|
it('adds a permitted account', () => { |
||||||
|
const permissionController = { |
||||||
|
getCaveat: jest.fn().mockImplementationOnce(() => { |
||||||
|
return { type: CaveatTypes.restrictReturnedAccounts, value: ['0x1'] }; |
||||||
|
}), |
||||||
|
updateCaveat: jest.fn(), |
||||||
|
}; |
||||||
|
|
||||||
|
getPermissionBackgroundApiMethods( |
||||||
|
permissionController, |
||||||
|
).addPermittedAccount('foo.com', '0x2'); |
||||||
|
|
||||||
|
expect(permissionController.getCaveat).toHaveBeenCalledTimes(1); |
||||||
|
expect(permissionController.getCaveat).toHaveBeenCalledWith( |
||||||
|
'foo.com', |
||||||
|
RestrictedMethods.eth_accounts, |
||||||
|
CaveatTypes.restrictReturnedAccounts, |
||||||
|
); |
||||||
|
|
||||||
|
expect(permissionController.updateCaveat).toHaveBeenCalledTimes(1); |
||||||
|
expect(permissionController.updateCaveat).toHaveBeenCalledWith( |
||||||
|
'foo.com', |
||||||
|
RestrictedMethods.eth_accounts, |
||||||
|
CaveatTypes.restrictReturnedAccounts, |
||||||
|
['0x1', '0x2'], |
||||||
|
); |
||||||
|
}); |
||||||
|
|
||||||
|
it('throws if the specified account is already permitted', () => { |
||||||
|
const permissionController = { |
||||||
|
getCaveat: jest.fn().mockImplementationOnce(() => { |
||||||
|
return { type: CaveatTypes.restrictReturnedAccounts, value: ['0x1'] }; |
||||||
|
}), |
||||||
|
updateCaveat: jest.fn(), |
||||||
|
}; |
||||||
|
|
||||||
|
expect(() => |
||||||
|
getPermissionBackgroundApiMethods( |
||||||
|
permissionController, |
||||||
|
).addPermittedAccount('foo.com', '0x1'), |
||||||
|
).toThrow( |
||||||
|
`eth_accounts permission for origin "foo.com" already permits account "0x1".`, |
||||||
|
); |
||||||
|
|
||||||
|
expect(permissionController.getCaveat).toHaveBeenCalledTimes(1); |
||||||
|
expect(permissionController.getCaveat).toHaveBeenCalledWith( |
||||||
|
'foo.com', |
||||||
|
RestrictedMethods.eth_accounts, |
||||||
|
CaveatTypes.restrictReturnedAccounts, |
||||||
|
); |
||||||
|
|
||||||
|
expect(permissionController.updateCaveat).not.toHaveBeenCalled(); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('removePermittedAccount', () => { |
||||||
|
it('removes a permitted account', () => { |
||||||
|
const permissionController = { |
||||||
|
getCaveat: jest.fn().mockImplementationOnce(() => { |
||||||
|
return { |
||||||
|
type: CaveatTypes.restrictReturnedAccounts, |
||||||
|
value: ['0x1', '0x2'], |
||||||
|
}; |
||||||
|
}), |
||||||
|
revokePermission: jest.fn(), |
||||||
|
updateCaveat: jest.fn(), |
||||||
|
}; |
||||||
|
|
||||||
|
getPermissionBackgroundApiMethods( |
||||||
|
permissionController, |
||||||
|
).removePermittedAccount('foo.com', '0x2'); |
||||||
|
|
||||||
|
expect(permissionController.getCaveat).toHaveBeenCalledTimes(1); |
||||||
|
expect(permissionController.getCaveat).toHaveBeenCalledWith( |
||||||
|
'foo.com', |
||||||
|
RestrictedMethods.eth_accounts, |
||||||
|
CaveatTypes.restrictReturnedAccounts, |
||||||
|
); |
||||||
|
|
||||||
|
expect(permissionController.revokePermission).not.toHaveBeenCalled(); |
||||||
|
|
||||||
|
expect(permissionController.updateCaveat).toHaveBeenCalledTimes(1); |
||||||
|
expect(permissionController.updateCaveat).toHaveBeenCalledWith( |
||||||
|
'foo.com', |
||||||
|
RestrictedMethods.eth_accounts, |
||||||
|
CaveatTypes.restrictReturnedAccounts, |
||||||
|
['0x1'], |
||||||
|
); |
||||||
|
}); |
||||||
|
|
||||||
|
it('revokes the accounts permission if the removed account is the only permitted account', () => { |
||||||
|
const permissionController = { |
||||||
|
getCaveat: jest.fn().mockImplementationOnce(() => { |
||||||
|
return { |
||||||
|
type: CaveatTypes.restrictReturnedAccounts, |
||||||
|
value: ['0x1'], |
||||||
|
}; |
||||||
|
}), |
||||||
|
revokePermission: jest.fn(), |
||||||
|
updateCaveat: jest.fn(), |
||||||
|
}; |
||||||
|
|
||||||
|
getPermissionBackgroundApiMethods( |
||||||
|
permissionController, |
||||||
|
).removePermittedAccount('foo.com', '0x1'); |
||||||
|
|
||||||
|
expect(permissionController.getCaveat).toHaveBeenCalledTimes(1); |
||||||
|
expect(permissionController.getCaveat).toHaveBeenCalledWith( |
||||||
|
'foo.com', |
||||||
|
RestrictedMethods.eth_accounts, |
||||||
|
CaveatTypes.restrictReturnedAccounts, |
||||||
|
); |
||||||
|
|
||||||
|
expect(permissionController.revokePermission).toHaveBeenCalledTimes(1); |
||||||
|
expect(permissionController.revokePermission).toHaveBeenCalledWith( |
||||||
|
'foo.com', |
||||||
|
RestrictedMethods.eth_accounts, |
||||||
|
); |
||||||
|
|
||||||
|
expect(permissionController.updateCaveat).not.toHaveBeenCalled(); |
||||||
|
}); |
||||||
|
|
||||||
|
it('throws if the specified account is not permitted', () => { |
||||||
|
const permissionController = { |
||||||
|
getCaveat: jest.fn().mockImplementationOnce(() => { |
||||||
|
return { type: CaveatTypes.restrictReturnedAccounts, value: ['0x1'] }; |
||||||
|
}), |
||||||
|
revokePermission: jest.fn(), |
||||||
|
updateCaveat: jest.fn(), |
||||||
|
}; |
||||||
|
|
||||||
|
expect(() => |
||||||
|
getPermissionBackgroundApiMethods( |
||||||
|
permissionController, |
||||||
|
).removePermittedAccount('foo.com', '0x2'), |
||||||
|
).toThrow( |
||||||
|
`eth_accounts permission for origin "foo.com" already does not permit account "0x2".`, |
||||||
|
); |
||||||
|
|
||||||
|
expect(permissionController.getCaveat).toHaveBeenCalledTimes(1); |
||||||
|
expect(permissionController.getCaveat).toHaveBeenCalledWith( |
||||||
|
'foo.com', |
||||||
|
RestrictedMethods.eth_accounts, |
||||||
|
CaveatTypes.restrictReturnedAccounts, |
||||||
|
); |
||||||
|
|
||||||
|
expect(permissionController.revokePermission).not.toHaveBeenCalled(); |
||||||
|
expect(permissionController.updateCaveat).not.toHaveBeenCalled(); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('requestAccountsPermissionWithId', () => { |
||||||
|
it('request an accounts permission and returns the request id', async () => { |
||||||
|
const permissionController = { |
||||||
|
requestPermissions: jest.fn().mockImplementationOnce(async () => { |
||||||
|
return [null, { id: 'arbitraryId' }]; |
||||||
|
}), |
||||||
|
}; |
||||||
|
|
||||||
|
const id = await getPermissionBackgroundApiMethods( |
||||||
|
permissionController, |
||||||
|
).requestAccountsPermissionWithId('foo.com'); |
||||||
|
|
||||||
|
expect(permissionController.requestPermissions).toHaveBeenCalledTimes(1); |
||||||
|
expect(permissionController.requestPermissions).toHaveBeenCalledWith( |
||||||
|
{ origin: 'foo.com' }, |
||||||
|
{ eth_accounts: {} }, |
||||||
|
); |
||||||
|
|
||||||
|
expect(id).toStrictEqual('arbitraryId'); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
@ -0,0 +1,39 @@ |
|||||||
|
import { CaveatMutatorOperation } from '@metamask/snap-controllers'; |
||||||
|
import { CaveatTypes } from '../../../../shared/constants/permissions'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Factories that construct caveat mutator functions that are passed to |
||||||
|
* PermissionController.updatePermissionsByCaveat. |
||||||
|
*/ |
||||||
|
export const CaveatMutatorFactories = { |
||||||
|
[CaveatTypes.restrictReturnedAccounts]: { |
||||||
|
removeAccount, |
||||||
|
}, |
||||||
|
}; |
||||||
|
|
||||||
|
/** |
||||||
|
* Removes the target account from the value arrays of all |
||||||
|
* `restrictReturnedAccounts` caveats. No-ops if the target account is not in |
||||||
|
* the array, and revokes the parent permission if it's the only account in |
||||||
|
* the array. |
||||||
|
* |
||||||
|
* @param {string} targetAccount - The address of the account to remove from |
||||||
|
* all accounts permissions. |
||||||
|
* @param {string[]} existingAccounts - The account address array from the |
||||||
|
* account permissions. |
||||||
|
*/ |
||||||
|
function removeAccount(targetAccount, existingAccounts) { |
||||||
|
const newAccounts = existingAccounts.filter( |
||||||
|
(address) => address !== targetAccount, |
||||||
|
); |
||||||
|
|
||||||
|
if (newAccounts.length === existingAccounts.length) { |
||||||
|
return { operation: CaveatMutatorOperation.noop }; |
||||||
|
} else if (newAccounts.length > 0) { |
||||||
|
return { |
||||||
|
operation: CaveatMutatorOperation.updateValue, |
||||||
|
value: newAccounts, |
||||||
|
}; |
||||||
|
} |
||||||
|
return { operation: CaveatMutatorOperation.revokePermission }; |
||||||
|
} |
@ -0,0 +1,32 @@ |
|||||||
|
import { CaveatMutatorOperation } from '@metamask/snap-controllers'; |
||||||
|
import { CaveatTypes } from '../../../../shared/constants/permissions'; |
||||||
|
import { CaveatMutatorFactories } from './caveat-mutators'; |
||||||
|
|
||||||
|
describe('caveat mutators', () => { |
||||||
|
describe('restrictReturnedAccounts', () => { |
||||||
|
const { removeAccount } = CaveatMutatorFactories[ |
||||||
|
CaveatTypes.restrictReturnedAccounts |
||||||
|
]; |
||||||
|
|
||||||
|
describe('removeAccount', () => { |
||||||
|
it('returns the no-op operation if the target account is not permitted', () => { |
||||||
|
expect(removeAccount('0x2', ['0x1'])).toStrictEqual({ |
||||||
|
operation: CaveatMutatorOperation.noop, |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
it('returns the update operation and a new value if the target account is permitted', () => { |
||||||
|
expect(removeAccount('0x2', ['0x1', '0x2'])).toStrictEqual({ |
||||||
|
operation: CaveatMutatorOperation.updateValue, |
||||||
|
value: ['0x1'], |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
it('returns the revoke permission operation the target account is the only permitted account', () => { |
||||||
|
expect(removeAccount('0x1', ['0x1'])).toStrictEqual({ |
||||||
|
operation: CaveatMutatorOperation.revokePermission, |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
@ -1,718 +1,6 @@ |
|||||||
import nanoid from 'nanoid'; |
export * from './caveat-mutators'; |
||||||
import { JsonRpcEngine } from 'json-rpc-engine'; |
export * from './background-api'; |
||||||
import { ObservableStore } from '@metamask/obs-store'; |
export * from './enums'; |
||||||
import log from 'loglevel'; |
export * from './permission-log'; |
||||||
import { CapabilitiesController as RpcCap } from 'rpc-cap'; |
export * from './specifications'; |
||||||
import { ethErrors } from 'eth-rpc-errors'; |
export * from './selectors'; |
||||||
import { cloneDeep } from 'lodash'; |
|
||||||
|
|
||||||
import { CAVEAT_NAMES } from '../../../../shared/constants/permissions'; |
|
||||||
import { |
|
||||||
APPROVAL_TYPE, |
|
||||||
SAFE_METHODS, // methods that do not require any permissions to use
|
|
||||||
WALLET_PREFIX, |
|
||||||
METADATA_STORE_KEY, |
|
||||||
METADATA_CACHE_MAX_SIZE, |
|
||||||
LOG_STORE_KEY, |
|
||||||
HISTORY_STORE_KEY, |
|
||||||
NOTIFICATION_NAMES, |
|
||||||
CAVEAT_TYPES, |
|
||||||
} from './enums'; |
|
||||||
|
|
||||||
import createPermissionsMethodMiddleware from './permissionsMethodMiddleware'; |
|
||||||
import PermissionsLogController from './permissionsLog'; |
|
||||||
|
|
||||||
// instanbul ignore next
|
|
||||||
const noop = () => undefined; |
|
||||||
|
|
||||||
export class PermissionsController { |
|
||||||
constructor( |
|
||||||
{ |
|
||||||
approvals, |
|
||||||
getKeyringAccounts, |
|
||||||
getRestrictedMethods, |
|
||||||
getUnlockPromise, |
|
||||||
isUnlocked, |
|
||||||
notifyDomain, |
|
||||||
notifyAllDomains, |
|
||||||
preferences, |
|
||||||
} = {}, |
|
||||||
restoredPermissions = {}, |
|
||||||
restoredState = {}, |
|
||||||
) { |
|
||||||
// additional top-level store key set in _initializeMetadataStore
|
|
||||||
this.store = new ObservableStore({ |
|
||||||
[LOG_STORE_KEY]: restoredState[LOG_STORE_KEY] || [], |
|
||||||
[HISTORY_STORE_KEY]: restoredState[HISTORY_STORE_KEY] || {}, |
|
||||||
}); |
|
||||||
|
|
||||||
this.getKeyringAccounts = getKeyringAccounts; |
|
||||||
this._getUnlockPromise = getUnlockPromise; |
|
||||||
this._notifyDomain = notifyDomain; |
|
||||||
this._notifyAllDomains = notifyAllDomains; |
|
||||||
this._isUnlocked = isUnlocked; |
|
||||||
|
|
||||||
this._restrictedMethods = getRestrictedMethods({ |
|
||||||
getKeyringAccounts: this.getKeyringAccounts.bind(this), |
|
||||||
getIdentities: this._getIdentities.bind(this), |
|
||||||
}); |
|
||||||
this.permissionsLog = new PermissionsLogController({ |
|
||||||
restrictedMethods: Object.keys(this._restrictedMethods), |
|
||||||
store: this.store, |
|
||||||
}); |
|
||||||
|
|
||||||
/** |
|
||||||
* @type {import('@metamask/controllers').ApprovalController} |
|
||||||
* @public |
|
||||||
*/ |
|
||||||
this.approvals = approvals; |
|
||||||
this._initializePermissions(restoredPermissions); |
|
||||||
this._lastSelectedAddress = preferences.getState().selectedAddress; |
|
||||||
this.preferences = preferences; |
|
||||||
|
|
||||||
this._initializeMetadataStore(restoredState); |
|
||||||
|
|
||||||
preferences.subscribe(async ({ selectedAddress }) => { |
|
||||||
if (selectedAddress && selectedAddress !== this._lastSelectedAddress) { |
|
||||||
this._lastSelectedAddress = selectedAddress; |
|
||||||
await this._handleAccountSelected(selectedAddress); |
|
||||||
} |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
createMiddleware({ origin, extensionId }) { |
|
||||||
if (typeof origin !== 'string' || !origin.length) { |
|
||||||
throw new Error('Must provide non-empty string origin.'); |
|
||||||
} |
|
||||||
|
|
||||||
const metadataState = this.store.getState()[METADATA_STORE_KEY]; |
|
||||||
|
|
||||||
if (extensionId && metadataState[origin]?.extensionId !== extensionId) { |
|
||||||
this.addDomainMetadata(origin, { extensionId }); |
|
||||||
} |
|
||||||
|
|
||||||
const engine = new JsonRpcEngine(); |
|
||||||
|
|
||||||
engine.push(this.permissionsLog.createMiddleware()); |
|
||||||
|
|
||||||
engine.push( |
|
||||||
createPermissionsMethodMiddleware({ |
|
||||||
addDomainMetadata: this.addDomainMetadata.bind(this), |
|
||||||
getAccounts: this.getAccounts.bind(this, origin), |
|
||||||
getUnlockPromise: () => this._getUnlockPromise(true), |
|
||||||
hasPermission: this.hasPermission.bind(this, origin), |
|
||||||
notifyAccountsChanged: this.notifyAccountsChanged.bind(this, origin), |
|
||||||
requestAccountsPermission: this._requestPermissions.bind( |
|
||||||
this, |
|
||||||
{ origin }, |
|
||||||
{ eth_accounts: {} }, |
|
||||||
), |
|
||||||
}), |
|
||||||
); |
|
||||||
|
|
||||||
engine.push( |
|
||||||
this.permissions.providerMiddlewareFunction.bind(this.permissions, { |
|
||||||
origin, |
|
||||||
}), |
|
||||||
); |
|
||||||
|
|
||||||
return engine.asMiddleware(); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Request {@code eth_accounts} permissions |
|
||||||
* @param {string} origin - The requesting origin |
|
||||||
* @returns {Promise<string>} The permissions request ID |
|
||||||
*/ |
|
||||||
async requestAccountsPermissionWithId(origin) { |
|
||||||
const id = nanoid(); |
|
||||||
this._requestPermissions({ origin }, { eth_accounts: {} }, id).then( |
|
||||||
async () => { |
|
||||||
const permittedAccounts = await this.getAccounts(origin); |
|
||||||
this.notifyAccountsChanged(origin, permittedAccounts); |
|
||||||
}, |
|
||||||
); |
|
||||||
return id; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Returns the accounts that should be exposed for the given origin domain, |
|
||||||
* if any. This method exists for when a trusted context needs to know |
|
||||||
* which accounts are exposed to a given domain. |
|
||||||
* |
|
||||||
* @param {string} origin - The origin string. |
|
||||||
*/ |
|
||||||
getAccounts(origin) { |
|
||||||
return new Promise((resolve, _) => { |
|
||||||
const req = { method: 'eth_accounts' }; |
|
||||||
const res = {}; |
|
||||||
this.permissions.providerMiddlewareFunction( |
|
||||||
{ origin }, |
|
||||||
req, |
|
||||||
res, |
|
||||||
noop, |
|
||||||
_end, |
|
||||||
); |
|
||||||
|
|
||||||
function _end() { |
|
||||||
if (res.error || !Array.isArray(res.result)) { |
|
||||||
resolve([]); |
|
||||||
} else { |
|
||||||
resolve(res.result); |
|
||||||
} |
|
||||||
} |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Returns whether the given origin has the given permission. |
|
||||||
* |
|
||||||
* @param {string} origin - The origin to check. |
|
||||||
* @param {string} permission - The permission to check for. |
|
||||||
* @returns {boolean} Whether the origin has the permission. |
|
||||||
*/ |
|
||||||
hasPermission(origin, permission) { |
|
||||||
return Boolean(this.permissions.getPermission(origin, permission)); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Gets the identities from the preferences controller store |
|
||||||
* |
|
||||||
* @returns {Object} identities |
|
||||||
*/ |
|
||||||
_getIdentities() { |
|
||||||
return this.preferences.getState().identities; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Submits a permissions request to rpc-cap. Internal, background use only. |
|
||||||
* |
|
||||||
* @param {IOriginMetadata} domain - The external domain metadata. |
|
||||||
* @param {IRequestedPermissions} permissions - The requested permissions. |
|
||||||
* @param {string} [id] - The desired id of the permissions request, if any. |
|
||||||
* @returns {Promise<IOcapLdCapability[]>} A Promise that resolves with the |
|
||||||
* approved permissions, or rejects with an error. |
|
||||||
*/ |
|
||||||
_requestPermissions(domain, permissions, id) { |
|
||||||
return new Promise((resolve, reject) => { |
|
||||||
// rpc-cap assigns an id to the request if there is none, as expected by
|
|
||||||
// requestUserApproval below
|
|
||||||
const req = { |
|
||||||
id, |
|
||||||
method: 'wallet_requestPermissions', |
|
||||||
params: [permissions], |
|
||||||
}; |
|
||||||
const res = {}; |
|
||||||
|
|
||||||
this.permissions.providerMiddlewareFunction(domain, req, res, noop, _end); |
|
||||||
|
|
||||||
function _end(_err) { |
|
||||||
const err = _err || res.error; |
|
||||||
if (err) { |
|
||||||
reject(err); |
|
||||||
} else { |
|
||||||
resolve(res.result); |
|
||||||
} |
|
||||||
} |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* User approval callback. Resolves the Promise for the permissions request |
|
||||||
* waited upon by rpc-cap, see requestUserApproval in _initializePermissions. |
|
||||||
* The request will be rejected if finalizePermissionsRequest fails. |
|
||||||
* Idempotent for a given request id. |
|
||||||
* |
|
||||||
* @param {Object} approved - The request object approved by the user |
|
||||||
* @param {Array} accounts - The accounts to expose, if any |
|
||||||
*/ |
|
||||||
async approvePermissionsRequest(approved, accounts) { |
|
||||||
const { id } = approved.metadata; |
|
||||||
|
|
||||||
if (!this.approvals.has({ id })) { |
|
||||||
log.debug(`Permissions request with id '${id}' not found.`); |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
try { |
|
||||||
if (Object.keys(approved.permissions).length === 0) { |
|
||||||
this.approvals.reject( |
|
||||||
id, |
|
||||||
ethErrors.rpc.invalidRequest({ |
|
||||||
message: 'Must request at least one permission.', |
|
||||||
}), |
|
||||||
); |
|
||||||
} else { |
|
||||||
// attempt to finalize the request and resolve it,
|
|
||||||
// settings caveats as necessary
|
|
||||||
approved.permissions = await this.finalizePermissionsRequest( |
|
||||||
approved.permissions, |
|
||||||
accounts, |
|
||||||
); |
|
||||||
this.approvals.accept(id, approved.permissions); |
|
||||||
} |
|
||||||
} catch (err) { |
|
||||||
// if finalization fails, reject the request
|
|
||||||
this.approvals.reject( |
|
||||||
id, |
|
||||||
ethErrors.rpc.invalidRequest({ |
|
||||||
message: err.message, |
|
||||||
data: err, |
|
||||||
}), |
|
||||||
); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* User rejection callback. Rejects the Promise for the permissions request |
|
||||||
* waited upon by rpc-cap, see requestUserApproval in _initializePermissions. |
|
||||||
* Idempotent for a given id. |
|
||||||
* |
|
||||||
* @param {string} id - The id of the request rejected by the user |
|
||||||
*/ |
|
||||||
async rejectPermissionsRequest(id) { |
|
||||||
if (!this.approvals.has({ id })) { |
|
||||||
log.debug(`Permissions request with id '${id}' not found.`); |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
this.approvals.reject(id, ethErrors.provider.userRejectedRequest()); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Expose an account to the given origin. Changes the eth_accounts |
|
||||||
* permissions and emits accountsChanged. |
|
||||||
* |
|
||||||
* Throws error if the origin or account is invalid, or if the update fails. |
|
||||||
* |
|
||||||
* @param {string} origin - The origin to expose the account to. |
|
||||||
* @param {string} account - The new account to expose. |
|
||||||
*/ |
|
||||||
async addPermittedAccount(origin, account) { |
|
||||||
const domains = this.permissions.getDomains(); |
|
||||||
if (!domains[origin]) { |
|
||||||
throw new Error('Unrecognized domain'); |
|
||||||
} |
|
||||||
|
|
||||||
this.validatePermittedAccounts([account]); |
|
||||||
|
|
||||||
const oldPermittedAccounts = this._getPermittedAccounts(origin); |
|
||||||
if (oldPermittedAccounts.length === 0) { |
|
||||||
throw new Error(`Origin does not have 'eth_accounts' permission`); |
|
||||||
} else if (oldPermittedAccounts.includes(account)) { |
|
||||||
throw new Error('Account is already permitted for origin'); |
|
||||||
} |
|
||||||
|
|
||||||
this.permissions.updateCaveatFor( |
|
||||||
origin, |
|
||||||
'eth_accounts', |
|
||||||
CAVEAT_NAMES.exposedAccounts, |
|
||||||
[...oldPermittedAccounts, account], |
|
||||||
); |
|
||||||
|
|
||||||
const permittedAccounts = await this.getAccounts(origin); |
|
||||||
|
|
||||||
this.notifyAccountsChanged(origin, permittedAccounts); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Removes an exposed account from the given origin. Changes the eth_accounts |
|
||||||
* permission and emits accountsChanged. |
|
||||||
* If origin only has a single permitted account, removes the eth_accounts |
|
||||||
* permission from the origin. |
|
||||||
* |
|
||||||
* Throws error if the origin or account is invalid, or if the update fails. |
|
||||||
* |
|
||||||
* @param {string} origin - The origin to remove the account from. |
|
||||||
* @param {string} account - The account to remove. |
|
||||||
*/ |
|
||||||
async removePermittedAccount(origin, account) { |
|
||||||
const domains = this.permissions.getDomains(); |
|
||||||
if (!domains[origin]) { |
|
||||||
throw new Error('Unrecognized domain'); |
|
||||||
} |
|
||||||
|
|
||||||
this.validatePermittedAccounts([account]); |
|
||||||
|
|
||||||
const oldPermittedAccounts = this._getPermittedAccounts(origin); |
|
||||||
if (oldPermittedAccounts.length === 0) { |
|
||||||
throw new Error(`Origin does not have 'eth_accounts' permission`); |
|
||||||
} else if (!oldPermittedAccounts.includes(account)) { |
|
||||||
throw new Error('Account is not permitted for origin'); |
|
||||||
} |
|
||||||
|
|
||||||
let newPermittedAccounts = oldPermittedAccounts.filter( |
|
||||||
(acc) => acc !== account, |
|
||||||
); |
|
||||||
|
|
||||||
if (newPermittedAccounts.length === 0) { |
|
||||||
this.removePermissionsFor({ [origin]: ['eth_accounts'] }); |
|
||||||
} else { |
|
||||||
this.permissions.updateCaveatFor( |
|
||||||
origin, |
|
||||||
'eth_accounts', |
|
||||||
CAVEAT_NAMES.exposedAccounts, |
|
||||||
newPermittedAccounts, |
|
||||||
); |
|
||||||
|
|
||||||
newPermittedAccounts = await this.getAccounts(origin); |
|
||||||
} |
|
||||||
|
|
||||||
this.notifyAccountsChanged(origin, newPermittedAccounts); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Remove all permissions associated with a particular account. Any eth_accounts |
|
||||||
* permissions left with no permitted accounts will be removed as well. |
|
||||||
* |
|
||||||
* Throws error if the account is invalid, or if the update fails. |
|
||||||
* |
|
||||||
* @param {string} account - The account to remove. |
|
||||||
*/ |
|
||||||
async removeAllAccountPermissions(account) { |
|
||||||
this.validatePermittedAccounts([account]); |
|
||||||
|
|
||||||
const domains = this.permissions.getDomains(); |
|
||||||
const connectedOrigins = Object.keys(domains).filter((origin) => |
|
||||||
this._getPermittedAccounts(origin).includes(account), |
|
||||||
); |
|
||||||
|
|
||||||
await Promise.all( |
|
||||||
connectedOrigins.map((origin) => |
|
||||||
this.removePermittedAccount(origin, account), |
|
||||||
), |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Finalizes a permissions request. Throws if request validation fails. |
|
||||||
* Clones the passed-in parameters to prevent inadvertent modification. |
|
||||||
* Sets (adds or replaces) caveats for the following permissions: |
|
||||||
* - eth_accounts: the permitted accounts caveat |
|
||||||
* |
|
||||||
* @param {Object} requestedPermissions - The requested permissions. |
|
||||||
* @param {string[]} requestedAccounts - The accounts to expose, if any. |
|
||||||
* @returns {Object} The finalized permissions request object. |
|
||||||
*/ |
|
||||||
async finalizePermissionsRequest(requestedPermissions, requestedAccounts) { |
|
||||||
const finalizedPermissions = cloneDeep(requestedPermissions); |
|
||||||
const finalizedAccounts = cloneDeep(requestedAccounts); |
|
||||||
|
|
||||||
const { eth_accounts: ethAccounts } = finalizedPermissions; |
|
||||||
|
|
||||||
if (ethAccounts) { |
|
||||||
this.validatePermittedAccounts(finalizedAccounts); |
|
||||||
|
|
||||||
if (!ethAccounts.caveats) { |
|
||||||
ethAccounts.caveats = []; |
|
||||||
} |
|
||||||
|
|
||||||
// caveat names are unique, and we will only construct this caveat here
|
|
||||||
ethAccounts.caveats = ethAccounts.caveats.filter( |
|
||||||
(c) => |
|
||||||
c.name !== CAVEAT_NAMES.exposedAccounts && |
|
||||||
c.name !== CAVEAT_NAMES.primaryAccountOnly, |
|
||||||
); |
|
||||||
|
|
||||||
ethAccounts.caveats.push({ |
|
||||||
type: CAVEAT_TYPES.limitResponseLength, |
|
||||||
value: 1, |
|
||||||
name: CAVEAT_NAMES.primaryAccountOnly, |
|
||||||
}); |
|
||||||
|
|
||||||
ethAccounts.caveats.push({ |
|
||||||
type: CAVEAT_TYPES.filterResponse, |
|
||||||
value: finalizedAccounts, |
|
||||||
name: CAVEAT_NAMES.exposedAccounts, |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
return finalizedPermissions; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Validate an array of accounts representing accounts to be exposed |
|
||||||
* to a domain. Throws error if validation fails. |
|
||||||
* |
|
||||||
* @param {string[]} accounts - An array of addresses. |
|
||||||
*/ |
|
||||||
validatePermittedAccounts(accounts) { |
|
||||||
if (!Array.isArray(accounts) || accounts.length === 0) { |
|
||||||
throw new Error('Must provide non-empty array of account(s).'); |
|
||||||
} |
|
||||||
|
|
||||||
// assert accounts exist
|
|
||||||
const allIdentities = this._getIdentities(); |
|
||||||
accounts.forEach((acc) => { |
|
||||||
if (!allIdentities[acc]) { |
|
||||||
throw new Error(`Unknown account: ${acc}`); |
|
||||||
} |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Notify a domain that its permitted accounts have changed. |
|
||||||
* Also updates the accounts history log. |
|
||||||
* |
|
||||||
* @param {string} origin - The origin of the domain to notify. |
|
||||||
* @param {Array<string>} newAccounts - The currently permitted accounts. |
|
||||||
*/ |
|
||||||
notifyAccountsChanged(origin, newAccounts) { |
|
||||||
if (typeof origin !== 'string' || !origin) { |
|
||||||
throw new Error(`Invalid origin: '${origin}'`); |
|
||||||
} |
|
||||||
|
|
||||||
if (!Array.isArray(newAccounts)) { |
|
||||||
throw new Error('Invalid accounts', newAccounts); |
|
||||||
} |
|
||||||
|
|
||||||
// We do not share accounts when the extension is locked.
|
|
||||||
if (this._isUnlocked()) { |
|
||||||
this._notifyDomain(origin, { |
|
||||||
method: NOTIFICATION_NAMES.accountsChanged, |
|
||||||
params: newAccounts, |
|
||||||
}); |
|
||||||
this.permissionsLog.updateAccountsHistory(origin, newAccounts); |
|
||||||
} |
|
||||||
|
|
||||||
// NOTE:
|
|
||||||
// We don't check for accounts changing in the notifyAllDomains case,
|
|
||||||
// because the log only records when accounts were last seen, and the
|
|
||||||
// the accounts only change for all domains at once when permissions are
|
|
||||||
// removed.
|
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Removes the given permissions for the given domain. |
|
||||||
* Should only be called after confirming that the permissions exist, to |
|
||||||
* avoid sending unnecessary notifications. |
|
||||||
* |
|
||||||
* @param {Object} domains - The map of domain origins to permissions to remove. |
|
||||||
* e.g. { origin: [permissions] } |
|
||||||
*/ |
|
||||||
removePermissionsFor(domains) { |
|
||||||
Object.entries(domains).forEach(([origin, perms]) => { |
|
||||||
this.permissions.removePermissionsFor( |
|
||||||
origin, |
|
||||||
perms.map((methodName) => { |
|
||||||
if (methodName === 'eth_accounts') { |
|
||||||
this.notifyAccountsChanged(origin, []); |
|
||||||
} |
|
||||||
|
|
||||||
return { parentCapability: methodName }; |
|
||||||
}), |
|
||||||
); |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Removes all known domains and their related permissions. |
|
||||||
*/ |
|
||||||
clearPermissions() { |
|
||||||
this.permissions.clearDomains(); |
|
||||||
// It's safe to notify that no accounts are available, regardless of
|
|
||||||
// extension lock state
|
|
||||||
this._notifyAllDomains({ |
|
||||||
method: NOTIFICATION_NAMES.accountsChanged, |
|
||||||
params: [], |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Stores domain metadata for the given origin (domain). |
|
||||||
* Deletes metadata for domains without permissions in a FIFO manner, once |
|
||||||
* more than 100 distinct origins have been added since boot. |
|
||||||
* Metadata is never deleted for domains with permissions, to prevent a |
|
||||||
* degraded user experience, since metadata cannot yet be requested on demand. |
|
||||||
* |
|
||||||
* @param {string} origin - The origin whose domain metadata to store. |
|
||||||
* @param {Object} metadata - The domain's metadata that will be stored. |
|
||||||
*/ |
|
||||||
addDomainMetadata(origin, metadata) { |
|
||||||
const oldMetadataState = this.store.getState()[METADATA_STORE_KEY]; |
|
||||||
const newMetadataState = { ...oldMetadataState }; |
|
||||||
|
|
||||||
// delete pending metadata origin from queue, and delete its metadata if
|
|
||||||
// it doesn't have any permissions
|
|
||||||
if (this._pendingSiteMetadata.size >= METADATA_CACHE_MAX_SIZE) { |
|
||||||
const permissionsDomains = this.permissions.getDomains(); |
|
||||||
|
|
||||||
const oldOrigin = this._pendingSiteMetadata.values().next().value; |
|
||||||
this._pendingSiteMetadata.delete(oldOrigin); |
|
||||||
if (!permissionsDomains[oldOrigin]) { |
|
||||||
delete newMetadataState[oldOrigin]; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// add new metadata to store after popping
|
|
||||||
newMetadataState[origin] = { |
|
||||||
...oldMetadataState[origin], |
|
||||||
...metadata, |
|
||||||
lastUpdated: Date.now(), |
|
||||||
}; |
|
||||||
|
|
||||||
if ( |
|
||||||
!newMetadataState[origin].extensionId && |
|
||||||
!newMetadataState[origin].host |
|
||||||
) { |
|
||||||
newMetadataState[origin].host = new URL(origin).host; |
|
||||||
} |
|
||||||
|
|
||||||
this._pendingSiteMetadata.add(origin); |
|
||||||
this._setDomainMetadata(newMetadataState); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Removes all domains without permissions from the restored metadata state, |
|
||||||
* and rehydrates the metadata store. |
|
||||||
* |
|
||||||
* Requires PermissionsController._initializePermissions to have been called first. |
|
||||||
* |
|
||||||
* @param {Object} restoredState - The restored permissions controller state. |
|
||||||
*/ |
|
||||||
_initializeMetadataStore(restoredState) { |
|
||||||
const metadataState = restoredState[METADATA_STORE_KEY] || {}; |
|
||||||
const newMetadataState = this._trimDomainMetadata(metadataState); |
|
||||||
|
|
||||||
this._pendingSiteMetadata = new Set(); |
|
||||||
this._setDomainMetadata(newMetadataState); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Trims the given metadataState object by removing metadata for all origins |
|
||||||
* without permissions. |
|
||||||
* Returns a new object; does not mutate the argument. |
|
||||||
* |
|
||||||
* @param {Object} metadataState - The metadata store state object to trim. |
|
||||||
* @returns {Object} The new metadata state object. |
|
||||||
*/ |
|
||||||
_trimDomainMetadata(metadataState) { |
|
||||||
const newMetadataState = { ...metadataState }; |
|
||||||
const origins = Object.keys(metadataState); |
|
||||||
const permissionsDomains = this.permissions.getDomains(); |
|
||||||
|
|
||||||
origins.forEach((origin) => { |
|
||||||
if (!permissionsDomains[origin]) { |
|
||||||
delete newMetadataState[origin]; |
|
||||||
} |
|
||||||
}); |
|
||||||
|
|
||||||
return newMetadataState; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Replaces the existing domain metadata with the passed-in object. |
|
||||||
* @param {Object} newMetadataState - The new metadata to set. |
|
||||||
*/ |
|
||||||
_setDomainMetadata(newMetadataState) { |
|
||||||
this.store.updateState({ [METADATA_STORE_KEY]: newMetadataState }); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Get current set of permitted accounts for the given origin |
|
||||||
* |
|
||||||
* @param {string} origin - The origin to obtain permitted accounts for |
|
||||||
* @returns {Array<string>} The list of permitted accounts |
|
||||||
*/ |
|
||||||
_getPermittedAccounts(origin) { |
|
||||||
const permittedAccounts = this.permissions |
|
||||||
.getPermission(origin, 'eth_accounts') |
|
||||||
?.caveats?.find((caveat) => caveat.name === CAVEAT_NAMES.exposedAccounts) |
|
||||||
?.value; |
|
||||||
|
|
||||||
return permittedAccounts || []; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* When a new account is selected in the UI, emit accountsChanged to each origin |
|
||||||
* where the selected account is exposed. |
|
||||||
* |
|
||||||
* Note: This will emit "false positive" accountsChanged events, but they are |
|
||||||
* handled by the inpage provider. |
|
||||||
* |
|
||||||
* @param {string} account - The newly selected account's address. |
|
||||||
*/ |
|
||||||
async _handleAccountSelected(account) { |
|
||||||
if (typeof account !== 'string') { |
|
||||||
throw new Error('Selected account should be a non-empty string.'); |
|
||||||
} |
|
||||||
|
|
||||||
const domains = this.permissions.getDomains() || {}; |
|
||||||
const connectedDomains = Object.entries(domains) |
|
||||||
.filter(([_, { permissions }]) => { |
|
||||||
const ethAccounts = permissions.find( |
|
||||||
(permission) => permission.parentCapability === 'eth_accounts', |
|
||||||
); |
|
||||||
const exposedAccounts = ethAccounts?.caveats.find( |
|
||||||
(caveat) => caveat.name === 'exposedAccounts', |
|
||||||
)?.value; |
|
||||||
return exposedAccounts?.includes(account); |
|
||||||
}) |
|
||||||
.map(([domain]) => domain); |
|
||||||
|
|
||||||
await Promise.all( |
|
||||||
connectedDomains.map((origin) => |
|
||||||
this._handleConnectedAccountSelected(origin), |
|
||||||
), |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* When a new account is selected in the UI, emit accountsChanged to 'origin' |
|
||||||
* |
|
||||||
* Note: This will emit "false positive" accountsChanged events, but they are |
|
||||||
* handled by the inpage provider. |
|
||||||
* |
|
||||||
* @param {string} origin - The origin |
|
||||||
*/ |
|
||||||
async _handleConnectedAccountSelected(origin) { |
|
||||||
const permittedAccounts = await this.getAccounts(origin); |
|
||||||
|
|
||||||
this.notifyAccountsChanged(origin, permittedAccounts); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* A convenience method for retrieving a login object |
|
||||||
* or creating a new one if needed. |
|
||||||
* |
|
||||||
* @param {string} origin - The origin string representing the domain. |
|
||||||
*/ |
|
||||||
_initializePermissions(restoredState) { |
|
||||||
// these permission requests are almost certainly stale
|
|
||||||
const initState = { ...restoredState, permissionsRequests: [] }; |
|
||||||
|
|
||||||
this.permissions = new RpcCap( |
|
||||||
{ |
|
||||||
// Supports passthrough methods:
|
|
||||||
safeMethods: SAFE_METHODS, |
|
||||||
|
|
||||||
// optional prefix for internal methods
|
|
||||||
methodPrefix: WALLET_PREFIX, |
|
||||||
|
|
||||||
restrictedMethods: this._restrictedMethods, |
|
||||||
|
|
||||||
/** |
|
||||||
* A promise-returning callback used to determine whether to approve |
|
||||||
* permissions requests or not. |
|
||||||
* |
|
||||||
* Currently only returns a boolean, but eventually should return any |
|
||||||
* specific parameters or amendments to the permissions. |
|
||||||
* |
|
||||||
* @param {string} req - The internal rpc-cap user request object. |
|
||||||
*/ |
|
||||||
requestUserApproval: async (req) => { |
|
||||||
const { |
|
||||||
metadata: { id, origin }, |
|
||||||
} = req; |
|
||||||
|
|
||||||
return this.approvals.addAndShowApprovalRequest({ |
|
||||||
id, |
|
||||||
origin, |
|
||||||
type: APPROVAL_TYPE, |
|
||||||
}); |
|
||||||
}, |
|
||||||
}, |
|
||||||
initState, |
|
||||||
); |
|
||||||
} |
|
||||||
} |
|
||||||
|
@ -1,950 +0,0 @@ |
|||||||
import { strict as assert } from 'assert'; |
|
||||||
import sinon from 'sinon'; |
|
||||||
|
|
||||||
import { |
|
||||||
constants, |
|
||||||
getters, |
|
||||||
getPermControllerOpts, |
|
||||||
getPermissionsMiddleware, |
|
||||||
} from '../../../../test/mocks/permission-controller'; |
|
||||||
import { |
|
||||||
getUserApprovalPromise, |
|
||||||
grantPermissions, |
|
||||||
} from '../../../../test/helpers/permission-controller-helpers'; |
|
||||||
import { METADATA_STORE_KEY } from './enums'; |
|
||||||
|
|
||||||
import { PermissionsController } from '.'; |
|
||||||
|
|
||||||
const { CAVEATS, ERRORS, PERMS, RPC_REQUESTS } = getters; |
|
||||||
|
|
||||||
const { ACCOUNTS, DOMAINS, PERM_NAMES } = constants; |
|
||||||
|
|
||||||
const initPermController = () => { |
|
||||||
return new PermissionsController({ |
|
||||||
...getPermControllerOpts(), |
|
||||||
}); |
|
||||||
}; |
|
||||||
|
|
||||||
const createApprovalSpies = (permController) => { |
|
||||||
sinon.spy(permController.approvals, '_add'); |
|
||||||
}; |
|
||||||
|
|
||||||
const getNextApprovalId = (permController) => { |
|
||||||
return permController.approvals._approvals.keys().next().value; |
|
||||||
}; |
|
||||||
|
|
||||||
const validatePermission = (perm, name, origin, caveats) => { |
|
||||||
assert.equal( |
|
||||||
name, |
|
||||||
perm.parentCapability, |
|
||||||
'should have expected permission name', |
|
||||||
); |
|
||||||
assert.equal(origin, perm.invoker, 'should have expected permission origin'); |
|
||||||
if (caveats) { |
|
||||||
assert.deepEqual( |
|
||||||
caveats, |
|
||||||
perm.caveats, |
|
||||||
'should have expected permission caveats', |
|
||||||
); |
|
||||||
} else { |
|
||||||
assert.ok(!perm.caveats, 'should not have any caveats'); |
|
||||||
} |
|
||||||
}; |
|
||||||
|
|
||||||
describe('permissions middleware', function () { |
|
||||||
describe('wallet_requestPermissions', function () { |
|
||||||
let permController; |
|
||||||
|
|
||||||
beforeEach(function () { |
|
||||||
permController = initPermController(); |
|
||||||
permController.notifyAccountsChanged = sinon.fake(); |
|
||||||
}); |
|
||||||
|
|
||||||
it('grants permissions on user approval', async function () { |
|
||||||
createApprovalSpies(permController); |
|
||||||
|
|
||||||
const aMiddleware = getPermissionsMiddleware( |
|
||||||
permController, |
|
||||||
DOMAINS.a.origin, |
|
||||||
); |
|
||||||
|
|
||||||
const req = RPC_REQUESTS.requestPermission( |
|
||||||
DOMAINS.a.origin, |
|
||||||
PERM_NAMES.eth_accounts, |
|
||||||
); |
|
||||||
const res = {}; |
|
||||||
|
|
||||||
const userApprovalPromise = getUserApprovalPromise(permController); |
|
||||||
|
|
||||||
const pendingApproval = assert.doesNotReject( |
|
||||||
aMiddleware(req, res), |
|
||||||
'should not reject permissions request', |
|
||||||
); |
|
||||||
|
|
||||||
await userApprovalPromise; |
|
||||||
|
|
||||||
assert.ok( |
|
||||||
permController.approvals._add.calledOnce, |
|
||||||
'should have added single approval request', |
|
||||||
); |
|
||||||
|
|
||||||
const id = getNextApprovalId(permController); |
|
||||||
const approvedReq = PERMS.approvedRequest( |
|
||||||
id, |
|
||||||
PERMS.requests.eth_accounts(), |
|
||||||
); |
|
||||||
|
|
||||||
await permController.approvePermissionsRequest( |
|
||||||
approvedReq, |
|
||||||
ACCOUNTS.a.permitted, |
|
||||||
); |
|
||||||
await pendingApproval; |
|
||||||
|
|
||||||
assert.ok( |
|
||||||
res.result && !res.error, |
|
||||||
'response should have result and no error', |
|
||||||
); |
|
||||||
|
|
||||||
assert.equal( |
|
||||||
res.result.length, |
|
||||||
1, |
|
||||||
'origin should have single approved permission', |
|
||||||
); |
|
||||||
|
|
||||||
validatePermission( |
|
||||||
res.result[0], |
|
||||||
PERM_NAMES.eth_accounts, |
|
||||||
DOMAINS.a.origin, |
|
||||||
CAVEATS.eth_accounts(ACCOUNTS.a.permitted), |
|
||||||
); |
|
||||||
|
|
||||||
const aAccounts = await permController.getAccounts(DOMAINS.a.origin); |
|
||||||
assert.deepEqual( |
|
||||||
aAccounts, |
|
||||||
[ACCOUNTS.a.primary], |
|
||||||
'origin should have correct accounts', |
|
||||||
); |
|
||||||
|
|
||||||
assert.ok( |
|
||||||
permController.notifyAccountsChanged.calledOnceWith( |
|
||||||
DOMAINS.a.origin, |
|
||||||
aAccounts, |
|
||||||
), |
|
||||||
'expected notification call should have been made', |
|
||||||
); |
|
||||||
}); |
|
||||||
|
|
||||||
it('handles serial approved requests that overwrite existing permissions', async function () { |
|
||||||
const aMiddleware = getPermissionsMiddleware( |
|
||||||
permController, |
|
||||||
DOMAINS.a.origin, |
|
||||||
); |
|
||||||
|
|
||||||
// create first request
|
|
||||||
|
|
||||||
const req1 = RPC_REQUESTS.requestPermission( |
|
||||||
DOMAINS.a.origin, |
|
||||||
PERM_NAMES.eth_accounts, |
|
||||||
); |
|
||||||
const res1 = {}; |
|
||||||
|
|
||||||
// send, approve, and validate first request
|
|
||||||
// note use of ACCOUNTS.a.permitted
|
|
||||||
|
|
||||||
let userApprovalPromise = getUserApprovalPromise(permController); |
|
||||||
|
|
||||||
const pendingApproval1 = assert.doesNotReject( |
|
||||||
aMiddleware(req1, res1), |
|
||||||
'should not reject permissions request', |
|
||||||
); |
|
||||||
|
|
||||||
await userApprovalPromise; |
|
||||||
|
|
||||||
const id1 = getNextApprovalId(permController); |
|
||||||
const approvedReq1 = PERMS.approvedRequest( |
|
||||||
id1, |
|
||||||
PERMS.requests.eth_accounts(), |
|
||||||
); |
|
||||||
|
|
||||||
await permController.approvePermissionsRequest( |
|
||||||
approvedReq1, |
|
||||||
ACCOUNTS.a.permitted, |
|
||||||
); |
|
||||||
await pendingApproval1; |
|
||||||
|
|
||||||
assert.ok( |
|
||||||
res1.result && !res1.error, |
|
||||||
'response should have result and no error', |
|
||||||
); |
|
||||||
|
|
||||||
assert.equal( |
|
||||||
res1.result.length, |
|
||||||
1, |
|
||||||
'origin should have single approved permission', |
|
||||||
); |
|
||||||
|
|
||||||
validatePermission( |
|
||||||
res1.result[0], |
|
||||||
PERM_NAMES.eth_accounts, |
|
||||||
DOMAINS.a.origin, |
|
||||||
CAVEATS.eth_accounts(ACCOUNTS.a.permitted), |
|
||||||
); |
|
||||||
|
|
||||||
const accounts1 = await permController.getAccounts(DOMAINS.a.origin); |
|
||||||
assert.deepEqual( |
|
||||||
accounts1, |
|
||||||
[ACCOUNTS.a.primary], |
|
||||||
'origin should have correct accounts', |
|
||||||
); |
|
||||||
|
|
||||||
assert.ok( |
|
||||||
permController.notifyAccountsChanged.calledOnceWith( |
|
||||||
DOMAINS.a.origin, |
|
||||||
accounts1, |
|
||||||
), |
|
||||||
'expected notification call should have been made', |
|
||||||
); |
|
||||||
|
|
||||||
// create second request
|
|
||||||
|
|
||||||
const requestedPerms2 = { |
|
||||||
...PERMS.requests.eth_accounts(), |
|
||||||
...PERMS.requests.test_method(), |
|
||||||
}; |
|
||||||
|
|
||||||
const req2 = RPC_REQUESTS.requestPermissions(DOMAINS.a.origin, { |
|
||||||
...requestedPerms2, |
|
||||||
}); |
|
||||||
const res2 = {}; |
|
||||||
|
|
||||||
// send, approve, and validate second request
|
|
||||||
// note use of ACCOUNTS.b.permitted
|
|
||||||
|
|
||||||
userApprovalPromise = getUserApprovalPromise(permController); |
|
||||||
|
|
||||||
const pendingApproval2 = assert.doesNotReject( |
|
||||||
aMiddleware(req2, res2), |
|
||||||
'should not reject permissions request', |
|
||||||
); |
|
||||||
|
|
||||||
await userApprovalPromise; |
|
||||||
|
|
||||||
const id2 = getNextApprovalId(permController); |
|
||||||
const approvedReq2 = PERMS.approvedRequest(id2, { ...requestedPerms2 }); |
|
||||||
|
|
||||||
await permController.approvePermissionsRequest( |
|
||||||
approvedReq2, |
|
||||||
ACCOUNTS.b.permitted, |
|
||||||
); |
|
||||||
await pendingApproval2; |
|
||||||
|
|
||||||
assert.ok( |
|
||||||
res2.result && !res2.error, |
|
||||||
'response should have result and no error', |
|
||||||
); |
|
||||||
|
|
||||||
assert.equal( |
|
||||||
res2.result.length, |
|
||||||
2, |
|
||||||
'origin should have single approved permission', |
|
||||||
); |
|
||||||
|
|
||||||
validatePermission( |
|
||||||
res2.result[0], |
|
||||||
PERM_NAMES.eth_accounts, |
|
||||||
DOMAINS.a.origin, |
|
||||||
CAVEATS.eth_accounts(ACCOUNTS.b.permitted), |
|
||||||
); |
|
||||||
|
|
||||||
validatePermission( |
|
||||||
res2.result[1], |
|
||||||
PERM_NAMES.test_method, |
|
||||||
DOMAINS.a.origin, |
|
||||||
); |
|
||||||
|
|
||||||
const accounts2 = await permController.getAccounts(DOMAINS.a.origin); |
|
||||||
assert.deepEqual( |
|
||||||
accounts2, |
|
||||||
[ACCOUNTS.b.primary], |
|
||||||
'origin should have correct accounts', |
|
||||||
); |
|
||||||
|
|
||||||
assert.equal( |
|
||||||
permController.notifyAccountsChanged.callCount, |
|
||||||
2, |
|
||||||
'should have called notification method 2 times in total', |
|
||||||
); |
|
||||||
|
|
||||||
assert.ok( |
|
||||||
permController.notifyAccountsChanged.lastCall.calledWith( |
|
||||||
DOMAINS.a.origin, |
|
||||||
accounts2, |
|
||||||
), |
|
||||||
'expected notification call should have been made', |
|
||||||
); |
|
||||||
}); |
|
||||||
|
|
||||||
it('rejects permissions on user rejection', async function () { |
|
||||||
createApprovalSpies(permController); |
|
||||||
|
|
||||||
const aMiddleware = getPermissionsMiddleware( |
|
||||||
permController, |
|
||||||
DOMAINS.a.origin, |
|
||||||
); |
|
||||||
|
|
||||||
const req = RPC_REQUESTS.requestPermission( |
|
||||||
DOMAINS.a.origin, |
|
||||||
PERM_NAMES.eth_accounts, |
|
||||||
); |
|
||||||
const res = {}; |
|
||||||
|
|
||||||
const expectedError = ERRORS.rejectPermissionsRequest.rejection(); |
|
||||||
|
|
||||||
const userApprovalPromise = getUserApprovalPromise(permController); |
|
||||||
|
|
||||||
const requestRejection = assert.rejects( |
|
||||||
aMiddleware(req, res), |
|
||||||
expectedError, |
|
||||||
'request should be rejected with correct error', |
|
||||||
); |
|
||||||
|
|
||||||
await userApprovalPromise; |
|
||||||
|
|
||||||
assert.ok( |
|
||||||
permController.approvals._add.calledOnce, |
|
||||||
'should have added single approval request', |
|
||||||
); |
|
||||||
|
|
||||||
const id = getNextApprovalId(permController); |
|
||||||
|
|
||||||
await permController.rejectPermissionsRequest(id); |
|
||||||
await requestRejection; |
|
||||||
|
|
||||||
assert.ok( |
|
||||||
!res.result && res.error && res.error.message === expectedError.message, |
|
||||||
'response should have expected error and no result', |
|
||||||
); |
|
||||||
|
|
||||||
const aAccounts = await permController.getAccounts(DOMAINS.a.origin); |
|
||||||
assert.deepEqual( |
|
||||||
aAccounts, |
|
||||||
[], |
|
||||||
'origin should have have correct accounts', |
|
||||||
); |
|
||||||
|
|
||||||
assert.ok( |
|
||||||
permController.notifyAccountsChanged.notCalled, |
|
||||||
'should not have called notification method', |
|
||||||
); |
|
||||||
}); |
|
||||||
|
|
||||||
it('rejects requests with unknown permissions', async function () { |
|
||||||
createApprovalSpies(permController); |
|
||||||
|
|
||||||
const aMiddleware = getPermissionsMiddleware( |
|
||||||
permController, |
|
||||||
DOMAINS.a.origin, |
|
||||||
); |
|
||||||
|
|
||||||
const req = RPC_REQUESTS.requestPermissions(DOMAINS.a.origin, { |
|
||||||
...PERMS.requests.does_not_exist(), |
|
||||||
...PERMS.requests.test_method(), |
|
||||||
}); |
|
||||||
const res = {}; |
|
||||||
|
|
||||||
const expectedError = ERRORS.rejectPermissionsRequest.methodNotFound( |
|
||||||
PERM_NAMES.does_not_exist, |
|
||||||
); |
|
||||||
|
|
||||||
await assert.rejects( |
|
||||||
aMiddleware(req, res), |
|
||||||
expectedError, |
|
||||||
'request should be rejected with correct error', |
|
||||||
); |
|
||||||
|
|
||||||
assert.ok( |
|
||||||
permController.approvals._add.notCalled, |
|
||||||
'no approval requests should have been added', |
|
||||||
); |
|
||||||
|
|
||||||
assert.ok( |
|
||||||
!res.result && res.error && res.error.message === expectedError.message, |
|
||||||
'response should have expected error and no result', |
|
||||||
); |
|
||||||
|
|
||||||
assert.ok( |
|
||||||
permController.notifyAccountsChanged.notCalled, |
|
||||||
'should not have called notification method', |
|
||||||
); |
|
||||||
}); |
|
||||||
|
|
||||||
it('accepts only a single pending permissions request per origin', async function () { |
|
||||||
createApprovalSpies(permController); |
|
||||||
|
|
||||||
// two middlewares for two origins
|
|
||||||
|
|
||||||
const aMiddleware = getPermissionsMiddleware( |
|
||||||
permController, |
|
||||||
DOMAINS.a.origin, |
|
||||||
); |
|
||||||
const bMiddleware = getPermissionsMiddleware( |
|
||||||
permController, |
|
||||||
DOMAINS.b.origin, |
|
||||||
); |
|
||||||
|
|
||||||
// create and start processing first request for first origin
|
|
||||||
|
|
||||||
const reqA1 = RPC_REQUESTS.requestPermission( |
|
||||||
DOMAINS.a.origin, |
|
||||||
PERM_NAMES.test_method, |
|
||||||
); |
|
||||||
const resA1 = {}; |
|
||||||
|
|
||||||
let userApprovalPromise = getUserApprovalPromise(permController); |
|
||||||
|
|
||||||
const requestApproval1 = assert.doesNotReject( |
|
||||||
aMiddleware(reqA1, resA1), |
|
||||||
'should not reject permissions request', |
|
||||||
); |
|
||||||
|
|
||||||
await userApprovalPromise; |
|
||||||
|
|
||||||
// create and start processing first request for second origin
|
|
||||||
|
|
||||||
const reqB1 = RPC_REQUESTS.requestPermission( |
|
||||||
DOMAINS.b.origin, |
|
||||||
PERM_NAMES.test_method, |
|
||||||
); |
|
||||||
const resB1 = {}; |
|
||||||
|
|
||||||
userApprovalPromise = getUserApprovalPromise(permController); |
|
||||||
|
|
||||||
const requestApproval2 = assert.doesNotReject( |
|
||||||
bMiddleware(reqB1, resB1), |
|
||||||
'should not reject permissions request', |
|
||||||
); |
|
||||||
|
|
||||||
await userApprovalPromise; |
|
||||||
|
|
||||||
assert.ok( |
|
||||||
permController.approvals._add.calledTwice, |
|
||||||
'should have added two approval requests', |
|
||||||
); |
|
||||||
|
|
||||||
// create and start processing second request for first origin,
|
|
||||||
// which should throw
|
|
||||||
|
|
||||||
const reqA2 = RPC_REQUESTS.requestPermission( |
|
||||||
DOMAINS.a.origin, |
|
||||||
PERM_NAMES.test_method, |
|
||||||
); |
|
||||||
const resA2 = {}; |
|
||||||
|
|
||||||
userApprovalPromise = getUserApprovalPromise(permController); |
|
||||||
|
|
||||||
const expectedError = ERRORS.pendingApprovals.requestAlreadyPending( |
|
||||||
DOMAINS.a.origin, |
|
||||||
); |
|
||||||
|
|
||||||
const requestApprovalFail = assert.rejects( |
|
||||||
aMiddleware(reqA2, resA2), |
|
||||||
expectedError, |
|
||||||
'request should be rejected with correct error', |
|
||||||
); |
|
||||||
|
|
||||||
await userApprovalPromise; |
|
||||||
await requestApprovalFail; |
|
||||||
|
|
||||||
assert.ok( |
|
||||||
!resA2.result && |
|
||||||
resA2.error && |
|
||||||
resA2.error.message === expectedError.message, |
|
||||||
'response should have expected error and no result', |
|
||||||
); |
|
||||||
|
|
||||||
assert.equal( |
|
||||||
permController.approvals._add.callCount, |
|
||||||
3, |
|
||||||
'should have attempted to create three pending approvals', |
|
||||||
); |
|
||||||
assert.equal( |
|
||||||
permController.approvals._approvals.size, |
|
||||||
2, |
|
||||||
'should only have created two pending approvals', |
|
||||||
); |
|
||||||
|
|
||||||
// now, remaining pending requests should be approved without issue
|
|
||||||
|
|
||||||
for (const id of permController.approvals._approvals.keys()) { |
|
||||||
await permController.approvePermissionsRequest( |
|
||||||
PERMS.approvedRequest(id, PERMS.requests.test_method()), |
|
||||||
); |
|
||||||
} |
|
||||||
await requestApproval1; |
|
||||||
await requestApproval2; |
|
||||||
|
|
||||||
assert.ok( |
|
||||||
resA1.result && !resA1.error, |
|
||||||
'first response should have result and no error', |
|
||||||
); |
|
||||||
assert.equal( |
|
||||||
resA1.result.length, |
|
||||||
1, |
|
||||||
'first origin should have single approved permission', |
|
||||||
); |
|
||||||
|
|
||||||
assert.ok( |
|
||||||
resB1.result && !resB1.error, |
|
||||||
'second response should have result and no error', |
|
||||||
); |
|
||||||
assert.equal( |
|
||||||
resB1.result.length, |
|
||||||
1, |
|
||||||
'second origin should have single approved permission', |
|
||||||
); |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
describe('restricted methods', function () { |
|
||||||
let permController; |
|
||||||
|
|
||||||
beforeEach(function () { |
|
||||||
permController = initPermController(); |
|
||||||
}); |
|
||||||
|
|
||||||
it('prevents restricted method access for unpermitted domain', async function () { |
|
||||||
const aMiddleware = getPermissionsMiddleware( |
|
||||||
permController, |
|
||||||
DOMAINS.a.origin, |
|
||||||
); |
|
||||||
|
|
||||||
const req = RPC_REQUESTS.test_method(DOMAINS.a.origin); |
|
||||||
const res = {}; |
|
||||||
|
|
||||||
const expectedError = ERRORS.rpcCap.unauthorized(); |
|
||||||
|
|
||||||
await assert.rejects( |
|
||||||
aMiddleware(req, res), |
|
||||||
expectedError, |
|
||||||
'request should be rejected with correct error', |
|
||||||
); |
|
||||||
|
|
||||||
assert.ok( |
|
||||||
!res.result && res.error && res.error.code === expectedError.code, |
|
||||||
'response should have expected error and no result', |
|
||||||
); |
|
||||||
}); |
|
||||||
|
|
||||||
it('allows restricted method access for permitted domain', async function () { |
|
||||||
const bMiddleware = getPermissionsMiddleware( |
|
||||||
permController, |
|
||||||
DOMAINS.b.origin, |
|
||||||
); |
|
||||||
|
|
||||||
grantPermissions( |
|
||||||
permController, |
|
||||||
DOMAINS.b.origin, |
|
||||||
PERMS.finalizedRequests.test_method(), |
|
||||||
); |
|
||||||
|
|
||||||
const req = RPC_REQUESTS.test_method(DOMAINS.b.origin, true); |
|
||||||
const res = {}; |
|
||||||
|
|
||||||
await assert.doesNotReject(bMiddleware(req, res), 'should not reject'); |
|
||||||
|
|
||||||
assert.ok( |
|
||||||
res.result && res.result === 1, |
|
||||||
'response should have correct result', |
|
||||||
); |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
describe('eth_accounts', function () { |
|
||||||
let permController; |
|
||||||
|
|
||||||
beforeEach(function () { |
|
||||||
permController = initPermController(); |
|
||||||
}); |
|
||||||
|
|
||||||
it('returns empty array for non-permitted domain', async function () { |
|
||||||
const aMiddleware = getPermissionsMiddleware( |
|
||||||
permController, |
|
||||||
DOMAINS.a.origin, |
|
||||||
); |
|
||||||
|
|
||||||
const req = RPC_REQUESTS.eth_accounts(DOMAINS.a.origin); |
|
||||||
const res = {}; |
|
||||||
|
|
||||||
await assert.doesNotReject(aMiddleware(req, res), 'should not reject'); |
|
||||||
|
|
||||||
assert.ok( |
|
||||||
res.result && !res.error, |
|
||||||
'response should have result and no error', |
|
||||||
); |
|
||||||
assert.deepEqual(res.result, [], 'response should have correct result'); |
|
||||||
}); |
|
||||||
|
|
||||||
it('returns correct accounts for permitted domain', async function () { |
|
||||||
const aMiddleware = getPermissionsMiddleware( |
|
||||||
permController, |
|
||||||
DOMAINS.a.origin, |
|
||||||
); |
|
||||||
|
|
||||||
grantPermissions( |
|
||||||
permController, |
|
||||||
DOMAINS.a.origin, |
|
||||||
PERMS.finalizedRequests.eth_accounts(ACCOUNTS.a.permitted), |
|
||||||
); |
|
||||||
|
|
||||||
const req = RPC_REQUESTS.eth_accounts(DOMAINS.a.origin); |
|
||||||
const res = {}; |
|
||||||
|
|
||||||
await assert.doesNotReject(aMiddleware(req, res), 'should not reject'); |
|
||||||
|
|
||||||
assert.ok( |
|
||||||
res.result && !res.error, |
|
||||||
'response should have result and no error', |
|
||||||
); |
|
||||||
assert.deepEqual( |
|
||||||
res.result, |
|
||||||
[ACCOUNTS.a.primary], |
|
||||||
'response should have correct result', |
|
||||||
); |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
describe('eth_requestAccounts', function () { |
|
||||||
let permController; |
|
||||||
|
|
||||||
beforeEach(function () { |
|
||||||
permController = initPermController(); |
|
||||||
}); |
|
||||||
|
|
||||||
it('requests accounts for unpermitted origin, and approves on user approval', async function () { |
|
||||||
createApprovalSpies(permController); |
|
||||||
|
|
||||||
const userApprovalPromise = getUserApprovalPromise(permController); |
|
||||||
|
|
||||||
const aMiddleware = getPermissionsMiddleware( |
|
||||||
permController, |
|
||||||
DOMAINS.a.origin, |
|
||||||
); |
|
||||||
|
|
||||||
const req = RPC_REQUESTS.eth_requestAccounts(DOMAINS.a.origin); |
|
||||||
const res = {}; |
|
||||||
|
|
||||||
const pendingApproval = assert.doesNotReject( |
|
||||||
aMiddleware(req, res), |
|
||||||
'should not reject permissions request', |
|
||||||
); |
|
||||||
|
|
||||||
await userApprovalPromise; |
|
||||||
|
|
||||||
assert.ok( |
|
||||||
permController.approvals._add.calledOnce, |
|
||||||
'should have added single approval request', |
|
||||||
); |
|
||||||
|
|
||||||
const id = getNextApprovalId(permController); |
|
||||||
const approvedReq = PERMS.approvedRequest( |
|
||||||
id, |
|
||||||
PERMS.requests.eth_accounts(), |
|
||||||
); |
|
||||||
|
|
||||||
await permController.approvePermissionsRequest( |
|
||||||
approvedReq, |
|
||||||
ACCOUNTS.a.permitted, |
|
||||||
); |
|
||||||
|
|
||||||
// wait for permission to be granted
|
|
||||||
await pendingApproval; |
|
||||||
|
|
||||||
const perms = permController.permissions.getPermissionsForDomain( |
|
||||||
DOMAINS.a.origin, |
|
||||||
); |
|
||||||
|
|
||||||
assert.equal( |
|
||||||
perms.length, |
|
||||||
1, |
|
||||||
'domain should have correct number of permissions', |
|
||||||
); |
|
||||||
|
|
||||||
validatePermission( |
|
||||||
perms[0], |
|
||||||
PERM_NAMES.eth_accounts, |
|
||||||
DOMAINS.a.origin, |
|
||||||
CAVEATS.eth_accounts(ACCOUNTS.a.permitted), |
|
||||||
); |
|
||||||
|
|
||||||
// we should also see the accounts on the response
|
|
||||||
assert.ok( |
|
||||||
res.result && !res.error, |
|
||||||
'response should have result and no error', |
|
||||||
); |
|
||||||
|
|
||||||
assert.deepEqual( |
|
||||||
res.result, |
|
||||||
[ACCOUNTS.a.primary], |
|
||||||
'result should have correct accounts', |
|
||||||
); |
|
||||||
|
|
||||||
// we should also be able to get the accounts independently
|
|
||||||
const aAccounts = await permController.getAccounts(DOMAINS.a.origin); |
|
||||||
assert.deepEqual( |
|
||||||
aAccounts, |
|
||||||
[ACCOUNTS.a.primary], |
|
||||||
'origin should have have correct accounts', |
|
||||||
); |
|
||||||
}); |
|
||||||
|
|
||||||
it('requests accounts for unpermitted origin, and rejects on user rejection', async function () { |
|
||||||
createApprovalSpies(permController); |
|
||||||
|
|
||||||
const userApprovalPromise = getUserApprovalPromise(permController); |
|
||||||
|
|
||||||
const aMiddleware = getPermissionsMiddleware( |
|
||||||
permController, |
|
||||||
DOMAINS.a.origin, |
|
||||||
); |
|
||||||
|
|
||||||
const req = RPC_REQUESTS.eth_requestAccounts(DOMAINS.a.origin); |
|
||||||
const res = {}; |
|
||||||
|
|
||||||
const expectedError = ERRORS.rejectPermissionsRequest.rejection(); |
|
||||||
|
|
||||||
const requestRejection = assert.rejects( |
|
||||||
aMiddleware(req, res), |
|
||||||
expectedError, |
|
||||||
'request should be rejected with correct error', |
|
||||||
); |
|
||||||
|
|
||||||
await userApprovalPromise; |
|
||||||
|
|
||||||
assert.ok( |
|
||||||
permController.approvals._add.calledOnce, |
|
||||||
'should have added single approval request', |
|
||||||
); |
|
||||||
|
|
||||||
const id = getNextApprovalId(permController); |
|
||||||
|
|
||||||
await permController.rejectPermissionsRequest(id); |
|
||||||
await requestRejection; |
|
||||||
|
|
||||||
assert.ok( |
|
||||||
!res.result && res.error && res.error.message === expectedError.message, |
|
||||||
'response should have expected error and no result', |
|
||||||
); |
|
||||||
|
|
||||||
const aAccounts = await permController.getAccounts(DOMAINS.a.origin); |
|
||||||
assert.deepEqual( |
|
||||||
aAccounts, |
|
||||||
[], |
|
||||||
'origin should have have correct accounts', |
|
||||||
); |
|
||||||
}); |
|
||||||
|
|
||||||
it('directly returns accounts for permitted domain', async function () { |
|
||||||
const cMiddleware = getPermissionsMiddleware( |
|
||||||
permController, |
|
||||||
DOMAINS.c.origin, |
|
||||||
); |
|
||||||
|
|
||||||
grantPermissions( |
|
||||||
permController, |
|
||||||
DOMAINS.c.origin, |
|
||||||
PERMS.finalizedRequests.eth_accounts(ACCOUNTS.c.permitted), |
|
||||||
); |
|
||||||
|
|
||||||
const req = RPC_REQUESTS.eth_requestAccounts(DOMAINS.c.origin); |
|
||||||
const res = {}; |
|
||||||
|
|
||||||
await assert.doesNotReject(cMiddleware(req, res), 'should not reject'); |
|
||||||
|
|
||||||
assert.ok( |
|
||||||
res.result && !res.error, |
|
||||||
'response should have result and no error', |
|
||||||
); |
|
||||||
assert.deepEqual( |
|
||||||
res.result, |
|
||||||
[ACCOUNTS.c.primary], |
|
||||||
'response should have correct result', |
|
||||||
); |
|
||||||
}); |
|
||||||
|
|
||||||
it('rejects new requests when request already pending', async function () { |
|
||||||
let unlock; |
|
||||||
const unlockPromise = new Promise((resolve) => { |
|
||||||
unlock = resolve; |
|
||||||
}); |
|
||||||
|
|
||||||
permController.getUnlockPromise = () => unlockPromise; |
|
||||||
|
|
||||||
const cMiddleware = getPermissionsMiddleware( |
|
||||||
permController, |
|
||||||
DOMAINS.c.origin, |
|
||||||
); |
|
||||||
|
|
||||||
grantPermissions( |
|
||||||
permController, |
|
||||||
DOMAINS.c.origin, |
|
||||||
PERMS.finalizedRequests.eth_accounts(ACCOUNTS.c.permitted), |
|
||||||
); |
|
||||||
|
|
||||||
const req = RPC_REQUESTS.eth_requestAccounts(DOMAINS.c.origin); |
|
||||||
const res = {}; |
|
||||||
|
|
||||||
// this will block until we resolve the unlock Promise
|
|
||||||
const requestApproval = assert.doesNotReject( |
|
||||||
cMiddleware(req, res), |
|
||||||
'should not reject', |
|
||||||
); |
|
||||||
|
|
||||||
// this will reject because of the already pending request
|
|
||||||
await assert.rejects( |
|
||||||
cMiddleware({ ...req }, {}), |
|
||||||
ERRORS.eth_requestAccounts.requestAlreadyPending(DOMAINS.c.origin), |
|
||||||
); |
|
||||||
|
|
||||||
// now unlock and let through the first request
|
|
||||||
unlock(); |
|
||||||
|
|
||||||
await requestApproval; |
|
||||||
|
|
||||||
assert.ok( |
|
||||||
res.result && !res.error, |
|
||||||
'response should have result and no error', |
|
||||||
); |
|
||||||
assert.deepEqual( |
|
||||||
res.result, |
|
||||||
[ACCOUNTS.c.primary], |
|
||||||
'response should have correct result', |
|
||||||
); |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
describe('metamask_sendDomainMetadata', function () { |
|
||||||
let permController, clock; |
|
||||||
|
|
||||||
beforeEach(function () { |
|
||||||
permController = initPermController(); |
|
||||||
clock = sinon.useFakeTimers(1); |
|
||||||
}); |
|
||||||
|
|
||||||
afterEach(function () { |
|
||||||
clock.restore(); |
|
||||||
}); |
|
||||||
|
|
||||||
it('records domain metadata', async function () { |
|
||||||
const name = 'BAZ'; |
|
||||||
|
|
||||||
const cMiddleware = getPermissionsMiddleware( |
|
||||||
permController, |
|
||||||
DOMAINS.c.origin, |
|
||||||
); |
|
||||||
|
|
||||||
const req = RPC_REQUESTS.metamask_sendDomainMetadata( |
|
||||||
DOMAINS.c.origin, |
|
||||||
name, |
|
||||||
); |
|
||||||
const res = {}; |
|
||||||
|
|
||||||
await assert.doesNotReject(cMiddleware(req, res), 'should not reject'); |
|
||||||
|
|
||||||
assert.ok(res.result, 'result should be true'); |
|
||||||
|
|
||||||
const metadataStore = permController.store.getState()[METADATA_STORE_KEY]; |
|
||||||
|
|
||||||
assert.deepEqual( |
|
||||||
metadataStore, |
|
||||||
{ |
|
||||||
[DOMAINS.c.origin]: { |
|
||||||
name, |
|
||||||
host: DOMAINS.c.host, |
|
||||||
lastUpdated: 1, |
|
||||||
}, |
|
||||||
}, |
|
||||||
'metadata should have been added to store', |
|
||||||
); |
|
||||||
}); |
|
||||||
|
|
||||||
it('records domain metadata and preserves extensionId', async function () { |
|
||||||
const extensionId = 'fooExtension'; |
|
||||||
|
|
||||||
const name = 'BAZ'; |
|
||||||
|
|
||||||
const cMiddleware = getPermissionsMiddleware( |
|
||||||
permController, |
|
||||||
DOMAINS.c.origin, |
|
||||||
extensionId, |
|
||||||
); |
|
||||||
|
|
||||||
const req = RPC_REQUESTS.metamask_sendDomainMetadata( |
|
||||||
DOMAINS.c.origin, |
|
||||||
name, |
|
||||||
); |
|
||||||
const res = {}; |
|
||||||
|
|
||||||
await assert.doesNotReject(cMiddleware(req, res), 'should not reject'); |
|
||||||
|
|
||||||
assert.ok(res.result, 'result should be true'); |
|
||||||
|
|
||||||
const metadataStore = permController.store.getState()[METADATA_STORE_KEY]; |
|
||||||
|
|
||||||
assert.deepEqual( |
|
||||||
metadataStore, |
|
||||||
{ [DOMAINS.c.origin]: { name, extensionId, lastUpdated: 1 } }, |
|
||||||
'metadata should have been added to store', |
|
||||||
); |
|
||||||
}); |
|
||||||
|
|
||||||
it('should not record domain metadata if no name', async function () { |
|
||||||
const name = null; |
|
||||||
|
|
||||||
const cMiddleware = getPermissionsMiddleware( |
|
||||||
permController, |
|
||||||
DOMAINS.c.origin, |
|
||||||
); |
|
||||||
|
|
||||||
const req = RPC_REQUESTS.metamask_sendDomainMetadata( |
|
||||||
DOMAINS.c.origin, |
|
||||||
name, |
|
||||||
); |
|
||||||
const res = {}; |
|
||||||
|
|
||||||
await assert.doesNotReject(cMiddleware(req, res), 'should not reject'); |
|
||||||
|
|
||||||
assert.ok(res.result, 'result should be true'); |
|
||||||
|
|
||||||
const metadataStore = permController.store.getState()[METADATA_STORE_KEY]; |
|
||||||
|
|
||||||
assert.deepEqual( |
|
||||||
metadataStore, |
|
||||||
{}, |
|
||||||
'metadata should not have been added to store', |
|
||||||
); |
|
||||||
}); |
|
||||||
|
|
||||||
it('should not record domain metadata if no metadata', async function () { |
|
||||||
const cMiddleware = getPermissionsMiddleware( |
|
||||||
permController, |
|
||||||
DOMAINS.c.origin, |
|
||||||
); |
|
||||||
|
|
||||||
const req = RPC_REQUESTS.metamask_sendDomainMetadata(DOMAINS.c.origin); |
|
||||||
delete req.domainMetadata; |
|
||||||
const res = {}; |
|
||||||
|
|
||||||
await assert.doesNotReject(cMiddleware(req, res), 'should not reject'); |
|
||||||
|
|
||||||
assert.ok(res.result, 'result should be true'); |
|
||||||
|
|
||||||
const metadataStore = permController.store.getState()[METADATA_STORE_KEY]; |
|
||||||
|
|
||||||
assert.deepEqual( |
|
||||||
metadataStore, |
|
||||||
{}, |
|
||||||
'metadata should not have been added to store', |
|
||||||
); |
|
||||||
}); |
|
||||||
}); |
|
||||||
}); |
|
@ -1,112 +0,0 @@ |
|||||||
import { createAsyncMiddleware } from 'json-rpc-engine'; |
|
||||||
import { ethErrors } from 'eth-rpc-errors'; |
|
||||||
|
|
||||||
/** |
|
||||||
* Create middleware for handling certain methods and preprocessing permissions requests. |
|
||||||
*/ |
|
||||||
export default function createPermissionsMethodMiddleware({ |
|
||||||
addDomainMetadata, |
|
||||||
getAccounts, |
|
||||||
getUnlockPromise, |
|
||||||
hasPermission, |
|
||||||
notifyAccountsChanged, |
|
||||||
requestAccountsPermission, |
|
||||||
}) { |
|
||||||
let isProcessingRequestAccounts = false; |
|
||||||
|
|
||||||
return createAsyncMiddleware(async (req, res, next) => { |
|
||||||
let responseHandler; |
|
||||||
|
|
||||||
switch (req.method) { |
|
||||||
// Intercepting eth_accounts requests for backwards compatibility:
|
|
||||||
// The getAccounts call below wraps the rpc-cap middleware, and returns
|
|
||||||
// an empty array in case of errors (such as 4100:unauthorized)
|
|
||||||
case 'eth_accounts': { |
|
||||||
res.result = await getAccounts(); |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
case 'eth_requestAccounts': { |
|
||||||
if (isProcessingRequestAccounts) { |
|
||||||
res.error = ethErrors.rpc.resourceUnavailable( |
|
||||||
'Already processing eth_requestAccounts. Please wait.', |
|
||||||
); |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
if (hasPermission('eth_accounts')) { |
|
||||||
isProcessingRequestAccounts = true; |
|
||||||
await getUnlockPromise(); |
|
||||||
isProcessingRequestAccounts = false; |
|
||||||
} |
|
||||||
|
|
||||||
// first, just try to get accounts
|
|
||||||
let accounts = await getAccounts(); |
|
||||||
if (accounts.length > 0) { |
|
||||||
res.result = accounts; |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
// if no accounts, request the accounts permission
|
|
||||||
try { |
|
||||||
await requestAccountsPermission(); |
|
||||||
} catch (err) { |
|
||||||
res.error = err; |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
// get the accounts again
|
|
||||||
accounts = await getAccounts(); |
|
||||||
/* istanbul ignore else: too hard to induce, see below comment */ |
|
||||||
if (accounts.length > 0) { |
|
||||||
res.result = accounts; |
|
||||||
} else { |
|
||||||
// this should never happen, because it should be caught in the
|
|
||||||
// above catch clause
|
|
||||||
res.error = ethErrors.rpc.internal( |
|
||||||
'Accounts unexpectedly unavailable. Please report this bug.', |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
// custom method for getting metadata from the requesting domain,
|
|
||||||
// sent automatically by the inpage provider when it's initialized
|
|
||||||
case 'metamask_sendDomainMetadata': { |
|
||||||
if (typeof req.params?.name === 'string') { |
|
||||||
addDomainMetadata(req.origin, req.params); |
|
||||||
} |
|
||||||
res.result = true; |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
// register return handler to send accountsChanged notification
|
|
||||||
case 'wallet_requestPermissions': { |
|
||||||
if ('eth_accounts' in req.params?.[0]) { |
|
||||||
responseHandler = async () => { |
|
||||||
if (Array.isArray(res.result)) { |
|
||||||
for (const permission of res.result) { |
|
||||||
if (permission.parentCapability === 'eth_accounts') { |
|
||||||
notifyAccountsChanged(await getAccounts()); |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
}; |
|
||||||
} |
|
||||||
break; |
|
||||||
} |
|
||||||
|
|
||||||
default: |
|
||||||
break; |
|
||||||
} |
|
||||||
|
|
||||||
// when this promise resolves, the response is on its way back
|
|
||||||
// eslint-disable-next-line node/callback-return
|
|
||||||
await next(); |
|
||||||
|
|
||||||
if (responseHandler) { |
|
||||||
responseHandler(); |
|
||||||
} |
|
||||||
}); |
|
||||||
} |
|
@ -1,174 +0,0 @@ |
|||||||
import { strict as assert } from 'assert'; |
|
||||||
import pify from 'pify'; |
|
||||||
|
|
||||||
import getRestrictedMethods from './restrictedMethods'; |
|
||||||
|
|
||||||
describe('restricted methods', function () { |
|
||||||
describe('eth_accounts', function () { |
|
||||||
it('should handle getKeyringAccounts error', async function () { |
|
||||||
const restrictedMethods = getRestrictedMethods({ |
|
||||||
getKeyringAccounts: async () => { |
|
||||||
throw new Error('foo'); |
|
||||||
}, |
|
||||||
}); |
|
||||||
const ethAccountsMethod = pify(restrictedMethods.eth_accounts.method); |
|
||||||
|
|
||||||
const res = {}; |
|
||||||
const fooError = new Error('foo'); |
|
||||||
await assert.rejects( |
|
||||||
ethAccountsMethod(null, res, null), |
|
||||||
fooError, |
|
||||||
'Should reject with expected error', |
|
||||||
); |
|
||||||
|
|
||||||
assert.deepEqual( |
|
||||||
res, |
|
||||||
{ error: fooError }, |
|
||||||
'response should have expected error and no result', |
|
||||||
); |
|
||||||
}); |
|
||||||
|
|
||||||
it('should handle missing identity for first account when sorting', async function () { |
|
||||||
const restrictedMethods = getRestrictedMethods({ |
|
||||||
getIdentities: () => { |
|
||||||
return { '0x7e57e2': {} }; |
|
||||||
}, |
|
||||||
getKeyringAccounts: async () => ['0x7e57e2', '0x7e57e3'], |
|
||||||
}); |
|
||||||
const ethAccountsMethod = pify(restrictedMethods.eth_accounts.method); |
|
||||||
|
|
||||||
const res = {}; |
|
||||||
await assert.rejects(ethAccountsMethod(null, res, null)); |
|
||||||
assert.ok(res.error instanceof Error, 'result should have error'); |
|
||||||
assert.deepEqual( |
|
||||||
Object.keys(res), |
|
||||||
['error'], |
|
||||||
'result should only contain error', |
|
||||||
); |
|
||||||
}); |
|
||||||
|
|
||||||
it('should handle missing identity for second account when sorting', async function () { |
|
||||||
const restrictedMethods = getRestrictedMethods({ |
|
||||||
getIdentities: () => { |
|
||||||
return { '0x7e57e3': {} }; |
|
||||||
}, |
|
||||||
getKeyringAccounts: async () => ['0x7e57e2', '0x7e57e3'], |
|
||||||
}); |
|
||||||
const ethAccountsMethod = pify(restrictedMethods.eth_accounts.method); |
|
||||||
|
|
||||||
const res = {}; |
|
||||||
await assert.rejects(ethAccountsMethod(null, res, null)); |
|
||||||
assert.ok(res.error instanceof Error, 'result should have error'); |
|
||||||
assert.deepEqual( |
|
||||||
Object.keys(res), |
|
||||||
['error'], |
|
||||||
'result should only contain error', |
|
||||||
); |
|
||||||
}); |
|
||||||
|
|
||||||
it('should return accounts in keyring order when none are selected', async function () { |
|
||||||
const keyringAccounts = ['0x7e57e2', '0x7e57e3', '0x7e57e4', '0x7e57e5']; |
|
||||||
const restrictedMethods = getRestrictedMethods({ |
|
||||||
getIdentities: () => { |
|
||||||
return keyringAccounts.reduce((identities, address) => { |
|
||||||
identities[address] = {}; |
|
||||||
return identities; |
|
||||||
}, {}); |
|
||||||
}, |
|
||||||
getKeyringAccounts: async () => [...keyringAccounts], |
|
||||||
}); |
|
||||||
const ethAccountsMethod = pify(restrictedMethods.eth_accounts.method); |
|
||||||
|
|
||||||
const res = {}; |
|
||||||
await ethAccountsMethod(null, res, null); |
|
||||||
assert.deepEqual( |
|
||||||
res, |
|
||||||
{ result: keyringAccounts }, |
|
||||||
'should return accounts in correct order', |
|
||||||
); |
|
||||||
}); |
|
||||||
|
|
||||||
it('should return accounts in keyring order when all have same last selected time', async function () { |
|
||||||
const keyringAccounts = ['0x7e57e2', '0x7e57e3', '0x7e57e4', '0x7e57e5']; |
|
||||||
const restrictedMethods = getRestrictedMethods({ |
|
||||||
getIdentities: () => { |
|
||||||
return keyringAccounts.reduce((identities, address) => { |
|
||||||
identities[address] = { lastSelected: 1000 }; |
|
||||||
return identities; |
|
||||||
}, {}); |
|
||||||
}, |
|
||||||
getKeyringAccounts: async () => [...keyringAccounts], |
|
||||||
}); |
|
||||||
const ethAccountsMethod = pify(restrictedMethods.eth_accounts.method); |
|
||||||
|
|
||||||
const res = {}; |
|
||||||
await ethAccountsMethod(null, res, null); |
|
||||||
assert.deepEqual( |
|
||||||
res, |
|
||||||
{ result: keyringAccounts }, |
|
||||||
'should return accounts in correct order', |
|
||||||
); |
|
||||||
}); |
|
||||||
|
|
||||||
it('should return accounts sorted by last selected (descending)', async function () { |
|
||||||
const keyringAccounts = ['0x7e57e2', '0x7e57e3', '0x7e57e4', '0x7e57e5']; |
|
||||||
const expectedResult = keyringAccounts.slice().reverse(); |
|
||||||
const restrictedMethods = getRestrictedMethods({ |
|
||||||
getIdentities: () => { |
|
||||||
return keyringAccounts.reduce((identities, address, index) => { |
|
||||||
identities[address] = { lastSelected: index * 1000 }; |
|
||||||
return identities; |
|
||||||
}, {}); |
|
||||||
}, |
|
||||||
getKeyringAccounts: async () => [...keyringAccounts], |
|
||||||
}); |
|
||||||
const ethAccountsMethod = pify(restrictedMethods.eth_accounts.method); |
|
||||||
|
|
||||||
const res = {}; |
|
||||||
await ethAccountsMethod(null, res, null); |
|
||||||
assert.deepEqual( |
|
||||||
res, |
|
||||||
{ result: expectedResult }, |
|
||||||
'should return accounts in correct order', |
|
||||||
); |
|
||||||
}); |
|
||||||
|
|
||||||
it('should return accounts sorted by last selected (descending) with unselected accounts last, in keyring order', async function () { |
|
||||||
const keyringAccounts = [ |
|
||||||
'0x7e57e2', |
|
||||||
'0x7e57e3', |
|
||||||
'0x7e57e4', |
|
||||||
'0x7e57e5', |
|
||||||
'0x7e57e6', |
|
||||||
]; |
|
||||||
const expectedResult = [ |
|
||||||
'0x7e57e4', |
|
||||||
'0x7e57e2', |
|
||||||
'0x7e57e3', |
|
||||||
'0x7e57e5', |
|
||||||
'0x7e57e6', |
|
||||||
]; |
|
||||||
const restrictedMethods = getRestrictedMethods({ |
|
||||||
getIdentities: () => { |
|
||||||
return { |
|
||||||
'0x7e57e2': { lastSelected: 1000 }, |
|
||||||
'0x7e57e3': {}, |
|
||||||
'0x7e57e4': { lastSelected: 2000 }, |
|
||||||
'0x7e57e5': {}, |
|
||||||
'0x7e57e6': {}, |
|
||||||
}; |
|
||||||
}, |
|
||||||
getKeyringAccounts: async () => [...keyringAccounts], |
|
||||||
}); |
|
||||||
const ethAccountsMethod = pify(restrictedMethods.eth_accounts.method); |
|
||||||
|
|
||||||
const res = {}; |
|
||||||
await ethAccountsMethod(null, res, null); |
|
||||||
assert.deepEqual( |
|
||||||
res, |
|
||||||
{ result: expectedResult }, |
|
||||||
'should return accounts in correct order', |
|
||||||
); |
|
||||||
}); |
|
||||||
}); |
|
||||||
}); |
|
@ -1,40 +0,0 @@ |
|||||||
export default function getRestrictedMethods({ |
|
||||||
getIdentities, |
|
||||||
getKeyringAccounts, |
|
||||||
}) { |
|
||||||
return { |
|
||||||
eth_accounts: { |
|
||||||
method: async (_, res, __, end) => { |
|
||||||
try { |
|
||||||
const accounts = await getKeyringAccounts(); |
|
||||||
const identities = getIdentities(); |
|
||||||
res.result = accounts.sort((firstAddress, secondAddress) => { |
|
||||||
if (!identities[firstAddress]) { |
|
||||||
throw new Error(`Missing identity for address ${firstAddress}`); |
|
||||||
} else if (!identities[secondAddress]) { |
|
||||||
throw new Error(`Missing identity for address ${secondAddress}`); |
|
||||||
} else if ( |
|
||||||
identities[firstAddress].lastSelected === |
|
||||||
identities[secondAddress].lastSelected |
|
||||||
) { |
|
||||||
return 0; |
|
||||||
} else if (identities[firstAddress].lastSelected === undefined) { |
|
||||||
return 1; |
|
||||||
} else if (identities[secondAddress].lastSelected === undefined) { |
|
||||||
return -1; |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
identities[secondAddress].lastSelected - |
|
||||||
identities[firstAddress].lastSelected |
|
||||||
); |
|
||||||
}); |
|
||||||
end(); |
|
||||||
} catch (err) { |
|
||||||
res.error = err; |
|
||||||
end(err); |
|
||||||
} |
|
||||||
}, |
|
||||||
}, |
|
||||||
}; |
|
||||||
} |
|
@ -0,0 +1,84 @@ |
|||||||
|
import { createSelector } from 'reselect'; |
||||||
|
import { CaveatTypes } from '../../../../shared/constants/permissions'; |
||||||
|
|
||||||
|
/** |
||||||
|
* This file contains selectors for PermissionController selector event |
||||||
|
* subscriptions, used to detect whenever a subject's accounts change so that |
||||||
|
* we can notify the subject via the `accountsChanged` provider event. |
||||||
|
*/ |
||||||
|
|
||||||
|
/** |
||||||
|
* @param {Record<string, Record<string, unknown>>} state - The |
||||||
|
* PermissionController state. |
||||||
|
* @returns {Record<string, unknown>} The PermissionController subjects. |
||||||
|
*/ |
||||||
|
const getSubjects = (state) => state.subjects; |
||||||
|
|
||||||
|
/** |
||||||
|
* Get the permitted accounts for each subject, keyed by origin. |
||||||
|
* The values of the returned map are immutable values from the |
||||||
|
* PermissionController state. |
||||||
|
* |
||||||
|
* @returns {Map<string, string[]>} The current origin:accounts[] map. |
||||||
|
*/ |
||||||
|
export const getPermittedAccountsByOrigin = createSelector( |
||||||
|
getSubjects, |
||||||
|
(subjects) => { |
||||||
|
return Object.values(subjects).reduce((originToAccountsMap, subject) => { |
||||||
|
const caveat = subject.permissions?.eth_accounts?.caveats.find( |
||||||
|
({ type }) => type === CaveatTypes.restrictReturnedAccounts, |
||||||
|
); |
||||||
|
|
||||||
|
if (caveat) { |
||||||
|
originToAccountsMap.set(subject.origin, caveat.value); |
||||||
|
} |
||||||
|
return originToAccountsMap; |
||||||
|
}, new Map()); |
||||||
|
}, |
||||||
|
); |
||||||
|
|
||||||
|
/** |
||||||
|
* Given the current and previous exposed accounts for each PermissionController |
||||||
|
* subject, returns a new map containing all accounts that have changed. |
||||||
|
* The values of each map must be immutable values directly from the |
||||||
|
* PermissionController state, or an empty array instantiated in this |
||||||
|
* function. |
||||||
|
* |
||||||
|
* @param {Map<string, string[]>} newAccountsMap - The new origin:accounts[] map. |
||||||
|
* @param {Map<string, string[]>} [previousAccountsMap] - The previous origin:accounts[] map. |
||||||
|
* @returns {Map<string, string[]>} The origin:accounts[] map of changed accounts. |
||||||
|
*/ |
||||||
|
export const getChangedAccounts = (newAccountsMap, previousAccountsMap) => { |
||||||
|
if (previousAccountsMap === undefined) { |
||||||
|
return newAccountsMap; |
||||||
|
} |
||||||
|
|
||||||
|
const changedAccounts = new Map(); |
||||||
|
if (newAccountsMap === previousAccountsMap) { |
||||||
|
return changedAccounts; |
||||||
|
} |
||||||
|
|
||||||
|
const newOrigins = new Set([...newAccountsMap.keys()]); |
||||||
|
|
||||||
|
for (const origin of previousAccountsMap.keys()) { |
||||||
|
const newAccounts = newAccountsMap.get(origin) ?? []; |
||||||
|
|
||||||
|
// The values of these maps are references to immutable values, which is why
|
||||||
|
// a strict equality check is enough for diffing. The values are either from
|
||||||
|
// PermissionController state, or an empty array initialized in the previous
|
||||||
|
// call to this function. `newAccountsMap` will never contain any empty
|
||||||
|
// arrays.
|
||||||
|
if (previousAccountsMap.get(origin) !== newAccounts) { |
||||||
|
changedAccounts.set(origin, newAccounts); |
||||||
|
} |
||||||
|
|
||||||
|
newOrigins.delete(origin); |
||||||
|
} |
||||||
|
|
||||||
|
// By now, newOrigins is either empty or contains some number of previously
|
||||||
|
// unencountered origins, and all of their accounts have "changed".
|
||||||
|
for (const origin of newOrigins.keys()) { |
||||||
|
changedAccounts.set(origin, newAccountsMap.get(origin)); |
||||||
|
} |
||||||
|
return changedAccounts; |
||||||
|
}; |
@ -0,0 +1,116 @@ |
|||||||
|
import { cloneDeep } from 'lodash'; |
||||||
|
import { getChangedAccounts, getPermittedAccountsByOrigin } from './selectors'; |
||||||
|
|
||||||
|
describe('PermissionController selectors', () => { |
||||||
|
describe('getChangedAccounts', () => { |
||||||
|
it('returns the new value if the previous value is undefined', () => { |
||||||
|
const newAccounts = new Map([['foo.bar', ['0x1']]]); |
||||||
|
expect(getChangedAccounts(newAccounts)).toBe(newAccounts); |
||||||
|
}); |
||||||
|
|
||||||
|
it('returns an empty map if the new and previous values are the same', () => { |
||||||
|
const newAccounts = new Map([['foo.bar', ['0x1']]]); |
||||||
|
expect(getChangedAccounts(newAccounts, newAccounts)).toStrictEqual( |
||||||
|
new Map(), |
||||||
|
); |
||||||
|
}); |
||||||
|
|
||||||
|
it('returns a new map of the changed accounts if the new and previous values differ', () => { |
||||||
|
// We set this on the new and previous value under the key 'foo.bar' to
|
||||||
|
// check that identical values are excluded.
|
||||||
|
const identicalValue = ['0x1']; |
||||||
|
|
||||||
|
const previousAccounts = new Map([ |
||||||
|
['bar.baz', ['0x1']], // included: different accounts
|
||||||
|
['fizz.buzz', ['0x1']], // included: removed in new value
|
||||||
|
]); |
||||||
|
previousAccounts.set('foo.bar', identicalValue); |
||||||
|
|
||||||
|
const newAccounts = new Map([ |
||||||
|
['bar.baz', ['0x1', '0x2']], // included: different accounts
|
||||||
|
['baz.fizz', ['0x3']], // included: brand new
|
||||||
|
]); |
||||||
|
newAccounts.set('foo.bar', identicalValue); |
||||||
|
|
||||||
|
expect(getChangedAccounts(newAccounts, previousAccounts)).toStrictEqual( |
||||||
|
new Map([ |
||||||
|
['bar.baz', ['0x1', '0x2']], |
||||||
|
['fizz.buzz', []], |
||||||
|
['baz.fizz', ['0x3']], |
||||||
|
]), |
||||||
|
); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('getPermittedAccountsByOrigin', () => { |
||||||
|
it('memoizes and gets permitted accounts by origin', () => { |
||||||
|
const state1 = { |
||||||
|
subjects: { |
||||||
|
'foo.bar': { |
||||||
|
origin: 'foo.bar', |
||||||
|
permissions: { |
||||||
|
eth_accounts: { |
||||||
|
caveats: [{ type: 'restrictReturnedAccounts', value: ['0x1'] }], |
||||||
|
}, |
||||||
|
}, |
||||||
|
}, |
||||||
|
'bar.baz': { |
||||||
|
origin: 'bar.baz', |
||||||
|
permissions: { |
||||||
|
eth_accounts: { |
||||||
|
caveats: [{ type: 'restrictReturnedAccounts', value: ['0x2'] }], |
||||||
|
}, |
||||||
|
}, |
||||||
|
}, |
||||||
|
'baz.bizz': { |
||||||
|
origin: 'baz.fizz', |
||||||
|
permissions: { |
||||||
|
eth_accounts: { |
||||||
|
caveats: [ |
||||||
|
{ type: 'restrictReturnedAccounts', value: ['0x1', '0x2'] }, |
||||||
|
], |
||||||
|
}, |
||||||
|
}, |
||||||
|
}, |
||||||
|
'no.accounts': { |
||||||
|
// we shouldn't see this in the result
|
||||||
|
permissions: { |
||||||
|
foobar: {}, |
||||||
|
}, |
||||||
|
}, |
||||||
|
}, |
||||||
|
}; |
||||||
|
|
||||||
|
const expected1 = new Map([ |
||||||
|
['foo.bar', ['0x1']], |
||||||
|
['bar.baz', ['0x2']], |
||||||
|
['baz.fizz', ['0x1', '0x2']], |
||||||
|
]); |
||||||
|
|
||||||
|
const selected1 = getPermittedAccountsByOrigin(state1); |
||||||
|
|
||||||
|
expect(selected1).toStrictEqual(expected1); |
||||||
|
// The selector should return the memoized value if state.subjects is
|
||||||
|
// the same object
|
||||||
|
expect(selected1).toBe(getPermittedAccountsByOrigin(state1)); |
||||||
|
|
||||||
|
// If we mutate the state, the selector return value should be different
|
||||||
|
// from the first.
|
||||||
|
const state2 = cloneDeep(state1); |
||||||
|
delete state2.subjects['foo.bar']; |
||||||
|
|
||||||
|
const expected2 = new Map([ |
||||||
|
['bar.baz', ['0x2']], |
||||||
|
['baz.fizz', ['0x1', '0x2']], |
||||||
|
]); |
||||||
|
|
||||||
|
const selected2 = getPermittedAccountsByOrigin(state2); |
||||||
|
|
||||||
|
expect(selected2).toStrictEqual(expected2); |
||||||
|
expect(selected2).not.toBe(selected1); |
||||||
|
// Since we didn't mutate the state at this point, the value should once
|
||||||
|
// again be the memoized.
|
||||||
|
expect(selected2).toBe(getPermittedAccountsByOrigin(state2)); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
@ -0,0 +1,258 @@ |
|||||||
|
import { constructPermission } from '@metamask/snap-controllers'; |
||||||
|
import { |
||||||
|
CaveatTypes, |
||||||
|
RestrictedMethods, |
||||||
|
} from '../../../../shared/constants/permissions'; |
||||||
|
|
||||||
|
/** |
||||||
|
* This file contains the specifications of the permissions and caveats |
||||||
|
* that are recognized by our permission system. See the PermissionController |
||||||
|
* README in @metamask/snap-controllers for details. |
||||||
|
*/ |
||||||
|
|
||||||
|
/** |
||||||
|
* The "keys" of all of permissions recognized by the PermissionController. |
||||||
|
* Permission keys and names have distinct meanings in the permission system. |
||||||
|
*/ |
||||||
|
const PermissionKeys = Object.freeze({ |
||||||
|
...RestrictedMethods, |
||||||
|
}); |
||||||
|
|
||||||
|
/** |
||||||
|
* Factory functions for all caveat types recognized by the |
||||||
|
* PermissionController. |
||||||
|
*/ |
||||||
|
const CaveatFactories = Object.freeze({ |
||||||
|
[CaveatTypes.restrictReturnedAccounts]: (accounts) => { |
||||||
|
return { type: CaveatTypes.restrictReturnedAccounts, value: accounts }; |
||||||
|
}, |
||||||
|
}); |
||||||
|
|
||||||
|
/** |
||||||
|
* A PreferencesController identity object. |
||||||
|
* |
||||||
|
* @typedef {Object} Identity |
||||||
|
* @property {string} address - The address of the identity. |
||||||
|
* @property {string} name - The name of the identity. |
||||||
|
* @property {number} [lastSelected] - Unix timestamp of when the identity was |
||||||
|
* last selected in the UI. |
||||||
|
*/ |
||||||
|
|
||||||
|
/** |
||||||
|
* Gets the specifications for all caveats that will be recognized by the |
||||||
|
* PermissionController. |
||||||
|
* |
||||||
|
* @param {{ |
||||||
|
* getIdentities: () => Record<string, Identity>, |
||||||
|
* }} options - Options bag. |
||||||
|
*/ |
||||||
|
export const getCaveatSpecifications = ({ getIdentities }) => { |
||||||
|
return { |
||||||
|
[CaveatTypes.restrictReturnedAccounts]: { |
||||||
|
type: CaveatTypes.restrictReturnedAccounts, |
||||||
|
|
||||||
|
decorator: (method, caveat) => { |
||||||
|
return async (args) => { |
||||||
|
const result = await method(args); |
||||||
|
return result |
||||||
|
.filter((account) => caveat.value.includes(account)) |
||||||
|
.slice(0, 1); |
||||||
|
}; |
||||||
|
}, |
||||||
|
|
||||||
|
validator: (caveat, _origin, _target) => |
||||||
|
validateCaveatAccounts(caveat.value, getIdentities), |
||||||
|
}, |
||||||
|
}; |
||||||
|
}; |
||||||
|
|
||||||
|
/** |
||||||
|
* Gets the specifications for all permissions that will be recognized by the |
||||||
|
* PermissionController. |
||||||
|
* |
||||||
|
* @param {{ |
||||||
|
* getAllAccounts: () => Promise<string[]>, |
||||||
|
* getIdentities: () => Record<string, Identity>, |
||||||
|
* }} options - Options bag. |
||||||
|
* @param options.getAllAccounts - A function that returns all Ethereum accounts |
||||||
|
* in the current MetaMask instance. |
||||||
|
* @param options.getIdentities - A function that returns the |
||||||
|
* `PreferencesController` identity objects for all Ethereum accounts in the |
||||||
|
* current MetaMask instance. |
||||||
|
*/ |
||||||
|
export const getPermissionSpecifications = ({ |
||||||
|
getAllAccounts, |
||||||
|
getIdentities, |
||||||
|
}) => { |
||||||
|
return { |
||||||
|
[PermissionKeys.eth_accounts]: { |
||||||
|
targetKey: PermissionKeys.eth_accounts, |
||||||
|
allowedCaveats: [CaveatTypes.restrictReturnedAccounts], |
||||||
|
|
||||||
|
factory: (permissionOptions, requestData) => { |
||||||
|
if (Array.isArray(permissionOptions.caveats)) { |
||||||
|
throw new Error( |
||||||
|
`${PermissionKeys.eth_accounts} error: Received unexpected caveats. Any permitted caveats will be added automatically.`, |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
// This value will be further validated as part of the caveat.
|
||||||
|
if (!requestData.approvedAccounts) { |
||||||
|
throw new Error( |
||||||
|
`${PermissionKeys.eth_accounts} error: No approved accounts specified.`, |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
return constructPermission({ |
||||||
|
...permissionOptions, |
||||||
|
caveats: [ |
||||||
|
CaveatFactories[CaveatTypes.restrictReturnedAccounts]( |
||||||
|
requestData.approvedAccounts, |
||||||
|
), |
||||||
|
], |
||||||
|
}); |
||||||
|
}, |
||||||
|
|
||||||
|
methodImplementation: async (_args) => { |
||||||
|
const accounts = await getAllAccounts(); |
||||||
|
const identities = getIdentities(); |
||||||
|
|
||||||
|
return accounts.sort((firstAddress, secondAddress) => { |
||||||
|
if (!identities[firstAddress]) { |
||||||
|
throw new Error(`Missing identity for address: "${firstAddress}".`); |
||||||
|
} else if (!identities[secondAddress]) { |
||||||
|
throw new Error( |
||||||
|
`Missing identity for address: "${secondAddress}".`, |
||||||
|
); |
||||||
|
} else if ( |
||||||
|
identities[firstAddress].lastSelected === |
||||||
|
identities[secondAddress].lastSelected |
||||||
|
) { |
||||||
|
return 0; |
||||||
|
} else if (identities[firstAddress].lastSelected === undefined) { |
||||||
|
return 1; |
||||||
|
} else if (identities[secondAddress].lastSelected === undefined) { |
||||||
|
return -1; |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
identities[secondAddress].lastSelected - |
||||||
|
identities[firstAddress].lastSelected |
||||||
|
); |
||||||
|
}); |
||||||
|
}, |
||||||
|
|
||||||
|
validator: (permission, _origin, _target) => { |
||||||
|
const { caveats } = permission; |
||||||
|
if ( |
||||||
|
!caveats || |
||||||
|
caveats.length !== 1 || |
||||||
|
caveats[0].type !== CaveatTypes.restrictReturnedAccounts |
||||||
|
) { |
||||||
|
throw new Error( |
||||||
|
`${PermissionKeys.eth_accounts} error: Invalid caveats. There must be a single caveat of type "${CaveatTypes.restrictReturnedAccounts}".`, |
||||||
|
); |
||||||
|
} |
||||||
|
}, |
||||||
|
}, |
||||||
|
}; |
||||||
|
}; |
||||||
|
|
||||||
|
/** |
||||||
|
* Validates the accounts associated with a caveat. In essence, ensures that |
||||||
|
* the accounts value is an array of non-empty strings, and that each string |
||||||
|
* corresponds to a PreferencesController identity. |
||||||
|
* |
||||||
|
* @param {string[]} accounts - The accounts associated with the caveat. |
||||||
|
* @param {() => Record<string, Identity>} getIdentities - Gets all |
||||||
|
* PreferencesController identities. |
||||||
|
*/ |
||||||
|
function validateCaveatAccounts(accounts, getIdentities) { |
||||||
|
if (!Array.isArray(accounts) || accounts.length === 0) { |
||||||
|
throw new Error( |
||||||
|
`${PermissionKeys.eth_accounts} error: Expected non-empty array of Ethereum addresses.`, |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
const identities = getIdentities(); |
||||||
|
accounts.forEach((address) => { |
||||||
|
if (!address || typeof address !== 'string') { |
||||||
|
throw new Error( |
||||||
|
`${PermissionKeys.eth_accounts} error: Expected an array of Ethereum addresses. Received: "${address}".`, |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
if (!identities[address]) { |
||||||
|
throw new Error( |
||||||
|
`${PermissionKeys.eth_accounts} error: Received unrecognized address: "${address}".`, |
||||||
|
); |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* All unrestricted methods recognized by the PermissionController. |
||||||
|
* Unrestricted methods are ignored by the permission system, but every |
||||||
|
* JSON-RPC request seen by the permission system must correspond to a |
||||||
|
* restricted or unrestricted method, or the request will be rejected with a |
||||||
|
* "method not found" error. |
||||||
|
*/ |
||||||
|
export const unrestrictedMethods = Object.freeze([ |
||||||
|
'eth_blockNumber', |
||||||
|
'eth_call', |
||||||
|
'eth_chainId', |
||||||
|
'eth_coinbase', |
||||||
|
'eth_decrypt', |
||||||
|
'eth_estimateGas', |
||||||
|
'eth_feeHistory', |
||||||
|
'eth_gasPrice', |
||||||
|
'eth_getBalance', |
||||||
|
'eth_getBlockByHash', |
||||||
|
'eth_getBlockByNumber', |
||||||
|
'eth_getBlockTransactionCountByHash', |
||||||
|
'eth_getBlockTransactionCountByNumber', |
||||||
|
'eth_getCode', |
||||||
|
'eth_getEncryptionPublicKey', |
||||||
|
'eth_getFilterChanges', |
||||||
|
'eth_getFilterLogs', |
||||||
|
'eth_getLogs', |
||||||
|
'eth_getProof', |
||||||
|
'eth_getStorageAt', |
||||||
|
'eth_getTransactionByBlockHashAndIndex', |
||||||
|
'eth_getTransactionByBlockNumberAndIndex', |
||||||
|
'eth_getTransactionByHash', |
||||||
|
'eth_getTransactionCount', |
||||||
|
'eth_getTransactionReceipt', |
||||||
|
'eth_getUncleByBlockHashAndIndex', |
||||||
|
'eth_getUncleByBlockNumberAndIndex', |
||||||
|
'eth_getUncleCountByBlockHash', |
||||||
|
'eth_getUncleCountByBlockNumber', |
||||||
|
'eth_getWork', |
||||||
|
'eth_hashrate', |
||||||
|
'eth_mining', |
||||||
|
'eth_newBlockFilter', |
||||||
|
'eth_newFilter', |
||||||
|
'eth_newPendingTransactionFilter', |
||||||
|
'eth_protocolVersion', |
||||||
|
'eth_sendRawTransaction', |
||||||
|
'eth_sendTransaction', |
||||||
|
'eth_sign', |
||||||
|
'eth_signTypedData', |
||||||
|
'eth_signTypedData_v1', |
||||||
|
'eth_signTypedData_v3', |
||||||
|
'eth_signTypedData_v4', |
||||||
|
'eth_submitHashrate', |
||||||
|
'eth_submitWork', |
||||||
|
'eth_syncing', |
||||||
|
'eth_uninstallFilter', |
||||||
|
'metamask_getProviderState', |
||||||
|
'metamask_watchAsset', |
||||||
|
'net_listening', |
||||||
|
'net_peerCount', |
||||||
|
'net_version', |
||||||
|
'personal_ecRecover', |
||||||
|
'personal_sign', |
||||||
|
'wallet_watchAsset', |
||||||
|
'web3_clientVersion', |
||||||
|
'web3_sha3', |
||||||
|
]); |
@ -0,0 +1,340 @@ |
|||||||
|
import { |
||||||
|
CaveatTypes, |
||||||
|
RestrictedMethods, |
||||||
|
} from '../../../../shared/constants/permissions'; |
||||||
|
import { |
||||||
|
getCaveatSpecifications, |
||||||
|
getPermissionSpecifications, |
||||||
|
unrestrictedMethods, |
||||||
|
} from './specifications'; |
||||||
|
|
||||||
|
// Note: This causes Date.now() to return the number 1.
|
||||||
|
jest.useFakeTimers('modern').setSystemTime(1); |
||||||
|
|
||||||
|
describe('PermissionController specifications', () => { |
||||||
|
describe('caveat specifications', () => { |
||||||
|
it('getCaveatSpecifications returns the expected specifications object', () => { |
||||||
|
const caveatSpecifications = getCaveatSpecifications({}); |
||||||
|
expect(Object.keys(caveatSpecifications)).toHaveLength(1); |
||||||
|
expect( |
||||||
|
caveatSpecifications[CaveatTypes.restrictReturnedAccounts].type, |
||||||
|
).toStrictEqual(CaveatTypes.restrictReturnedAccounts); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('restrictReturnedAccounts', () => { |
||||||
|
describe('decorator', () => { |
||||||
|
it('returns the first array member included in the caveat value', async () => { |
||||||
|
const getIdentities = jest.fn(); |
||||||
|
const { decorator } = getCaveatSpecifications({ getIdentities })[ |
||||||
|
CaveatTypes.restrictReturnedAccounts |
||||||
|
]; |
||||||
|
|
||||||
|
const method = async () => ['0x1', '0x2', '0x3']; |
||||||
|
const caveat = { |
||||||
|
type: CaveatTypes.restrictReturnedAccounts, |
||||||
|
value: ['0x1', '0x2'], |
||||||
|
}; |
||||||
|
const decorated = decorator(method, caveat); |
||||||
|
expect(await decorated()).toStrictEqual(['0x1']); |
||||||
|
}); |
||||||
|
|
||||||
|
it('returns an empty array if no array members are included in the caveat value', async () => { |
||||||
|
const getIdentities = jest.fn(); |
||||||
|
const { decorator } = getCaveatSpecifications({ getIdentities })[ |
||||||
|
CaveatTypes.restrictReturnedAccounts |
||||||
|
]; |
||||||
|
|
||||||
|
const method = async () => ['0x1', '0x2', '0x3']; |
||||||
|
const caveat = { |
||||||
|
type: CaveatTypes.restrictReturnedAccounts, |
||||||
|
value: ['0x5'], |
||||||
|
}; |
||||||
|
const decorated = decorator(method, caveat); |
||||||
|
expect(await decorated()).toStrictEqual([]); |
||||||
|
}); |
||||||
|
|
||||||
|
it('returns an empty array if the method result is an empty array', async () => { |
||||||
|
const getIdentities = jest.fn(); |
||||||
|
const { decorator } = getCaveatSpecifications({ getIdentities })[ |
||||||
|
CaveatTypes.restrictReturnedAccounts |
||||||
|
]; |
||||||
|
|
||||||
|
const method = async () => []; |
||||||
|
const caveat = { |
||||||
|
type: CaveatTypes.restrictReturnedAccounts, |
||||||
|
value: ['0x1', '0x2'], |
||||||
|
}; |
||||||
|
const decorated = decorator(method, caveat); |
||||||
|
expect(await decorated()).toStrictEqual([]); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('validator', () => { |
||||||
|
it('rejects invalid array values', () => { |
||||||
|
const getIdentities = jest.fn(); |
||||||
|
const { validator } = getCaveatSpecifications({ getIdentities })[ |
||||||
|
CaveatTypes.restrictReturnedAccounts |
||||||
|
]; |
||||||
|
|
||||||
|
[null, 'foo', {}, []].forEach((invalidValue) => { |
||||||
|
expect(() => validator({ value: invalidValue })).toThrow( |
||||||
|
/Expected non-empty array of Ethereum addresses\.$/u, |
||||||
|
); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
it('rejects falsy or non-string addresses', () => { |
||||||
|
const getIdentities = jest.fn(); |
||||||
|
const { validator } = getCaveatSpecifications({ getIdentities })[ |
||||||
|
CaveatTypes.restrictReturnedAccounts |
||||||
|
]; |
||||||
|
|
||||||
|
[[{}], [[]], [null], ['']].forEach((invalidValue) => { |
||||||
|
expect(() => validator({ value: invalidValue })).toThrow( |
||||||
|
/Expected an array of Ethereum addresses. Received:/u, |
||||||
|
); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
it('rejects addresses that have no corresponding identity', () => { |
||||||
|
const getIdentities = jest.fn().mockImplementationOnce(() => { |
||||||
|
return { |
||||||
|
'0x1': true, |
||||||
|
'0x3': true, |
||||||
|
}; |
||||||
|
}); |
||||||
|
|
||||||
|
const { validator } = getCaveatSpecifications({ getIdentities })[ |
||||||
|
CaveatTypes.restrictReturnedAccounts |
||||||
|
]; |
||||||
|
|
||||||
|
expect(() => validator({ value: ['0x1', '0x2', '0x3'] })).toThrow( |
||||||
|
/Received unrecognized address:/u, |
||||||
|
); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('permission specifications', () => { |
||||||
|
it('getPermissionSpecifications returns the expected specifications object', () => { |
||||||
|
const permissionSpecifications = getPermissionSpecifications({}); |
||||||
|
expect(Object.keys(permissionSpecifications)).toHaveLength(1); |
||||||
|
expect( |
||||||
|
permissionSpecifications[RestrictedMethods.eth_accounts].targetKey, |
||||||
|
).toStrictEqual(RestrictedMethods.eth_accounts); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('eth_accounts', () => { |
||||||
|
describe('factory', () => { |
||||||
|
it('constructs a valid eth_accounts permission', () => { |
||||||
|
const getIdentities = jest.fn(); |
||||||
|
const getAllAccounts = jest.fn(); |
||||||
|
const { factory } = getPermissionSpecifications({ |
||||||
|
getIdentities, |
||||||
|
getAllAccounts, |
||||||
|
})[RestrictedMethods.eth_accounts]; |
||||||
|
|
||||||
|
expect( |
||||||
|
factory( |
||||||
|
{ invoker: 'foo.bar', target: 'eth_accounts' }, |
||||||
|
{ approvedAccounts: ['0x1'] }, |
||||||
|
), |
||||||
|
).toStrictEqual({ |
||||||
|
caveats: [ |
||||||
|
{ |
||||||
|
type: CaveatTypes.restrictReturnedAccounts, |
||||||
|
value: ['0x1'], |
||||||
|
}, |
||||||
|
], |
||||||
|
date: 1, |
||||||
|
id: expect.any(String), |
||||||
|
invoker: 'foo.bar', |
||||||
|
parentCapability: 'eth_accounts', |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
it('throws an error if no approvedAccounts are specified', () => { |
||||||
|
const getIdentities = jest.fn(); |
||||||
|
const getAllAccounts = jest.fn(); |
||||||
|
const { factory } = getPermissionSpecifications({ |
||||||
|
getIdentities, |
||||||
|
getAllAccounts, |
||||||
|
})[RestrictedMethods.eth_accounts]; |
||||||
|
|
||||||
|
expect(() => |
||||||
|
factory( |
||||||
|
{ invoker: 'foo.bar', target: 'eth_accounts' }, |
||||||
|
{}, // no approvedAccounts
|
||||||
|
), |
||||||
|
).toThrow(/No approved accounts specified\.$/u); |
||||||
|
}); |
||||||
|
|
||||||
|
it('throws an error if any caveats are specified directly', () => { |
||||||
|
const getIdentities = jest.fn(); |
||||||
|
const getAllAccounts = jest.fn(); |
||||||
|
const { factory } = getPermissionSpecifications({ |
||||||
|
getIdentities, |
||||||
|
getAllAccounts, |
||||||
|
})[RestrictedMethods.eth_accounts]; |
||||||
|
|
||||||
|
expect(() => |
||||||
|
factory( |
||||||
|
{ |
||||||
|
caveats: [ |
||||||
|
{ |
||||||
|
type: CaveatTypes.restrictReturnedAccounts, |
||||||
|
value: ['0x1', '0x2'], |
||||||
|
}, |
||||||
|
], |
||||||
|
invoker: 'foo.bar', |
||||||
|
target: 'eth_accounts', |
||||||
|
}, |
||||||
|
{ approvedAccounts: ['0x1'] }, |
||||||
|
), |
||||||
|
).toThrow(/Received unexpected caveats./u); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('methodImplementation', () => { |
||||||
|
it('returns the keyring accounts in lastSelected order', async () => { |
||||||
|
const getIdentities = jest.fn().mockImplementationOnce(() => { |
||||||
|
return { |
||||||
|
'0x1': { |
||||||
|
lastSelected: 1, |
||||||
|
}, |
||||||
|
'0x2': {}, |
||||||
|
'0x3': { |
||||||
|
lastSelected: 3, |
||||||
|
}, |
||||||
|
'0x4': { |
||||||
|
lastSelected: 3, |
||||||
|
}, |
||||||
|
}; |
||||||
|
}); |
||||||
|
const getAllAccounts = jest |
||||||
|
.fn() |
||||||
|
.mockImplementationOnce(() => ['0x1', '0x2', '0x3', '0x4']); |
||||||
|
|
||||||
|
const { methodImplementation } = getPermissionSpecifications({ |
||||||
|
getIdentities, |
||||||
|
getAllAccounts, |
||||||
|
})[RestrictedMethods.eth_accounts]; |
||||||
|
|
||||||
|
expect(await methodImplementation()).toStrictEqual([ |
||||||
|
'0x3', |
||||||
|
'0x4', |
||||||
|
'0x1', |
||||||
|
'0x2', |
||||||
|
]); |
||||||
|
}); |
||||||
|
|
||||||
|
it('throws if a keyring account is missing an address (case 1)', async () => { |
||||||
|
const getIdentities = jest.fn().mockImplementationOnce(() => { |
||||||
|
return { |
||||||
|
'0x2': { |
||||||
|
lastSelected: 3, |
||||||
|
}, |
||||||
|
'0x3': { |
||||||
|
lastSelected: 3, |
||||||
|
}, |
||||||
|
}; |
||||||
|
}); |
||||||
|
const getAllAccounts = jest |
||||||
|
.fn() |
||||||
|
.mockImplementationOnce(() => ['0x1', '0x2', '0x3']); |
||||||
|
|
||||||
|
const { methodImplementation } = getPermissionSpecifications({ |
||||||
|
getIdentities, |
||||||
|
getAllAccounts, |
||||||
|
})[RestrictedMethods.eth_accounts]; |
||||||
|
|
||||||
|
await expect(() => methodImplementation()).rejects.toThrow( |
||||||
|
'Missing identity for address: "0x1".', |
||||||
|
); |
||||||
|
}); |
||||||
|
|
||||||
|
it('throws if a keyring account is missing an address (case 2)', async () => { |
||||||
|
const getIdentities = jest.fn().mockImplementationOnce(() => { |
||||||
|
return { |
||||||
|
'0x1': { |
||||||
|
lastSelected: 1, |
||||||
|
}, |
||||||
|
'0x3': { |
||||||
|
lastSelected: 3, |
||||||
|
}, |
||||||
|
}; |
||||||
|
}); |
||||||
|
const getAllAccounts = jest |
||||||
|
.fn() |
||||||
|
.mockImplementationOnce(() => ['0x1', '0x2', '0x3']); |
||||||
|
|
||||||
|
const { methodImplementation } = getPermissionSpecifications({ |
||||||
|
getIdentities, |
||||||
|
getAllAccounts, |
||||||
|
})[RestrictedMethods.eth_accounts]; |
||||||
|
|
||||||
|
await expect(() => methodImplementation()).rejects.toThrow( |
||||||
|
'Missing identity for address: "0x2".', |
||||||
|
); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('validator', () => { |
||||||
|
it('accepts valid permissions', () => { |
||||||
|
const getIdentities = jest.fn(); |
||||||
|
const getAllAccounts = jest.fn(); |
||||||
|
const { validator } = getPermissionSpecifications({ |
||||||
|
getIdentities, |
||||||
|
getAllAccounts, |
||||||
|
})[RestrictedMethods.eth_accounts]; |
||||||
|
|
||||||
|
expect(() => |
||||||
|
validator({ |
||||||
|
caveats: [ |
||||||
|
{ |
||||||
|
type: CaveatTypes.restrictReturnedAccounts, |
||||||
|
value: ['0x1', '0x2'], |
||||||
|
}, |
||||||
|
], |
||||||
|
date: 1, |
||||||
|
id: expect.any(String), |
||||||
|
invoker: 'foo.bar', |
||||||
|
parentCapability: 'eth_accounts', |
||||||
|
}), |
||||||
|
).not.toThrow(); |
||||||
|
}); |
||||||
|
|
||||||
|
it('rejects invalid caveats', () => { |
||||||
|
const getIdentities = jest.fn(); |
||||||
|
const getAllAccounts = jest.fn(); |
||||||
|
const { validator } = getPermissionSpecifications({ |
||||||
|
getIdentities, |
||||||
|
getAllAccounts, |
||||||
|
})[RestrictedMethods.eth_accounts]; |
||||||
|
|
||||||
|
[null, [], [1, 2], [{ type: 'foobar' }]].forEach( |
||||||
|
(invalidCaveatsValue) => { |
||||||
|
expect(() => |
||||||
|
validator({ |
||||||
|
caveats: invalidCaveatsValue, |
||||||
|
date: 1, |
||||||
|
id: expect.any(String), |
||||||
|
invoker: 'foo.bar', |
||||||
|
parentCapability: 'eth_accounts', |
||||||
|
}), |
||||||
|
).toThrow(/Invalid caveats./u); |
||||||
|
}, |
||||||
|
); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('unrestricted methods', () => { |
||||||
|
it('defines the unrestricted methods', () => { |
||||||
|
expect(Array.isArray(unrestrictedMethods)).toBe(true); |
||||||
|
expect(Object.isFrozen(unrestrictedMethods)).toBe(true); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
@ -1,34 +1,36 @@ |
|||||||
import { strict as assert } from 'assert'; |
|
||||||
import cleanErrorStack from './cleanErrorStack'; |
import cleanErrorStack from './cleanErrorStack'; |
||||||
|
|
||||||
describe('Clean Error Stack', function () { |
describe('Clean Error Stack', () => { |
||||||
const testMessage = 'Test Message'; |
const testMessage = 'Test Message'; |
||||||
const testError = new Error(testMessage); |
const testError = new Error(testMessage); |
||||||
const undefinedErrorName = new Error(testMessage); |
const undefinedErrorName = new Error(testMessage); |
||||||
const blankErrorName = new Error(testMessage); |
const blankErrorName = new Error(testMessage); |
||||||
const blankMsgError = new Error(); |
const blankMsgError = new Error(); |
||||||
|
|
||||||
beforeEach(function () { |
beforeEach(() => { |
||||||
undefinedErrorName.name = undefined; |
undefinedErrorName.name = undefined; |
||||||
blankErrorName.name = ''; |
blankErrorName.name = ''; |
||||||
}); |
}); |
||||||
|
|
||||||
it('tests error with message', function () { |
it('tests error with message', () => { |
||||||
assert.equal(cleanErrorStack(testError).toString(), 'Error: Test Message'); |
expect(cleanErrorStack(testError).toString()).toStrictEqual( |
||||||
|
'Error: Test Message', |
||||||
|
); |
||||||
}); |
}); |
||||||
|
|
||||||
it('tests error with undefined name', function () { |
it('tests error with undefined name', () => { |
||||||
assert.equal( |
expect(cleanErrorStack(undefinedErrorName).toString()).toStrictEqual( |
||||||
cleanErrorStack(undefinedErrorName).toString(), |
|
||||||
'Error: Test Message', |
'Error: Test Message', |
||||||
); |
); |
||||||
}); |
}); |
||||||
|
|
||||||
it('tests error with blank name', function () { |
it('tests error with blank name', () => { |
||||||
assert.equal(cleanErrorStack(blankErrorName).toString(), 'Test Message'); |
expect(cleanErrorStack(blankErrorName).toString()).toStrictEqual( |
||||||
|
'Test Message', |
||||||
|
); |
||||||
}); |
}); |
||||||
|
|
||||||
it('tests error with blank message', function () { |
it('tests error with blank message', () => { |
||||||
assert.equal(cleanErrorStack(blankMsgError).toString(), 'Error'); |
expect(cleanErrorStack(blankMsgError).toString()).toStrictEqual('Error'); |
||||||
}); |
}); |
||||||
}); |
}); |
||||||
|
@ -1,38 +0,0 @@ |
|||||||
import promiseToCallback from 'promise-to-callback'; |
|
||||||
|
|
||||||
const callbackNoop = function (err) { |
|
||||||
if (err) { |
|
||||||
throw err; |
|
||||||
} |
|
||||||
}; |
|
||||||
|
|
||||||
/** |
|
||||||
* A generator that returns a function which, when passed a promise, can treat that promise as a node style callback. |
|
||||||
* The prime advantage being that callbacks are better for error handling. |
|
||||||
* |
|
||||||
* @param {Function} fn - The function to handle as a callback |
|
||||||
* @param {Object} context - The context in which the fn is to be called, most often a this reference |
|
||||||
* |
|
||||||
*/ |
|
||||||
export default function nodeify(fn, context) { |
|
||||||
return function (...args) { |
|
||||||
const lastArg = args[args.length - 1]; |
|
||||||
const lastArgIsCallback = typeof lastArg === 'function'; |
|
||||||
let callback; |
|
||||||
if (lastArgIsCallback) { |
|
||||||
callback = lastArg; |
|
||||||
args.pop(); |
|
||||||
} else { |
|
||||||
callback = callbackNoop; |
|
||||||
} |
|
||||||
// call the provided function and ensure result is a promise
|
|
||||||
let result; |
|
||||||
try { |
|
||||||
result = Promise.resolve(fn.apply(context, args)); |
|
||||||
} catch (err) { |
|
||||||
result = Promise.reject(err); |
|
||||||
} |
|
||||||
// wire up promise resolution to callback
|
|
||||||
promiseToCallback(result)(callback); |
|
||||||
}; |
|
||||||
} |
|
@ -1,74 +0,0 @@ |
|||||||
import { strict as assert } from 'assert'; |
|
||||||
import nodeify from './nodeify'; |
|
||||||
|
|
||||||
describe('nodeify', function () { |
|
||||||
const obj = { |
|
||||||
foo: 'bar', |
|
||||||
promiseFunc(a) { |
|
||||||
const solution = this.foo + a; |
|
||||||
return Promise.resolve(solution); |
|
||||||
}, |
|
||||||
}; |
|
||||||
|
|
||||||
it('should retain original context', function (done) { |
|
||||||
const nodified = nodeify(obj.promiseFunc, obj); |
|
||||||
nodified('baz', (err, res) => { |
|
||||||
if (!err) { |
|
||||||
assert.equal(res, 'barbaz'); |
|
||||||
done(); |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
done(new Error(err.toString())); |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
it('no callback - should allow the last argument to not be a function', function (done) { |
|
||||||
const nodified = nodeify(obj.promiseFunc, obj); |
|
||||||
try { |
|
||||||
nodified('baz'); |
|
||||||
done(); |
|
||||||
} catch (err) { |
|
||||||
done( |
|
||||||
new Error( |
|
||||||
'should not have thrown if the last argument is not a function', |
|
||||||
), |
|
||||||
); |
|
||||||
} |
|
||||||
}); |
|
||||||
|
|
||||||
it('sync functions - returns value', function (done) { |
|
||||||
const nodified = nodeify(() => 42); |
|
||||||
try { |
|
||||||
nodified((err, result) => { |
|
||||||
if (err) { |
|
||||||
done(new Error(`should not have thrown any error: ${err.message}`)); |
|
||||||
return; |
|
||||||
} |
|
||||||
assert.equal(42, result, 'got expected result'); |
|
||||||
}); |
|
||||||
done(); |
|
||||||
} catch (err) { |
|
||||||
done(new Error(`should not have thrown any error: ${err.message}`)); |
|
||||||
} |
|
||||||
}); |
|
||||||
|
|
||||||
it('sync functions - handles errors', function (done) { |
|
||||||
const nodified = nodeify(() => { |
|
||||||
throw new Error('boom!'); |
|
||||||
}); |
|
||||||
try { |
|
||||||
nodified((err, result) => { |
|
||||||
if (result) { |
|
||||||
done(new Error('should not have returned any result')); |
|
||||||
return; |
|
||||||
} |
|
||||||
assert.ok(err, 'got expected error'); |
|
||||||
assert.ok(err.message.includes('boom!'), 'got expected error message'); |
|
||||||
}); |
|
||||||
done(); |
|
||||||
} catch (err) { |
|
||||||
done(new Error(`should not have thrown any error: ${err.message}`)); |
|
||||||
} |
|
||||||
}); |
|
||||||
}); |
|
@ -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(); |
||||||
|
} |
@ -1,14 +1,20 @@ |
|||||||
import addEthereumChain from './add-ethereum-chain'; |
import addEthereumChain from './add-ethereum-chain'; |
||||||
import switchEthereumChain from './switch-ethereum-chain'; |
import ethAccounts from './eth-accounts'; |
||||||
import getProviderState from './get-provider-state'; |
import getProviderState from './get-provider-state'; |
||||||
import logWeb3ShimUsage from './log-web3-shim-usage'; |
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'; |
import watchAsset from './watch-asset'; |
||||||
|
|
||||||
const handlers = [ |
const handlers = [ |
||||||
addEthereumChain, |
addEthereumChain, |
||||||
switchEthereumChain, |
ethAccounts, |
||||||
getProviderState, |
getProviderState, |
||||||
logWeb3ShimUsage, |
logWeb3ShimUsage, |
||||||
|
requestAccounts, |
||||||
|
sendMetadata, |
||||||
|
switchEthereumChain, |
||||||
watchAsset, |
watchAsset, |
||||||
]; |
]; |
||||||
export default handlers; |
export default handlers; |
||||||
|
@ -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(); |
||||||
|
} |
@ -0,0 +1,58 @@ |
|||||||
|
import { ethErrors } from 'eth-rpc-errors'; |
||||||
|
import { MESSAGE_TYPE } from '../../../../../shared/constants/app'; |
||||||
|
|
||||||
|
/** |
||||||
|
* This internal method is used by our external provider to send metadata about |
||||||
|
* permission subjects so that we can e.g. display a proper name and icon in |
||||||
|
* our UI. |
||||||
|
*/ |
||||||
|
|
||||||
|
const sendMetadata = { |
||||||
|
methodNames: [MESSAGE_TYPE.SEND_METADATA], |
||||||
|
implementation: sendMetadataHandler, |
||||||
|
hookNames: { |
||||||
|
addSubjectMetadata: true, |
||||||
|
subjectType: true, |
||||||
|
}, |
||||||
|
}; |
||||||
|
export default sendMetadata; |
||||||
|
|
||||||
|
/** |
||||||
|
* @typedef {Record<string, Function>} SendMetadataOptions |
||||||
|
* @property {Function} addSubjectMetadata - A function that records subject |
||||||
|
* metadata, bound to the requesting origin. |
||||||
|
* @property {string} subjectType - The type of the requesting origin / subject. |
||||||
|
*/ |
||||||
|
|
||||||
|
/** |
||||||
|
* @param {import('json-rpc-engine').JsonRpcRequest<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, subjectType }, |
||||||
|
) { |
||||||
|
const { origin, params } = req; |
||||||
|
if (params && typeof params === 'object' && !Array.isArray(params)) { |
||||||
|
const { icon = null, name = null, ...remainingParams } = params; |
||||||
|
|
||||||
|
addSubjectMetadata({ |
||||||
|
...remainingParams, |
||||||
|
iconUrl: icon, |
||||||
|
name, |
||||||
|
subjectType, |
||||||
|
origin, |
||||||
|
}); |
||||||
|
} else { |
||||||
|
return end(ethErrors.rpc.invalidParams({ data: params })); |
||||||
|
} |
||||||
|
|
||||||
|
res.result = true; |
||||||
|
return end(); |
||||||
|
} |
@ -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, |
||||||
|
}; |
||||||
|
} |
@ -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, |
||||||
|
}, |
||||||
|
], |
||||||
|
}, |
||||||
|
}; |
||||||
|
} |
@ -0,0 +1,41 @@ |
|||||||
|
import { cloneDeep } from 'lodash'; |
||||||
|
import { SUBJECT_TYPES } from '../../../shared/constants/app'; |
||||||
|
|
||||||
|
const version = 69; |
||||||
|
|
||||||
|
/** |
||||||
|
* Adds the `subjectType` property to all subject metadata. |
||||||
|
*/ |
||||||
|
export default { |
||||||
|
version, |
||||||
|
async migrate(originalVersionedData) { |
||||||
|
const versionedData = cloneDeep(originalVersionedData); |
||||||
|
versionedData.meta.version = version; |
||||||
|
const state = versionedData.data; |
||||||
|
const newState = transformState(state); |
||||||
|
versionedData.data = newState; |
||||||
|
return versionedData; |
||||||
|
}, |
||||||
|
}; |
||||||
|
|
||||||
|
function transformState(state) { |
||||||
|
if (typeof state?.SubjectMetadataController?.subjectMetadata === 'object') { |
||||||
|
const { |
||||||
|
SubjectMetadataController: { subjectMetadata }, |
||||||
|
} = state; |
||||||
|
|
||||||
|
// mutate SubjectMetadataController.subjectMetadata in place
|
||||||
|
Object.values(subjectMetadata).forEach((metadata) => { |
||||||
|
if ( |
||||||
|
metadata && |
||||||
|
typeof metadata === 'object' && |
||||||
|
!Array.isArray(metadata) |
||||||
|
) { |
||||||
|
metadata.subjectType = metadata.extensionId |
||||||
|
? SUBJECT_TYPES.EXTENSION |
||||||
|
: SUBJECT_TYPES.WEBSITE; |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
return state; |
||||||
|
} |
@ -0,0 +1,102 @@ |
|||||||
|
import { SUBJECT_TYPES } from '../../../shared/constants/app'; |
||||||
|
import migration69 from './069'; |
||||||
|
|
||||||
|
describe('migration #69', () => { |
||||||
|
it('should update the version metadata', async () => { |
||||||
|
const oldStorage = { |
||||||
|
meta: { |
||||||
|
version: 68, |
||||||
|
}, |
||||||
|
data: {}, |
||||||
|
}; |
||||||
|
|
||||||
|
const newStorage = await migration69.migrate(oldStorage); |
||||||
|
expect(newStorage.meta).toStrictEqual({ |
||||||
|
version: 69, |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should migrate all data', async () => { |
||||||
|
const oldStorage = { |
||||||
|
meta: { |
||||||
|
version: 68, |
||||||
|
}, |
||||||
|
data: { |
||||||
|
FooController: { a: 'b' }, |
||||||
|
SubjectMetadataController: { |
||||||
|
subjectMetadata: { |
||||||
|
'https://1inch.exchange': { |
||||||
|
iconUrl: |
||||||
|
'https://1inch.exchange/assets/favicon/favicon-32x32.png', |
||||||
|
name: 'DEX Aggregator - 1inch.exchange', |
||||||
|
origin: 'https://1inch.exchange', |
||||||
|
extensionId: null, |
||||||
|
}, |
||||||
|
'https://ascii-tree-generator.com': { |
||||||
|
iconUrl: 'https://ascii-tree-generator.com/favicon.ico', |
||||||
|
name: 'ASCII Tree Generator', |
||||||
|
origin: 'https://ascii-tree-generator.com', |
||||||
|
extensionId: 'ascii-tree-generator-extension', |
||||||
|
}, |
||||||
|
'https://null.com': null, |
||||||
|
'https://foo.com': 'bad data', |
||||||
|
'https://bar.com': ['bad data'], |
||||||
|
}, |
||||||
|
}, |
||||||
|
}, |
||||||
|
}; |
||||||
|
|
||||||
|
const newStorage = await migration69.migrate(oldStorage); |
||||||
|
expect(newStorage).toStrictEqual({ |
||||||
|
meta: { |
||||||
|
version: 69, |
||||||
|
}, |
||||||
|
data: { |
||||||
|
FooController: { a: 'b' }, |
||||||
|
SubjectMetadataController: { |
||||||
|
subjectMetadata: { |
||||||
|
'https://1inch.exchange': { |
||||||
|
iconUrl: |
||||||
|
'https://1inch.exchange/assets/favicon/favicon-32x32.png', |
||||||
|
name: 'DEX Aggregator - 1inch.exchange', |
||||||
|
origin: 'https://1inch.exchange', |
||||||
|
extensionId: null, |
||||||
|
subjectType: SUBJECT_TYPES.WEBSITE, |
||||||
|
}, |
||||||
|
'https://ascii-tree-generator.com': { |
||||||
|
iconUrl: 'https://ascii-tree-generator.com/favicon.ico', |
||||||
|
name: 'ASCII Tree Generator', |
||||||
|
origin: 'https://ascii-tree-generator.com', |
||||||
|
extensionId: 'ascii-tree-generator-extension', |
||||||
|
subjectType: SUBJECT_TYPES.EXTENSION, |
||||||
|
}, |
||||||
|
'https://null.com': null, |
||||||
|
'https://foo.com': 'bad data', |
||||||
|
'https://bar.com': ['bad data'], |
||||||
|
}, |
||||||
|
}, |
||||||
|
}, |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should handle missing SubjectMetadataController', async () => { |
||||||
|
const oldStorage = { |
||||||
|
meta: { |
||||||
|
version: 68, |
||||||
|
}, |
||||||
|
data: { |
||||||
|
FooController: { a: 'b' }, |
||||||
|
}, |
||||||
|
}; |
||||||
|
|
||||||
|
const newStorage = await migration69.migrate(oldStorage); |
||||||
|
expect(newStorage).toStrictEqual({ |
||||||
|
meta: { |
||||||
|
version: 69, |
||||||
|
}, |
||||||
|
data: { |
||||||
|
FooController: { a: 'b' }, |
||||||
|
}, |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
@ -0,0 +1,13 @@ |
|||||||
|
"project_id_env": CROWDIN_PROJECT_ID |
||||||
|
"api_token_env": CROWDIN_PERSONAL_TOKEN |
||||||
|
"base_path" : "." |
||||||
|
"base_url" : "https://metamask.crowdin.com" |
||||||
|
|
||||||
|
"preserve_hierarchy": true |
||||||
|
|
||||||
|
files: [ |
||||||
|
{ |
||||||
|
"source" : "app/_locales/en/messages.json", |
||||||
|
"translation" : "/app/_locales/%two_letters_code%/%original_file_name%", |
||||||
|
} |
||||||
|
] |