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:
parent
f2a719a70c
commit
b75f812953
72
app/scripts/controllers/permissions/enums.js
Normal file
72
app/scripts/controllers/permissions/enums.js
Normal 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',
|
||||
]
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
@ -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"
|
||||
]
|
405
app/scripts/controllers/permissions/permissionsLog.js
Normal file
405
app/scripts/controllers/permissions/permissionsLog.js
Normal 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
|
||||
}
|
@ -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()
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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",
|
||||
|
@ -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,
|
||||
|
48
ui/app/selectors/permissions.js
Normal file
48
ui/app/selectors/permissions.js
Normal 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
|
||||
: []
|
||||
)
|
||||
}
|
||||
)
|
@ -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
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user