Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/snaps-controllers/coverage.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"branches": 94.87,
"branches": 94.77,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ?? [] branch is not covered, but it's not easy to do without adding mock data to DYNAMIC_PERMISSION_DEPENDENCIES.

"functions": 98.03,
"lines": 98.63,
"statements": 98.43
Expand Down
238 changes: 238 additions & 0 deletions packages/snaps-controllers/src/snaps/SnapController.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import {
DEFAULT_SOURCE_PATH,
DEFAULT_ICON_PATH,
TEST_SECRET_RECOVERY_PHRASE_SEED_BYTES,
MOCK_INITIAL_PERMISSIONS,
} from '@metamask/snaps-utils/test-utils';
import type { SemVerRange, SemVerVersion, Json } from '@metamask/utils';
import {
Expand Down Expand Up @@ -8427,6 +8428,243 @@ describe('SnapController', () => {
controller.destroy();
});

it('updates a Snap with a dynamic permission, and keeps the dynamic permission if one of the dependent permissions is used', async () => {
const { manifest } = await getMockSnapFilesWithUpdatedChecksum({
manifest: getSnapManifest({
version: '1.1.0' as SemVerVersion,
initialPermissions: {
...MOCK_INITIAL_PERMISSIONS,
'endowment:ethereum-provider': {},
},
}),
});

const detectSnapLocation = jest
.fn()
.mockImplementationOnce(() => new LoopbackLocation())
.mockImplementationOnce(
() =>
new LoopbackLocation({
manifest: manifest.result,
}),
);

const rootMessenger = getControllerMessenger();
const messenger = getSnapControllerMessenger(rootMessenger);

rootMessenger.registerActionHandler(
'PermissionController:getPermissions',
() => ({
...MOCK_SNAP_PERMISSIONS,
'endowment:ethereum-provider': {
caveats: null,
date: 1664187844588,
id: 'izn0WGUO8cvq_jqvLQuQP',
invoker: MOCK_SNAP_ID,
parentCapability: 'endowment:ethereum-provider',
},
'endowment:caip25': {
caveats: null,
date: 1664187844588,
id: 'izn0WGUO8cvq_jqvLQuQP',
invoker: MOCK_SNAP_ID,
parentCapability: 'endowment:caip25',
},
}),
);

const controller = getSnapController(
getSnapControllerOptions({
messenger,
detectSnapLocation,
}),
);
const callActionSpy = jest.spyOn(messenger, 'call');
const onSnapUpdated = jest.fn();

await controller.installSnaps(MOCK_ORIGIN, { [MOCK_SNAP_ID]: {} });
await controller.stopSnap(MOCK_SNAP_ID);

messenger.subscribe('SnapController:snapUpdated', onSnapUpdated);

await controller.installSnaps(MOCK_ORIGIN, {
[MOCK_SNAP_ID]: { version: '1.1.0' },
});

expect(callActionSpy).toHaveBeenCalledTimes(21);
expect(callActionSpy).toHaveBeenNthCalledWith(
12,
'ApprovalController:addRequest',
{
origin: MOCK_ORIGIN,
id: expect.any(String),
type: SNAP_APPROVAL_UPDATE,
requestData: {
metadata: {
id: expect.any(String),
dappOrigin: MOCK_ORIGIN,
origin: MOCK_SNAP_ID,
},
snapId: MOCK_SNAP_ID,
},
requestState: {
loading: true,
},
},
true,
);

expect(messenger.call).toHaveBeenNthCalledWith(
15,
'ApprovalController:updateRequestState',
expect.objectContaining({
id: expect.any(String),
requestState: {
loading: false,
permissions: {},
newVersion: '1.1.0',
newPermissions: {},
approvedPermissions: {
...MOCK_SNAP_PERMISSIONS,
'endowment:ethereum-provider': expect.any(Object),
'endowment:caip25': expect.any(Object),
},
unusedPermissions: {},
newConnections: {},
unusedConnections: {},
approvedConnections: {},
},
}),
);

expect(messenger.call).not.toHaveBeenCalledWith(
'PermissionController:revokePermissions',
expect.anything(),
);

controller.destroy();
});

it('updates a Snap with a dynamic permission, and revokes the dynamic permission if none of the dependent permissions are used', async () => {
const { manifest } = await getMockSnapFilesWithUpdatedChecksum({
manifest: getSnapManifest({
version: '1.1.0' as SemVerVersion,
initialPermissions: {
...MOCK_INITIAL_PERMISSIONS,
},
}),
});

const detectSnapLocation = jest
.fn()
.mockImplementationOnce(() => new LoopbackLocation())
.mockImplementationOnce(
() =>
new LoopbackLocation({
manifest: manifest.result,
}),
);

const rootMessenger = getControllerMessenger();
const messenger = getSnapControllerMessenger(rootMessenger);

rootMessenger.registerActionHandler(
'PermissionController:getPermissions',
() => ({
...MOCK_SNAP_PERMISSIONS,
'endowment:ethereum-provider': {
caveats: null,
date: 1664187844588,
id: 'izn0WGUO8cvq_jqvLQuQP',
invoker: MOCK_SNAP_ID,
parentCapability: 'endowment:ethereum-provider',
},
'endowment:caip25': {
caveats: null,
date: 1664187844588,
id: 'izn0WGUO8cvq_jqvLQuQP',
invoker: MOCK_SNAP_ID,
parentCapability: 'endowment:caip25',
},
}),
);

const controller = getSnapController(
getSnapControllerOptions({
messenger,
detectSnapLocation,
}),
);
const callActionSpy = jest.spyOn(messenger, 'call');
const onSnapUpdated = jest.fn();

await controller.installSnaps(MOCK_ORIGIN, { [MOCK_SNAP_ID]: {} });
await controller.stopSnap(MOCK_SNAP_ID);

messenger.subscribe('SnapController:snapUpdated', onSnapUpdated);

await controller.installSnaps(MOCK_ORIGIN, {
[MOCK_SNAP_ID]: { version: '1.1.0' },
});

expect(callActionSpy).toHaveBeenCalledTimes(22);
expect(callActionSpy).toHaveBeenNthCalledWith(
12,
'ApprovalController:addRequest',
{
origin: MOCK_ORIGIN,
id: expect.any(String),
type: SNAP_APPROVAL_UPDATE,
requestData: {
metadata: {
id: expect.any(String),
dappOrigin: MOCK_ORIGIN,
origin: MOCK_SNAP_ID,
},
snapId: MOCK_SNAP_ID,
},
requestState: {
loading: true,
},
},
true,
);

expect(messenger.call).toHaveBeenNthCalledWith(
15,
'ApprovalController:updateRequestState',
expect.objectContaining({
id: expect.any(String),
requestState: {
loading: false,
permissions: {},
newVersion: '1.1.0',
newPermissions: {},
approvedPermissions: {
...MOCK_SNAP_PERMISSIONS,
},
unusedPermissions: {
'endowment:ethereum-provider': expect.any(Object),
'endowment:caip25': expect.any(Object),
},
newConnections: {},
unusedConnections: {},
approvedConnections: {},
},
}),
);

expect(messenger.call).toHaveBeenNthCalledWith(
18,
'PermissionController:revokePermissions',
{
[MOCK_SNAP_ID]: ['endowment:ethereum-provider', 'endowment:caip25'],
},
);

controller.destroy();
});

it('can update crashed snap', async () => {
const { manifest } = await getMockSnapFilesWithUpdatedChecksum({
manifest: getSnapManifest({
Expand Down
54 changes: 50 additions & 4 deletions packages/snaps-controllers/src/snaps/SnapController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ import { gt, gte } from 'semver';
import {
ALLOWED_PERMISSIONS,
CLIENT_ONLY_HANDLERS,
DYNAMIC_PERMISSION_DEPENDENCIES,
LEGACY_ENCRYPTION_KEY_DERIVATION_OPTIONS,
METAMASK_ORIGIN,
STATE_DEBOUNCE_TIMEOUT,
Expand Down Expand Up @@ -4272,6 +4273,46 @@ export class SnapController extends BaseController<
});
}

/**
* Get the desired permissions including dynamic permissions. That is, if a
* dynamic permission was previously granted and at least one of its
* dependencies is still desired, it will be included in the desired
* permissions.
*
* @param oldPermissions - The old permissions.
* @param desiredPermissions - The desired permissions.
* @returns The desired permissions including dynamic permissions.
*/
#getDesiredPermissions(
oldPermissions: SubjectPermissions<
ValidPermission<string, Caveat<string, any>>
>,
desiredPermissions: Record<string, Pick<PermissionConstraint, 'caveats'>>,
) {
return Object.keys(oldPermissions).reduce<
Record<string, Pick<PermissionConstraint, 'caveats'>>
>((accumulator, permissionName) => {
if (this.#dynamicPermissions.includes(permissionName)) {
const hasDependencies = hasProperty(
DYNAMIC_PERMISSION_DEPENDENCIES,
permissionName,
);

const hasDependency = DYNAMIC_PERMISSION_DEPENDENCIES[
permissionName
]?.some((dependency) => hasProperty(desiredPermissions, dependency));

// If the permission doesn't have dependencies, or if at least one of
// its dependencies is desired, include it in the desired permissions.
if (!hasDependencies || hasDependency) {
accumulator[permissionName] = oldPermissions[permissionName];
}
}

return accumulator;
}, desiredPermissions);
}

#calculatePermissionsChange(
snapId: SnapId,
desiredPermissionsSet: Record<
Expand All @@ -4289,16 +4330,21 @@ export class SnapController extends BaseController<
} {
const oldPermissions =
this.messenger.call('PermissionController:getPermissions', snapId) ?? {};
const desiredPermissionsSetWithDynamic = this.#getDesiredPermissions(
oldPermissions,
desiredPermissionsSet,
);

const newPermissions = permissionsDiff(
desiredPermissionsSet,
desiredPermissionsSetWithDynamic,
oldPermissions,
);
// TODO(ritave): The assumption that these are unused only holds so long as we do not
// permit dynamic permission requests.

// TODO: The assumption that these are unused only holds so long as we do
// not permit dynamic permission requests.
const unusedPermissions = permissionsDiff(
oldPermissions,
desiredPermissionsSet,
desiredPermissionsSetWithDynamic,
);

// It's a Set Intersection of oldPermissions and desiredPermissionsSet
Expand Down
14 changes: 14 additions & 0 deletions packages/snaps-controllers/src/snaps/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,17 @@ export const CLIENT_ONLY_HANDLERS = Object.freeze([
HandlerType.OnAssetsMarketData,
HandlerType.OnWebSocketEvent,
]);

/**
* A mapping of dynamic permission to their required dependencies, i.e., if the
* dynamic permission is requested, at least one of its dependencies should
* also be requested in order to use the dynamic permission.
*
* This is primarily used to grant or revoke the permission if its dependencies
* are granted or revoked.
*/
export const DYNAMIC_PERMISSION_DEPENDENCIES: Readonly<
Record<string, string[]>
> = Object.freeze({
'endowment:caip25': ['endowment:ethereum-provider'],
});
Loading