diff --git a/app/scripts/controllers/permissions/enums.js b/app/scripts/controllers/permissions/enums.js new file mode 100644 index 000000000..1e68eddbe --- /dev/null +++ b/app/scripts/controllers/permissions/enums.js @@ -0,0 +1,72 @@ + +export const WALLET_PREFIX = 'wallet_' + +export const HISTORY_STORE_KEY = 'permissionsHistory' + +export const LOG_STORE_KEY = 'permissionsLog' + +export const METADATA_STORE_KEY = 'domainMetadata' + +export const CAVEAT_NAMES = { + exposedAccounts: 'exposedAccounts', +} + +export const NOTIFICATION_NAMES = { + accountsChanged: 'wallet_accountsChanged', +} + +export const LOG_IGNORE_METHODS = [ + 'wallet_sendDomainMetadata', +] + +export const SAFE_METHODS = [ + 'web3_sha3', + 'net_listening', + 'net_peerCount', + 'net_version', + 'eth_blockNumber', + 'eth_call', + 'eth_chainId', + 'eth_coinbase', + 'eth_estimateGas', + 'eth_gasPrice', + 'eth_getBalance', + 'eth_getBlockByHash', + 'eth_getBlockByNumber', + 'eth_getBlockTransactionCountByHash', + 'eth_getBlockTransactionCountByNumber', + 'eth_getCode', + 'eth_getFilterChanges', + 'eth_getFilterLogs', + 'eth_getLogs', + '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', + 'personal_sign', + 'eth_signTypedData', + 'eth_signTypedData_v1', + 'eth_signTypedData_v3', + 'eth_submitHashrate', + 'eth_submitWork', + 'eth_syncing', + 'eth_uninstallFilter', + 'metamask_watchAsset', + 'wallet_watchAsset', +] diff --git a/app/scripts/controllers/permissions/index.js b/app/scripts/controllers/permissions/index.js index 5b7a1318f..ec45d9b2e 100644 --- a/app/scripts/controllers/permissions/index.js +++ b/app/scripts/controllers/permissions/index.js @@ -4,42 +4,46 @@ import ObservableStore from 'obs-store' import log from 'loglevel' import { CapabilitiesController as RpcCap } from 'rpc-cap' import { ethErrors } from 'eth-json-rpc-errors' + import getRestrictedMethods from './restrictedMethods' import createMethodMiddleware from './methodMiddleware' -import createLoggerMiddleware from './loggerMiddleware' +import PermissionsLogController from './permissionsLog' // Methods that do not require any permissions to use: -import SAFE_METHODS from './permissions-safe-methods.json' - -// some constants -const METADATA_STORE_KEY = 'domainMetadata' -const LOG_STORE_KEY = 'permissionsLog' -const HISTORY_STORE_KEY = 'permissionsHistory' -const WALLET_METHOD_PREFIX = 'wallet_' -const ACCOUNTS_CHANGED_NOTIFICATION = 'wallet_accountsChanged' - -export const CAVEAT_NAMES = { - exposedAccounts: 'exposedAccounts', -} +import { + SAFE_METHODS, // methods that do not require any permissions to use + WALLET_PREFIX, + METADATA_STORE_KEY, + LOG_STORE_KEY, + HISTORY_STORE_KEY, + CAVEAT_NAMES, + NOTIFICATION_NAMES, +} from './enums' export class PermissionsController { constructor ( { - platform, notifyDomain, notifyAllDomains, keyringController, + platform, notifyDomain, notifyAllDomains, getKeyringAccounts, } = {}, restoredPermissions = {}, restoredState = {}) { + this.store = new ObservableStore({ [METADATA_STORE_KEY]: restoredState[METADATA_STORE_KEY] || {}, [LOG_STORE_KEY]: restoredState[LOG_STORE_KEY] || [], [HISTORY_STORE_KEY]: restoredState[HISTORY_STORE_KEY] || {}, }) - this.notifyDomain = notifyDomain + + this._notifyDomain = notifyDomain this.notifyAllDomains = notifyAllDomains - this.keyringController = keyringController + this.getKeyringAccounts = getKeyringAccounts this._platform = platform this._restrictedMethods = getRestrictedMethods(this) + this.permissionsLogController = new PermissionsLogController({ + restrictedMethods: Object.keys(this._restrictedMethods), + store: this.store, + }) this._initializePermissions(restoredPermissions) } @@ -56,14 +60,7 @@ export class PermissionsController { const engine = new JsonRpcEngine() - engine.push(createLoggerMiddleware({ - walletPrefix: WALLET_METHOD_PREFIX, - restrictedMethods: Object.keys(this._restrictedMethods), - ignoreMethods: [ 'wallet_sendDomainMetadata' ], - store: this.store, - logStoreKey: LOG_STORE_KEY, - historyStoreKey: HISTORY_STORE_KEY, - })) + engine.push(this.permissionsLogController.createMiddleware()) engine.push(createMethodMiddleware({ store: this.store, @@ -107,7 +104,7 @@ export class PermissionsController { } /** - * Submits a permissions request to rpc-cap. Internal use only. + * Submits a permissions request to rpc-cap. Internal, background use only. * * @param {string} origin - The origin string. * @param {IRequestedPermissions} permissions - The requested permissions. @@ -222,22 +219,26 @@ export class PermissionsController { } /** - * Update the accounts exposed to the given origin. + * Update the accounts exposed to the given origin. Changes the eth_accounts + * permissions and emits accountsChanged. + * At least one account must be exposed. If no accounts are to be exposed, the + * eth_accounts permissions should be removed completely. + * * Throws error if the update fails. * * @param {string} origin - The origin to change the exposed accounts for. * @param {string[]} accounts - The new account(s) to expose. */ - async updateExposedAccounts (origin, accounts) { + async updatePermittedAccounts (origin, accounts) { - await this.validateExposedAccounts(accounts) + await this.validatePermittedAccounts(accounts) this.permissions.updateCaveatFor( origin, 'eth_accounts', CAVEAT_NAMES.exposedAccounts, accounts ) this.notifyDomain(origin, { - method: ACCOUNTS_CHANGED_NOTIFICATION, + method: NOTIFICATION_NAMES.accountsChanged, result: accounts, }) } @@ -255,7 +256,7 @@ export class PermissionsController { if (ethAccounts) { - await this.validateExposedAccounts(accounts) + await this.validatePermittedAccounts(accounts) if (!ethAccounts.caveats) { ethAccounts.caveats = [] @@ -282,14 +283,14 @@ export class PermissionsController { * * @param {string[]} accounts - An array of addresses. */ - async validateExposedAccounts (accounts) { + async validatePermittedAccounts (accounts) { if (!Array.isArray(accounts) || accounts.length === 0) { throw new Error('Must provide non-empty array of account(s).') } // assert accounts exist - const allAccounts = await this.keyringController.getAccounts() + const allAccounts = await this.getKeyringAccounts() accounts.forEach(acc => { if (!allAccounts.includes(acc)) { throw new Error(`Unknown account: ${acc}`) @@ -297,6 +298,29 @@ export class PermissionsController { }) } + notifyDomain (origin, payload) { + + // if the accounts changed from the perspective of the dapp, + // update "last seen" time for the origin and account(s) + // exception: no accounts -> no times to update + if ( + payload.method === NOTIFICATION_NAMES.accountsChanged && + Array.isArray(payload.result) + ) { + this.permissionsLogController.updateAccountsHistory( + origin, payload.result + ) + } + + this._notifyDomain(origin, payload) + + // NOTE: + // we don't check for accounts changing in the notifyAllDomains case, + // because the log only records when accounts were last seen, + // and the accounts only change for all domains at once when permissions + // are removed + } + /** * Removes the given permissions for the given domain. * @param {Object} domains { origin: [permissions] } @@ -312,7 +336,7 @@ export class PermissionsController { if (methodName === 'eth_accounts') { this.notifyDomain( origin, - { method: ACCOUNTS_CHANGED_NOTIFICATION, result: [] } + { method: NOTIFICATION_NAMES.accountsChanged, result: [] } ) } @@ -322,13 +346,41 @@ export class PermissionsController { }) } + /** + * When a new account is selected in the UI for 'origin', emit accountsChanged + * to 'origin' if the selected account is permitted. + * @param {string} origin - The origin. + * @param {string} account - The newly selected account's address. + */ + async handleNewAccountSelected (origin, account) { + + const permittedAccounts = await this.getAccounts(origin) + + // do nothing if the account is not permitted for the origin, or + // if it's already first in the array of permitted accounts + if ( + !account || !permittedAccounts.includes(account) || + permittedAccounts[0] === account + ) { + return + } + + const newPermittedAccounts = [account].concat( + permittedAccounts.filter(_account => _account !== account) + ) + + // update permitted accounts to ensure that accounts are returned + // in the same order every time + this.updatePermittedAccounts(origin, newPermittedAccounts) + } + /** * Removes all known domains and their related permissions. */ clearPermissions () { this.permissions.clearDomains() this.notifyAllDomains({ - method: ACCOUNTS_CHANGED_NOTIFICATION, + method: NOTIFICATION_NAMES.accountsChanged, result: [], }) } @@ -352,7 +404,7 @@ export class PermissionsController { safeMethods: SAFE_METHODS, // optional prefix for internal methods - methodPrefix: WALLET_METHOD_PREFIX, + methodPrefix: WALLET_PREFIX, restrictedMethods: this._restrictedMethods, @@ -379,5 +431,5 @@ export class PermissionsController { } export function addInternalMethodPrefix (method) { - return WALLET_METHOD_PREFIX + method + return WALLET_PREFIX + method } diff --git a/app/scripts/controllers/permissions/loggerMiddleware.js b/app/scripts/controllers/permissions/loggerMiddleware.js deleted file mode 100644 index 3c9325442..000000000 --- a/app/scripts/controllers/permissions/loggerMiddleware.js +++ /dev/null @@ -1,168 +0,0 @@ -import clone from 'clone' -import { isValidAddress } from 'ethereumjs-util' - -const LOG_LIMIT = 100 - -/** - * Create middleware for logging requests and responses to restricted and - * permissions-related methods. - */ -export default function createLoggerMiddleware ({ - walletPrefix, restrictedMethods, store, logStoreKey, historyStoreKey, ignoreMethods, -}) { - return (req, res, next, _end) => { - let activityEntry, requestedMethods - const { origin, method } = req - const isInternal = method.startsWith(walletPrefix) - if ((isInternal || restrictedMethods.includes(method)) && !ignoreMethods.includes(method)) { - activityEntry = logActivity(req, isInternal) - if (method === `${walletPrefix}requestPermissions`) { - requestedMethods = getRequestedMethods(req) - } - } else if (method === 'eth_requestAccounts') { - activityEntry = logActivity(req, isInternal) - requestedMethods = [ 'eth_accounts' ] - } else { - return next() - } - - next(cb => { - const time = Date.now() - addResponse(activityEntry, res, time) - if (!res.error && requestedMethods) { - logHistory(requestedMethods, origin, res.result, time, method === 'eth_requestAccounts') - } - cb() - }) - } - - function logActivity (request, isInternal) { - const activityEntry = { - id: request.id, - method: request.method, - methodType: isInternal ? 'internal' : 'restricted', - origin: request.origin, - request: cloneObj(request), - requestTime: Date.now(), - response: null, - responseTime: null, - success: null, - } - commitActivity(activityEntry) - return activityEntry - } - - function addResponse (activityEntry, response, time) { - if (!response) { - return - } - activityEntry.response = cloneObj(response) - activityEntry.responseTime = time - activityEntry.success = !response.error - } - - function commitActivity (entry) { - const logs = store.getState()[logStoreKey] - if (logs.length > LOG_LIMIT - 2) { - logs.pop() - } - logs.push(entry) - store.updateState({ [logStoreKey]: logs }) - } - - function getRequestedMethods (request) { - if ( - !request.params || - typeof request.params[0] !== 'object' || - Array.isArray(request.params[0]) - ) { - return null - } - return Object.keys(request.params[0]) - } - - function logHistory (requestedMethods, origin, result, time, isEthRequestAccounts) { - let accounts, entries - if (isEthRequestAccounts) { - accounts = result - const accountToTimeMap = accounts.reduce((acc, account) => ({ ...acc, [account]: time }), {}) - entries = { 'eth_accounts': { accounts: accountToTimeMap, lastApproved: time } } - } else { - entries = result - ? result - .map(perm => { - if (perm.parentCapability === 'eth_accounts') { - accounts = getAccountsFromPermission(perm) - } - return perm.parentCapability - }) - .reduce((acc, m) => { - if (requestedMethods.includes(m)) { - if (m === 'eth_accounts') { - const accountToTimeMap = accounts.reduce((acc, account) => ({ ...acc, [account]: time }), {}) - acc[m] = { lastApproved: time, accounts: accountToTimeMap } - } else { - acc[m] = { lastApproved: time } - } - } - return acc - }, {}) - : {} - } - - if (Object.keys(entries).length > 0) { - commitHistory(origin, entries) - } - } - - function commitHistory (origin, entries) { - const history = store.getState()[historyStoreKey] || {} - const newOriginHistory = { - ...history[origin], - ...entries, - } - - if (history[origin] && history[origin]['eth_accounts'] && entries['eth_accounts']) { - newOriginHistory['eth_accounts'] = { - lastApproved: entries['eth_accounts'].lastApproved, - accounts: { - ...history[origin]['eth_accounts'].accounts, - ...entries['eth_accounts'].accounts, - }, - } - } - - history[origin] = newOriginHistory - - store.updateState({ [historyStoreKey]: history }) - } -} - -// the call to clone is set to disallow circular references -// we attempt cloning at a depth of 3 and 2, then return a -// shallow copy of the object -function cloneObj (obj) { - for (let i = 3; i > 1; i--) { - try { - return clone(obj, false, i) - } catch (_) {} - } - return { ...obj } -} - -function getAccountsFromPermission (perm) { - if (perm.parentCapability !== 'eth_accounts' || !perm.caveats) { - return [] - } - const accounts = {} - for (const c of perm.caveats) { - if (c.type === 'filterResponse' && Array.isArray(c.value)) { - for (const v of c.value) { - if (isValidAddress(v)) { - accounts[v] = true - } - } - } - } - return Object.keys(accounts) -} diff --git a/app/scripts/controllers/permissions/permissions-safe-methods.json b/app/scripts/controllers/permissions/permissions-safe-methods.json deleted file mode 100644 index 780a52ede..000000000 --- a/app/scripts/controllers/permissions/permissions-safe-methods.json +++ /dev/null @@ -1,51 +0,0 @@ -[ - "web3_sha3", - "net_listening", - "net_peerCount", - "net_version", - "eth_blockNumber", - "eth_call", - "eth_chainId", - "eth_coinbase", - "eth_estimateGas", - "eth_gasPrice", - "eth_getBalance", - "eth_getBlockByHash", - "eth_getBlockByNumber", - "eth_getBlockTransactionCountByHash", - "eth_getBlockTransactionCountByNumber", - "eth_getCode", - "eth_getFilterChanges", - "eth_getFilterLogs", - "eth_getLogs", - "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", - "personal_sign", - "eth_signTypedData", - "eth_signTypedData_v1", - "eth_signTypedData_v3", - "eth_submitHashrate", - "eth_submitWork", - "eth_syncing", - "eth_uninstallFilter", - "wallet_watchAsset", - "metamask_watchAsset" -] diff --git a/app/scripts/controllers/permissions/permissionsLog.js b/app/scripts/controllers/permissions/permissionsLog.js new file mode 100644 index 000000000..caa369a2e --- /dev/null +++ b/app/scripts/controllers/permissions/permissionsLog.js @@ -0,0 +1,405 @@ + +import clone from 'clone' +import { isValidAddress } from 'ethereumjs-util' +import { + CAVEAT_NAMES, + HISTORY_STORE_KEY, + LOG_STORE_KEY, + LOG_IGNORE_METHODS, + WALLET_PREFIX, +} from './enums' + +const LOG_LIMIT = 100 + +/** + * Controller with middleware for logging requests and responses to restricted + * and permissions-related methods. + */ +export default class PermissionsLogController { + + constructor ({ restrictedMethods, store }) { + this.restrictedMethods = restrictedMethods + this.store = store + } + + /** + * Get the activity log. + * + * @returns {Array} - The activity log. + */ + getActivityLog () { + return this.store.getState()[LOG_STORE_KEY] || [] + } + + /** + * Update the activity log. + * + * @param {Array} logs - The new activity log array. + */ + updateActivityLog (logs) { + this.store.updateState({ [LOG_STORE_KEY]: logs }) + } + + /** + * Get the permissions history log. + * + * @returns {Object} - The permissions history log. + */ + getHistory () { + return this.store.getState()[HISTORY_STORE_KEY] || {} + } + + /** + * Update the permissions history log. + * + * @param {Object} history - The new permissions history log object. + */ + updateHistory (history) { + this.store.updateState({ [HISTORY_STORE_KEY]: history }) + } + + /** + * Updates the exposed account history for the given origin. + * Sets the 'last seen' time to Date.now() for the given accounts. + * + * @param {string} origin - The origin that the accounts are exposed to. + * @param {Array} accounts - The accounts. + */ + updateAccountsHistory (origin, accounts) { + + if (accounts.length === 0) { + return + } + + const accountToTimeMap = getAccountToTimeMap(accounts, Date.now()) + + this.commitNewHistory(origin, { + eth_accounts: { + accounts: accountToTimeMap, + }, + }) + } + + /** + * Create a permissions log middleware. + * + * @returns {JsonRpcEngineMiddleware} - The permissions log middleware. + */ + createMiddleware () { + return (req, res, next, _end) => { + + let requestedMethods + const { origin, method, id: requestId } = req + const isInternal = method.startsWith(WALLET_PREFIX) + + // we only log certain methods + if ( + !LOG_IGNORE_METHODS.includes(method) && + (isInternal || this.restrictedMethods.includes(method)) + ) { + + this.logActivityRequest(req, isInternal) + + if (method === `${WALLET_PREFIX}requestPermissions`) { + // get the corresponding methods from the requested permissions + requestedMethods = this.getRequestedMethods(req) + } + } else if (method === 'eth_requestAccounts') { + + // eth_requestAccounts is a special case; we need to extract the accounts + // from it + this.logActivityRequest(req, isInternal) + requestedMethods = [ 'eth_accounts' ] + } else { + // no-op + return next() + } + + // call next with a return handler for capturing the response + next(cb => { + + const time = Date.now() + this.logActivityResponse(requestId, res, time) + + if (!res.error && requestedMethods) { + // any permissions or accounts changes will be recorded on the response, + // so we only log permissions history here + this.logPermissionsHistory( + requestedMethods, origin, res.result, time, + method === 'eth_requestAccounts', + ) + } + cb() + }) + } + } + + /** + * Creates and commits an activity log entry, without response data. + * + * @param {Object} request - The request object. + * @param {boolean} isInternal - Whether the request is internal. + */ + logActivityRequest (request, isInternal) { + const activityEntry = { + id: request.id, + method: request.method, + methodType: isInternal ? 'internal' : 'restricted', + origin: request.origin, + request: cloneObj(request), + requestTime: Date.now(), + response: null, + responseTime: null, + success: null, + } + this.commitNewActivity(activityEntry) + } + + /** + * Adds response data to an existing activity log entry and re-commits it. + * + * @param {string} id - The original request id. + * @param {Object} response - The response object. + * @param {number} time - Output from Date.now() + */ + logActivityResponse (id, response, time) { + + if (!id || !response) { + return + } + + const logs = this.getActivityLog() + const index = getLastIndexOfObjectArray(logs, 'id', id) + if (index === -1) { + return + } + + const entry = logs[index] + entry.response = cloneObj(response) + entry.responseTime = time + entry.success = !response.error + + this.updateActivityLog(logs) + } + + /** + * Commit a new entry to the activity log. + * Removes the oldest entry from the log if it exceeds the log limit. + * + * @param {Object} entry - The activity log entry. + */ + commitNewActivity (entry) { + + const logs = this.getActivityLog() + + // add new entry to end of log + logs.push(entry) + + // remove oldest log if exceeding size limit + if (logs.length > LOG_LIMIT) { + logs.shift() + } + + this.updateActivityLog(logs) + } + + /** + * Create new permissions history log entries, if any, and commit them. + * + * @param {Array} requestedMethods - The method names corresponding to the requested permissions. + * @param {string} origin - The origin of the permissions request. + * @param {Array { + + if (perm.parentCapability === 'eth_accounts') { + accounts = this.getAccountsFromPermission(perm) + } + + return perm.parentCapability + }) + .reduce((acc, method) => { + + if (requestedMethods.includes(method)) { + + if (method === 'eth_accounts') { + + const accountToTimeMap = getAccountToTimeMap(accounts, time) + + acc[method] = { + lastApproved: time, + accounts: accountToTimeMap, + } + } else { + acc[method] = { lastApproved: time } + } + } + + return acc + }, {}) + : {} // no result (e.g. in case of error), no log + } + + if (Object.keys(newEntries).length > 0) { + this.commitNewHistory(origin, newEntries) + } + } + + /** + * Commit new entries to the permissions history log. + * Merges the history for the given origin, overwriting existing entries + * with the same key (permission name). + * + * @param {string} origin - The requesting origin. + * @param {Object} newEntries - The new entries to commit. + */ + commitNewHistory (origin, newEntries) { + + // a simple merge updates most permissions + const history = this.getHistory() + const newOriginHistory = { + ...history[origin], + ...newEntries, + } + + // eth_accounts requires special handling, because of information + // we store about the accounts + const existingEthAccountsEntry = ( + history[origin] && history[origin]['eth_accounts'] + ) + const newEthAccountsEntry = newEntries['eth_accounts'] + if (existingEthAccountsEntry && newEthAccountsEntry) { + + // we may intend to update just the accounts, not the permission + // itself + const lastApproved = ( + newEthAccountsEntry.lastApproved || + existingEthAccountsEntry.lastApproved + ) + + // merge old and new eth_accounts history entries + newOriginHistory['eth_accounts'] = { + lastApproved, + accounts: { + ...existingEthAccountsEntry.accounts, + ...newEthAccountsEntry.accounts, + }, + } + } + + history[origin] = newOriginHistory + + this.updateHistory(history) + } + + /** + * Get all requested methods from a permissions request. + * + * @param {Object} request - The request object. + * @returns {Array} - The names of the requested permissions. + */ + getRequestedMethods (request) { + if ( + !request.params || + typeof request.params[0] !== 'object' || + Array.isArray(request.params[0]) + ) { + return null + } + return Object.keys(request.params[0]) + } + + /** + * Get the permitted accounts from an eth_accounts permissions object. + * Returns an empty array if the permission is not eth_accounts. + * + * @param {Object} perm - The permissions object. + * @returns {Array} - The permitted accounts. + */ + getAccountsFromPermission (perm) { + + if (perm.parentCapability !== 'eth_accounts' || !perm.caveats) { + return [] + } + + const accounts = {} + for (const caveat of perm.caveats) { + + if ( + caveat.name === CAVEAT_NAMES.exposedAccounts && + Array.isArray(caveat.value) + ) { + + for (const value of caveat.value) { + if (isValidAddress(value)) { + accounts[value] = true + } + } + } + } + return Object.keys(accounts) + } +} + +// helper functions + +// the call to clone is set to disallow circular references +// we attempt cloning at a depth of 3 and 2, then return a +// shallow copy of the object +function cloneObj (obj) { + + for (let i = 3; i > 1; i--) { + try { + return clone(obj, false, i) + } catch (_) {} + } + return { ...obj } +} + +function getAccountToTimeMap (accounts, time) { + return accounts.reduce( + (acc, account) => ({ ...acc, [account]: time }), {} + ) +} + +function getLastIndexOfObjectArray (array, key, value) { + + if (Array.isArray(array) && array.length > 0) { + + for (let i = array.length - 1; i >= 0; i--) { + + if (typeof array[i] !== 'object') { + throw new Error(`Encountered non-Object element at index ${i}`) + } + + if (array[i][key] === value) { + return i + } + } + } + return -1 +} diff --git a/app/scripts/controllers/permissions/restrictedMethods.js b/app/scripts/controllers/permissions/restrictedMethods.js index 14d38b05c..b77336cfe 100644 --- a/app/scripts/controllers/permissions/restrictedMethods.js +++ b/app/scripts/controllers/permissions/restrictedMethods.js @@ -4,7 +4,7 @@ export default function getRestrictedMethods (permissionsController) { 'eth_accounts': { description: 'View the address of the selected account', method: (_, res, __, end) => { - permissionsController.keyringController.getAccounts() + permissionsController.getKeyringAccounts() .then((accounts) => { res.result = accounts end() diff --git a/app/scripts/controllers/preferences.js b/app/scripts/controllers/preferences.js index 76bb242f2..e9a10ebce 100644 --- a/app/scripts/controllers/preferences.js +++ b/app/scripts/controllers/preferences.js @@ -61,6 +61,8 @@ class PreferencesController { // ENS decentralized website resolution ipfsGateway: 'ipfs.dweb.link', + + lastSelectedAddressByOrigin: {}, }, opts.initState) this.diagnostics = opts.diagnostics @@ -369,6 +371,56 @@ class PreferencesController { return this.store.getState().selectedAddress } + /** + * Update the last selected address for the given origin. + * + * @param {string} origin - The origin for which the address was selected. + * @param {string} address - The new selected address. + */ + setLastSelectedAddress (origin, address) { + + const { lastSelectedAddressByOrigin } = this.store.getState() + + // only update state if it's necessary + if (lastSelectedAddressByOrigin[origin] !== address) { + lastSelectedAddressByOrigin[origin] = address + this.store.updateState({ lastSelectedAddressByOrigin }) + } + } + + /** + * Remove the selected address history for the given origin. + * + * @param {Array} origins - The origin to remove the last selected address for. + */ + removeLastSelectedAddressesFor (origins) { + + if ( + !Array.isArray(origins) || + (origins.length > 0 && typeof origins[0] !== 'string') + ) { + throw new Error('Expected array of strings') + } + + if (origins.length === 0) { + return + } + + const { lastSelectedAddressByOrigin } = this.store.getState() + + origins.forEach(origin => { + delete lastSelectedAddressByOrigin[origin] + }) + this.store.updateState({ lastSelectedAddressByOrigin }) + } + + /** + * Clears the selected address history. + */ + clearLastSelectedAddressHistory () { + this.store.updateState({ lastSelectedAddressByOrigin: {} }) + } + /** * Contains data about tokens users add to their account. * @typedef {Object} AddedToken diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 6ce3e05be..9dc8f0b2c 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -205,7 +205,7 @@ export default class MetamaskController extends EventEmitter { this.keyringController.memStore.subscribe((s) => this._onKeyringControllerUpdate(s)) this.permissionsController = new PermissionsController({ - keyringController: this.keyringController, + getKeyringAccounts: this.keyringController.getAccounts.bind(this.keyringController), platform: opts.platform, notifyDomain: this.notifyConnections.bind(this), notifyAllDomains: this.notifyAllConnections.bind(this), @@ -490,6 +490,8 @@ export default class MetamaskController extends EventEmitter { setPreference: nodeify(preferencesController.setPreference, preferencesController), completeOnboarding: nodeify(preferencesController.completeOnboarding, preferencesController), addKnownMethodData: nodeify(preferencesController.addKnownMethodData, preferencesController), + clearLastSelectedAddressHistory: nodeify(preferencesController.clearLastSelectedAddressHistory, preferencesController), + removeLastSelectedAddressesFor: nodeify(preferencesController.removeLastSelectedAddressesFor, preferencesController), // BlacklistController whitelistPhishingDomain: this.whitelistPhishingDomain.bind(this), @@ -557,8 +559,9 @@ export default class MetamaskController extends EventEmitter { getApprovedAccounts: nodeify(permissionsController.getAccounts.bind(permissionsController)), rejectPermissionsRequest: nodeify(permissionsController.rejectPermissionsRequest, permissionsController), removePermissionsFor: permissionsController.removePermissionsFor.bind(permissionsController), - updateExposedAccounts: nodeify(permissionsController.updateExposedAccounts, permissionsController), + updatePermittedAccounts: nodeify(permissionsController.updatePermittedAccounts, permissionsController), legacyExposeAccounts: nodeify(permissionsController.legacyExposeAccounts, permissionsController), + handleNewAccountSelected: nodeify(this.handleNewAccountSelected, this), getRequestAccountTabIds: (cb) => cb(null, this.getRequestAccountTabIds()), getOpenMetamaskTabsIds: (cb) => cb(null, this.getOpenMetamaskTabsIds()), @@ -1020,6 +1023,18 @@ export default class MetamaskController extends EventEmitter { await this.preferencesController.setSelectedAddress(accounts[0]) } + /** + * Handle when a new account is selected for the given origin in the UI. + * Stores the address by origin and notifies external providers associated + * with the origin. + * @param {string} origin - The origin for which the address was selected. + * @param {string} address - The new selected address. + */ + async handleNewAccountSelected (origin, address) { + this.permissionsController.handleNewAccountSelected(origin, address) + this.preferencesController.setLastSelectedAddress(origin, address) + } + // --------------------------------------------------------------------------- // Identity Management (signature operations) diff --git a/package.json b/package.json index 5e42d0e5c..c056d1053 100644 --- a/package.json +++ b/package.json @@ -172,7 +172,7 @@ "redux-logger": "^3.0.6", "redux-thunk": "^2.2.0", "reselect": "^3.0.1", - "rpc-cap": "^1.0.1", + "rpc-cap": "^1.0.3", "safe-event-emitter": "^1.0.1", "safe-json-stringify": "^1.2.0", "single-call-balance-checker-abi": "^1.0.0", diff --git a/ui/app/components/app/account-menu/account-menu.container.js b/ui/app/components/app/account-menu/account-menu.container.js index 206afb176..7bc62603e 100644 --- a/ui/app/components/app/account-menu/account-menu.container.js +++ b/ui/app/components/app/account-menu/account-menu.container.js @@ -15,6 +15,8 @@ import { getMetaMaskKeyrings, getOriginOfCurrentTab, getSelectedAddress, + // getLastSelectedAddress, + // getPermittedAccounts, } from '../../../selectors/selectors' import AccountMenu from './account-menu.component' @@ -26,12 +28,22 @@ const SHOW_SEARCH_ACCOUNTS_MIN_COUNT = 5 function mapStateToProps (state) { const { metamask: { isAccountMenuOpen } } = state const accounts = getMetaMaskAccountsOrdered(state) + const origin = getOriginOfCurrentTab(state) + const selectedAddress = getSelectedAddress(state) + + /** + * TODO:LoginPerSite:ui + * - propagate the relevant props below after computing them + */ + // const lastSelectedAddress = getLastSelectedAddress(state, origin) + // const permittedAccounts = getPermittedAccounts(state, origin) + // const selectedAccountIsPermitted = permittedAccounts.includes(selectedAddress) return { isAccountMenuOpen, addressConnectedDomainMap: getAddressConnectedDomainMap(state), - originOfCurrentTab: getOriginOfCurrentTab(state), - selectedAddress: getSelectedAddress(state), + originOfCurrentTab: origin, + selectedAddress: selectedAddress, keyrings: getMetaMaskKeyrings(state), accounts, shouldShowAccountsSearch: accounts.length >= SHOW_SEARCH_ACCOUNTS_MIN_COUNT, diff --git a/ui/app/selectors/permissions.js b/ui/app/selectors/permissions.js new file mode 100644 index 000000000..9399934a9 --- /dev/null +++ b/ui/app/selectors/permissions.js @@ -0,0 +1,48 @@ + +import { createSelector } from 'reselect' +import { + CAVEAT_NAMES, +} from '../../../app/scripts/controllers/permissions/enums' + +const permissionsSelector = (state, origin) => { + return origin && state.metamask.domains && state.metamask.domains[origin] +} + +// all permissions for the origin probably too expensive for deep equality check +const accountsPermissionSelector = createSelector( + permissionsSelector, + (domain = {}) => { + + return ( + Array.isArray(domain.permissions) + ? domain.permissions.find( + perm => perm.parentCapability === 'eth_accounts' + ) + : {} + ) + } +) + +/** + * Selects the permitted accounts from an eth_accounts permission. + * Expects input from accountsPermissionsSelector. + * @returns - An empty array or an array of accounts. + */ +export const getPermittedAccounts = createSelector( + accountsPermissionSelector, // deep equal check performed on this output + (accountsPermission = {}) => { + + const accountsCaveat = ( + Array.isArray(accountsPermission.caveats) && + accountsPermission.caveats.find( + c => c.name === CAVEAT_NAMES.exposedAccounts + ) + ) + + return ( + accountsCaveat && Array.isArray(accountsCaveat.value) + ? accountsCaveat.value + : [] + ) + } +) diff --git a/ui/app/selectors/selectors.js b/ui/app/selectors/selectors.js index 410e09520..045eaf43a 100644 --- a/ui/app/selectors/selectors.js +++ b/ui/app/selectors/selectors.js @@ -12,6 +12,10 @@ import { getOriginFromUrl, } from '../helpers/utils/util' +import { getPermittedAccounts } from './permissions' + +export { getPermittedAccounts } from './permissions' + export function getNetworkIdentifier (state) { const { metamask: { provider: { type, nickname, rpcTarget } } } = state @@ -87,6 +91,21 @@ export function getSelectedAddress (state) { return selectedAddress } +function lastSelectedAddressSelector (state, origin) { + return state.metamask.lastSelectedAddressByOrigin[origin] || null +} + +// not using reselect here since the returns are contingent; +// we have no reasons to recompute the permitted accounts if there +// exists a lastSelectedAddress +export function getLastSelectedAddress (state, origin) { + return ( + lastSelectedAddressSelector(state, origin) || + getPermittedAccounts(state, origin)[0] || // always returns array + getSelectedAddress(state) + ) +} + export function getSelectedIdentity (state) { const selectedAddress = getSelectedAddress(state) const identities = state.metamask.identities diff --git a/ui/app/store/actions.js b/ui/app/store/actions.js index 6123bfecb..01f4ea842 100644 --- a/ui/app/store/actions.js +++ b/ui/app/store/actions.js @@ -1235,6 +1235,7 @@ export function showAccountDetail (address) { if (err) { return dispatch(displayWarning(err.message)) } + background.handleNewAccountSelected(origin, address) dispatch(updateTokens(tokens)) dispatch({ type: actionConstants.SHOW_ACCOUNT_DETAIL, @@ -2208,6 +2209,7 @@ export function legacyExposeAccounts (origin, accounts) { export function removePermissionsFor (domains) { return () => { background.removePermissionsFor(domains) + background.removeLastSelectedAddressesFor(Object.keys(domains)) } } @@ -2217,6 +2219,7 @@ export function removePermissionsFor (domains) { export function clearPermissions () { return () => { background.clearPermissions() + background.clearLastSelectedAddressHistory() } } diff --git a/yarn.lock b/yarn.lock index 267a45849..a13a1d6c8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -25323,10 +25323,10 @@ rn-host-detect@^1.1.5: resolved "https://registry.yarnpkg.com/rn-host-detect/-/rn-host-detect-1.1.5.tgz#fbecb982b73932f34529e97932b9a63e58d8deb6" integrity sha512-ufk2dFT3QeP9HyZ/xTuMtW27KnFy815CYitJMqQm+pgG3ZAtHBsrU8nXizNKkqXGy3bQmhEoloVbrfbvMJMqkg== -rpc-cap@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/rpc-cap/-/rpc-cap-1.0.1.tgz#c19f6651d9d003256c73831422e0bd60b4fa8b55" - integrity sha512-M75F5IfohYkwGvitWmstimP9OL+9h10m1ZRC2zCB1Nli4EPzL8n5re58xlrcOnwOO38FdSSPfcwcCzMuVT8K2g== +rpc-cap@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/rpc-cap/-/rpc-cap-1.0.3.tgz#c58f99ee97a92441f4310f407c0f40fecdbf0e78" + integrity sha512-6lheD7UU4IY+OpILTL65E5NQWFPfG1Igd/CAGbnMJY+3szmQ9mUrf4/3bbcvNhu64Q/KYfCstVhxJREmTeFLOg== dependencies: clone "^2.1.2" eth-json-rpc-errors "^2.0.0"