Skip to content

Commit

Permalink
[BREAKING] Add snap_resolveInterface RPC method (#2509)
Browse files Browse the repository at this point in the history
This PR adds a new non-restricted RPC method that allows a snap to
resolve a given user interface bound to a `snap_dialog` with a custom
value.

- [x] Add the `resolveInterface` method in the `SnapInterfaceController`
- [x] Add a new `snap_resolveInterface` RPC method
- [x] Update `snap_dialog` RPC method to set the approval ID to the
interface ID
- [x] [BREAKING] `snap_dialog` now takes the `requestUserApproval` hook
which is meant to be bind to the `addAndShowRequest` method of the
`ApprovalController`
  • Loading branch information
GuillaumeRx authored Jun 27, 2024
1 parent 35ae1e7 commit ad0eae4
Show file tree
Hide file tree
Showing 25 changed files with 948 additions and 204 deletions.
6 changes: 3 additions & 3 deletions packages/snaps-controllers/coverage.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"branches": 92.08,
"functions": 96.74,
"lines": 97.96,
"statements": 97.64
"functions": 96.75,
"lines": 97.97,
"statements": 97.65
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { getJsxElementFromComponent } from '@metamask/snaps-utils';
import { MOCK_SNAP_ID } from '@metamask/snaps-utils/test-utils';

import {
MockApprovalController,
getRestrictedSnapInterfaceControllerMessenger,
getRootSnapInterfaceControllerMessenger,
} from '../test-utils';
Expand Down Expand Up @@ -932,4 +933,167 @@ describe('SnapInterfaceController', () => {
).toThrow(`Interface with id '${id}' not found.`);
});
});

describe('resolveInterface', () => {
it('resolves the interface with the given value', async () => {
const rootMessenger = getRootSnapInterfaceControllerMessenger();
const controllerMessenger =
getRestrictedSnapInterfaceControllerMessenger(rootMessenger);

/* eslint-disable-next-line no-new */
new SnapInterfaceController({
messenger: controllerMessenger,
});

const approvalControllerMock = new MockApprovalController();

rootMessenger.registerActionHandler(
'ApprovalController:hasRequest',
approvalControllerMock.hasRequest.bind(approvalControllerMock),
);

rootMessenger.registerActionHandler(
'ApprovalController:acceptRequest',
approvalControllerMock.acceptRequest.bind(approvalControllerMock),
);

const id = await rootMessenger.call(
'SnapInterfaceController:createInterface',
MOCK_SNAP_ID,
<Box>
<Text>foo</Text>
</Box>,
);

const approvalPromise = approvalControllerMock.addRequest({
id,
});

rootMessenger.call(
'SnapInterfaceController:resolveInterface',
MOCK_SNAP_ID,
id,
'bar',
);

expect(await approvalPromise).toBe('bar');
});

it('throws if the interface does not exist', async () => {
const rootMessenger = getRootSnapInterfaceControllerMessenger();
const controllerMessenger =
getRestrictedSnapInterfaceControllerMessenger(rootMessenger);

/* eslint-disable-next-line no-new */
new SnapInterfaceController({
messenger: controllerMessenger,
});

const approvalControllerMock = new MockApprovalController();

rootMessenger.registerActionHandler(
'ApprovalController:hasRequest',
approvalControllerMock.hasRequest.bind(approvalControllerMock),
);

rootMessenger.registerActionHandler(
'ApprovalController:acceptRequest',
approvalControllerMock.acceptRequest.bind(approvalControllerMock),
);

await expect(
rootMessenger.call(
'SnapInterfaceController:resolveInterface',
MOCK_SNAP_ID,
'foo',
'bar',
),
).rejects.toThrow(`Interface with id 'foo' not found.`);
});

it('throws if the interface is resolved by another snap', async () => {
const rootMessenger = getRootSnapInterfaceControllerMessenger();
const controllerMessenger =
getRestrictedSnapInterfaceControllerMessenger(rootMessenger);

/* eslint-disable-next-line no-new */
new SnapInterfaceController({
messenger: controllerMessenger,
});

const approvalControllerMock = new MockApprovalController();

rootMessenger.registerActionHandler(
'ApprovalController:hasRequest',
approvalControllerMock.hasRequest.bind(approvalControllerMock),
);

rootMessenger.registerActionHandler(
'ApprovalController:acceptRequest',
approvalControllerMock.acceptRequest.bind(approvalControllerMock),
);

const id = await rootMessenger.call(
'SnapInterfaceController:createInterface',
MOCK_SNAP_ID,
<Box>
<Text>foo</Text>
</Box>,
);

// eslint-disable-next-line @typescript-eslint/no-floating-promises
approvalControllerMock.addRequest({
id,
});

await expect(
rootMessenger.call(
'SnapInterfaceController:resolveInterface',
'baz' as SnapId,
id,
'bar',
),
).rejects.toThrow('Interface not created by baz.');
});

it('throws if the interface has no approval request', async () => {
const rootMessenger = getRootSnapInterfaceControllerMessenger();
const controllerMessenger =
getRestrictedSnapInterfaceControllerMessenger(rootMessenger);

/* eslint-disable-next-line no-new */
new SnapInterfaceController({
messenger: controllerMessenger,
});

const approvalControllerMock = new MockApprovalController();

rootMessenger.registerActionHandler(
'ApprovalController:hasRequest',
approvalControllerMock.hasRequest.bind(approvalControllerMock),
);

rootMessenger.registerActionHandler(
'ApprovalController:acceptRequest',
approvalControllerMock.acceptRequest.bind(approvalControllerMock),
);

const id = await rootMessenger.call(
'SnapInterfaceController:createInterface',
MOCK_SNAP_ID,
<Box>
<Text>foo</Text>
</Box>,
);

await expect(
rootMessenger.call(
'SnapInterfaceController:resolveInterface',
MOCK_SNAP_ID,
id,
'bar',
),
).rejects.toThrow(`Approval request with id '${id}' not found.`);
});
});
});
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import type {
AcceptRequest,
HasApprovalRequest,
} from '@metamask/approval-controller';
import type { RestrictedControllerMessenger } from '@metamask/base-controller';
import { BaseController } from '@metamask/base-controller';
import type {
Expand All @@ -12,6 +16,7 @@ import type {
} from '@metamask/snaps-sdk';
import type { JSXElement } from '@metamask/snaps-sdk/jsx';
import { getJsonSizeUnsafe, validateJsxLinks } from '@metamask/snaps-utils';
import type { Json } from '@metamask/utils';
import { assert } from '@metamask/utils';
import { nanoid } from 'nanoid';

Expand Down Expand Up @@ -50,16 +55,24 @@ export type UpdateInterfaceState = {
handler: SnapInterfaceController['updateInterfaceState'];
};

export type ResolveInterface = {
type: `${typeof controllerName}:resolveInterface`;
handler: SnapInterfaceController['resolveInterface'];
};

export type SnapInterfaceControllerAllowedActions =
| TestOrigin
| MaybeUpdateState;
| MaybeUpdateState
| HasApprovalRequest
| AcceptRequest;

export type SnapInterfaceControllerActions =
| CreateInterface
| GetInterface
| UpdateInterface
| DeleteInterface
| UpdateInterfaceState;
| UpdateInterfaceState
| ResolveInterface;

export type SnapInterfaceControllerMessenger = RestrictedControllerMessenger<
typeof controllerName,
Expand Down Expand Up @@ -135,6 +148,11 @@ export class SnapInterfaceController extends BaseController<
`${controllerName}:updateInterfaceState`,
this.updateInterfaceState.bind(this),
);

