Skip to content

Commit

Permalink
Jl/caip multichain/lifecycle methods (#25842)
Browse files Browse the repository at this point in the history
<!--
Please submit this PR as a draft initially.
Do not mark it as "Ready for review" until the template has been
completely filled out, and PR status checks have passed at least once.
-->

## **Description**

* Add `wallet_getSession`
* Add `wallet_revokeSession`
* Emit `wallet_sessionChanged` on authorization change
* Note this does not include specs. Seems we are not currently testing
accountChanged and chainChanged events and should probably get those
covered first

[![Open in GitHub
Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/25842?quickstart=1)

## **Related issues**

See: MetaMask/MetaMask-planning#2821

## **Manual testing steps**

1. Go to this page...
2.
3.

## **Screenshots/Recordings**

<!-- If applicable, add screenshots and/or recordings to visualize the
before and after of your change. -->

### **Before**

<!-- [screenshots/recordings] -->

### **After**

<!-- [screenshots/recordings] -->

## **Pre-merge author checklist**

- [ ] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask
Extension Coding
Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.github/guidelines/CODING_GUIDELINES.md).
- [ ] I've completed the PR template to the best of my ability
- [ ] I’ve included tests if applicable
- [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [ ] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-extension/blob/develop/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.

## **Pre-merge reviewer checklist**

- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
  • Loading branch information
jiexi authored Jul 17, 2024
1 parent 8990171 commit c9c03ad
Show file tree
Hide file tree
Showing 9 changed files with 428 additions and 7 deletions.
1 change: 1 addition & 0 deletions app/scripts/controllers/permissions/enums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ export enum NOTIFICATION_NAMES {
accountsChanged = 'metamask_accountsChanged',
unlockStateChanged = 'metamask_unlockStateChanged',
chainChanged = 'metamask_chainChanged',
sessionChanged = 'wallet_sessionChanged',
}
80 changes: 80 additions & 0 deletions app/scripts/controllers/permissions/selectors.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { createSelector } from 'reselect';
import { CaveatTypes } from '../../../../shared/constants/permissions';
import {
Caip25CaveatType,
Caip25EndowmentPermissionName,
} from '../../lib/multichain-api/caip25permissions';

/**
* This file contains selectors for PermissionController selector event
Expand Down Expand Up @@ -39,6 +43,33 @@ export const getPermittedAccountsByOrigin = createSelector(
},
);

/**
* Get the authorized CAIP-25 scopes for each subject, keyed by origin.
* The values of the returned map are immutable values from the
* PermissionController state.
*
* @returns {Map<string, Caip25Authorization>} The current origin:authorization map.
*/
export const getAuthorizedScopesByOrigin = createSelector(
getSubjects,
(subjects) => {
return Object.values(subjects).reduce(
(originToAuthorizationsMap, subject) => {
const caveats =
subject.permissions?.[Caip25EndowmentPermissionName]?.caveats || [];

const caveat = caveats.find(({ type }) => type === Caip25CaveatType);

if (caveat) {
originToAuthorizationsMap.set(subject.origin, caveat.value);
}
return originToAuthorizationsMap;
},
new Map(),
);
},
);

/**
* Given the current and previous exposed accounts for each PermissionController
* subject, returns a new map containing all accounts that have changed.
Expand Down Expand Up @@ -84,3 +115,52 @@ export const getChangedAccounts = (newAccountsMap, previousAccountsMap) => {
}
return changedAccounts;
};

/**
* Given the current and previous exposed CAIP-25 authorization for each PermissionController
* subject, returns a new map containing all authorizations that have changed.
* The values of each map must be immutable values directly from the
* PermissionController state, or an empty object instantiated in this
* function.
*
* @param {Map<string, Caip25Authorization>} newAuthorizationsMap - The new origin:authorization map.
* @param {Map<string, Caip25Authorization>} [previousAuthorizationsMap] - The previous origin:authorization map.
* @returns {Map<string, Caip25Authorization>} The origin:authorization map of changed authorizations.
*/
export const getChangedAuthorizations = (
newAuthorizationsMap,
previousAuthorizationsMap,
) => {
if (previousAuthorizationsMap === undefined) {
return newAuthorizationsMap;
}

const changedAuthorizations = new Map();
if (newAuthorizationsMap === previousAuthorizationsMap) {
return changedAuthorizations;
}

const newOrigins = new Set([...newAuthorizationsMap.keys()]);

for (const origin of previousAuthorizationsMap.keys()) {
const newAuthorizations = newAuthorizationsMap.get(origin) ?? {};

// The values of these maps are references to immutable values, which is why
// a strict equality check is enough for diffing. The values are either from
// PermissionController state, or an empty object initialized in the previous
// call to this function. `newAuthorizationsMap` will never contain any empty
// objects.
if (previousAuthorizationsMap.get(origin) !== newAuthorizations) {
changedAuthorizations.set(origin, newAuthorizations);
}

newOrigins.delete(origin);
}

// By now, newOrigins is either empty or contains some number of previously
// unencountered origins, and all of their authorizations have "changed".
for (const origin of newOrigins.keys()) {
changedAuthorizations.set(origin, newAuthorizationsMap.get(origin));
}
return changedAuthorizations;
};
2 changes: 2 additions & 0 deletions app/scripts/lib/multichain-api/scope/assert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ describe('Scope Assert', () => {

describe('assertScopeSupported', () => {
const findNetworkClientIdByChainId = jest.fn();

describe('scopeString', () => {
it('checks if the scopeString is supported', () => {
try {
Expand Down Expand Up @@ -136,6 +137,7 @@ describe('Scope Assert', () => {

describe('assertScopesSupported', () => {
const findNetworkClientIdByChainId = jest.fn();

it('throws an error if no scopes are defined', () => {
expect(() => {
assertScopesSupported(
Expand Down
37 changes: 37 additions & 0 deletions app/scripts/lib/multichain-api/wallet-getSession.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { EthereumRpcError } from 'eth-rpc-errors';
import {
Caip25CaveatType,
Caip25EndowmentPermissionName,
} from './caip25permissions';
import { mergeScopes } from './scope';

export async function walletGetSessionHandler(
request,
response,
_next,
end,
hooks,
) {
if (request.params?.sessionId) {
return end(
new EthereumRpcError(5500, 'SessionId not recognized'), // we aren't currently storing a sessionId to check this against
);
}

const caveat = hooks.getCaveat(
request.origin,
Caip25EndowmentPermissionName,
Caip25CaveatType,
);
if (!caveat) {
return end(new EthereumRpcError(5501, 'No active sessions'));
}

response.result = {
sessionScopes: mergeScopes(
caveat.value.requiredScopes,
caveat.value.optionalScopes,
),
};
return end();
}
111 changes: 111 additions & 0 deletions app/scripts/lib/multichain-api/wallet-getSession.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { EthereumRpcError } from 'eth-rpc-errors';
import {
Caip25CaveatType,
Caip25EndowmentPermissionName,
} from './caip25permissions';
import { walletGetSessionHandler } from './wallet-getSession';

const baseRequest = {
origin: 'http://test.com',
params: {},
};

const createMockedHandler = () => {
const next = jest.fn();
const end = jest.fn();
const getCaveat = jest.fn().mockReturnValue({
value: {
requiredScopes: {
'eip155:1': {
methods: ['eth_call'],
notifications: [],
},
'eip155:5': {
methods: ['eth_chainId'],
notifications: [],
},
},
optionalScopes: {
'eip155:1': {
methods: ['net_version'],
notifications: ['chainChanged'],
},
wallet: {
methods: ['wallet_watchAsset'],
notifications: [],
},
},
},
});
const response = {};
const handler = (request) =>
walletGetSessionHandler(request, response, next, end, {
getCaveat,
});

return {
next,
response,
end,
getCaveat,
handler,
};
};

describe('wallet_getSession', () => {
it('throws an error when sessionId param is specified', async () => {
const { handler, end } = createMockedHandler();
await handler({
...baseRequest,
params: {
sessionId: '0xdeadbeef',
},
});
expect(end).toHaveBeenCalledWith(
new EthereumRpcError(5500, 'SessionId not recognized'),
);
});

it('gets the authorized scopes from the CAIP-25 endowement permission', async () => {
const { handler, getCaveat } = createMockedHandler();

await handler(baseRequest);
expect(getCaveat).toHaveBeenCalledWith(
'http://test.com',
Caip25EndowmentPermissionName,
Caip25CaveatType,
);
});

it('throws an error if the CAIP-25 endowement permission does not exist', async () => {
const { handler, getCaveat, end } = createMockedHandler();
getCaveat.mockReturnValue(null);

await handler(baseRequest);
expect(end).toHaveBeenCalledWith(
new EthereumRpcError(5501, 'No active sessions'),
);
});

it('returns the merged scopes', async () => {
const { handler, response } = createMockedHandler();

await handler(baseRequest);
expect(response.result).toStrictEqual({
sessionScopes: {
'eip155:1': {
methods: ['eth_call', 'net_version'],
notifications: ['chainChanged'],
},
'eip155:5': {
methods: ['eth_chainId'],
notifications: [],
},
wallet: {
methods: ['wallet_watchAsset'],
notifications: [],
},
},
});
});
});
36 changes: 36 additions & 0 deletions app/scripts/lib/multichain-api/wallet-revokeSession.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import {
PermissionDoesNotExistError,
UnrecognizedSubjectError,
} from '@metamask/permission-controller';
import { EthereumRpcError } from 'eth-rpc-errors';
import { Caip25EndowmentPermissionName } from './caip25permissions';

export async function walletRevokeSessionHandler(
request,
response,
_next,
end,
hooks,
) {
if (request.params?.sessionId) {
return end(
new EthereumRpcError(5500, 'SessionId not recognized'), // we aren't currently storing a sessionId to check this against
);
}

try {
hooks.revokePermission(request.origin, Caip25EndowmentPermissionName);
} catch (err) {
if (
err instanceof UnrecognizedSubjectError ||
err instanceof PermissionDoesNotExistError
) {
return end(new EthereumRpcError(5501, 'No active sessions'));
}

return end(err); // TODO: handle this better
}

response.result = true;
return end();
}
Loading

0 comments on commit c9c03ad

Please sign in to comment.