Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[BREAKING] Add snap_resolveInterface RPC method #2509

Merged
merged 12 commits into from
Jun 27, 2024
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);
GuillaumeRx marked this conversation as resolved.
Show resolved Hide resolved

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) {
GuillaumeRx marked this conversation as resolved.
Show resolved Hide resolved
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
Loading