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

Improve LoginPerSite UX/devX and permissions logging (#7649)

Update accounts permission history on accountsChanged
Create PermissionsLogController
Fix permissions activity log pruning
Add selectors, background hooks for better UX
Make selected account the first account returned
Use enums for store keys in log controller
Add last selected address history to PreferencesController
This commit is contained in:
Erik Marks 2020-01-27 14:42:03 -08:00 committed by GitHub
parent f2a719a70c
commit b75f812953
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 724 additions and 265 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Object>} - The activity log.
*/
getActivityLog () {
return this.store.getState()[LOG_STORE_KEY] || []
}
/**
* Update the activity log.
*
* @param {Array<Object>} 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<string>} 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<string>} requestedMethods - The method names corresponding to the requested permissions.
* @param {string} origin - The origin of the permissions request.
* @param {Array<IOcapLdCapability} result - The permissions request response.result.
* @param {string} time - The time of the request, i.e. Date.now().
* @param {boolean} isEthRequestAccounts - Whether the permissions request was 'eth_requestAccounts'.
*/
logPermissionsHistory (requestedMethods, origin, result, time, isEthRequestAccounts) {
let accounts, newEntries
if (isEthRequestAccounts) {
accounts = result
const accountToTimeMap = getAccountToTimeMap(accounts, time)
newEntries = {
'eth_accounts': {
accounts: accountToTimeMap,
lastApproved: time,
},
}
} else {
// Records new "lastApproved" times for the granted permissions, if any.
// Special handling for eth_accounts, in order to record the time the
// accounts were last seen or approved by the origin.
newEntries = result
? result
.map(perm => {
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<string>} - 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<string>} - 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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
: []
)
}
)

View File

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

View File

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

View File

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