this.messagingSystem.registerActionHandler(
`${controllerName}:resolveInterface`,
this.resolveInterface.bind(this),
);
}

/**
Expand Down Expand Up @@ -232,6 +250,23 @@ export class SnapInterfaceController extends BaseController<
});
}

/**
* Resolve the promise of a given interface approval request.
* The approval needs to have the same ID as the interface.
*
* @param snapId - The snap id.
* @param id - The interface id.
* @param value - The value to resolve the promise with.
*/
async resolveInterface(snapId: SnapId, id: string, value: Json) {
this.#validateArgs(snapId, id);
this.#validateApproval(id);

await this.#acceptApprovalRequest(id, value);

this.deleteInterface(id);
}

/**
* Utility function to validate the args passed to the other methods.
*
Expand All @@ -251,6 +286,18 @@ export class SnapInterfaceController extends BaseController<
);
}

/**
* Utility function to validate that the approval request exists.
*
* @param id - The interface id.
*/
#validateApproval(id: string) {
assert(
this.#hasApprovalRequest(id),
`Approval request with id '${id}' not found.`,
);
}

/**
* Trigger a Phishing list update if needed.
*/
Expand All @@ -269,6 +316,33 @@ export class SnapInterfaceController extends BaseController<
.result;
}

/**
* Check if an approval request exists for a given interface by looking up
* if the ApprovalController has a request with the given interface ID.
*
* @param id - The interface id.
* @returns True if an approval request exists, otherwise false.
*/
#hasApprovalRequest(id: string) {
return this.messagingSystem.call('ApprovalController:hasRequest', {
id,
});
}

/**
* Accept an approval request for a given interface.
*
* @param id - The interface id.
* @param value - The value to resolve the promise with.
*/
async #acceptApprovalRequest(id: string, value: Json) {
await this.messagingSystem.call(
'ApprovalController:acceptRequest',
id,
value,
);
}

/**
* Utility function to validate the components of an interface.
* Throws if something is invalid.
Expand Down
24 changes: 23 additions & 1 deletion packages/snaps-controllers/src/test-utils/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,11 @@ export class MockApprovalController {
};
};

async addRequest(request: { requestData?: Record<string, Json> }) {
async addRequest(request: {
id?: string;
origin?: string;
requestData?: Record<string, Json>;
}) {
const promise = new Promise((resolve, reject) => {
this.#approval = {
promise: { resolve, reject },
Expand All @@ -85,6 +89,21 @@ export class MockApprovalController {
return promise;
}

hasRequest(
opts: { id?: string; origin?: string; type?: string } = {},
): boolean {
return this.#approval?.request.id === opts.id;
}

async acceptRequest(_id: string, value: unknown) {
if (this.#approval) {
this.#approval.promise.resolve(value);
return await Promise.resolve({ value });
}

return await Promise.reject(new Error('No request to approve.'));
}

updateRequestStateAndApprove({
requestState,
}: {
Expand Down Expand Up @@ -679,7 +698,10 @@ export const getRestrictedSnapInterfaceControllerMessenger = (
allowedActions: [
'PhishingController:testOrigin',
'PhishingController:maybeUpdateState',
'ApprovalController:hasRequest',
'ApprovalController:acceptRequest',
],
allowedEvents: [],
});

if (mocked) {
Expand Down
Loading

0 comments on commit ad0eae4

Please sign in to comment.