diff --git a/packages/examples/packages/bip32/snap.manifest.json b/packages/examples/packages/bip32/snap.manifest.json
index f296af262e..d16700c9aa 100644
--- a/packages/examples/packages/bip32/snap.manifest.json
+++ b/packages/examples/packages/bip32/snap.manifest.json
@@ -7,7 +7,7 @@
"url": "https://github.com/MetaMask/snaps.git"
},
"source": {
- "shasum": "Ard/F/8RqOYcKlxb3xx6OB5XLtmv7t/Asrn8bhkCooQ=",
+ "shasum": "te1oZPWHfniZ/ST6D/blxr8w6yhoR4qaJeeNoOP7O6A=",
"location": {
"npm": {
"filePath": "dist/bundle.js",
diff --git a/packages/examples/packages/bip32/src/index.test.ts b/packages/examples/packages/bip32/src/index.test.ts
index 0862e16d16..7aca481873 100644
--- a/packages/examples/packages/bip32/src/index.test.ts
+++ b/packages/examples/packages/bip32/src/index.test.ts
@@ -1,7 +1,6 @@
import { expect } from '@jest/globals';
-import { installSnap } from '@metamask/snaps-jest';
+import { assertIsConfirmationDialog, installSnap } from '@metamask/snaps-jest';
import { copyable, heading, panel, text } from '@metamask/snaps-sdk';
-import { assert } from '@metamask/utils';
describe('onRpcRequest', () => {
it('throws an error if the requested method does not exist', async () => {
@@ -109,6 +108,7 @@ describe('onRpcRequest', () => {
});
const ui = await response.getInterface();
+ assertIsConfirmationDialog(ui);
expect(ui).toRender(
panel([
heading('Signature request'),
@@ -141,6 +141,7 @@ describe('onRpcRequest', () => {
});
const ui = await response.getInterface();
+ assertIsConfirmationDialog(ui);
expect(ui).toRender(
panel([
heading('Signature request'),
@@ -173,6 +174,7 @@ describe('onRpcRequest', () => {
});
const ui = await response.getInterface();
+ assertIsConfirmationDialog(ui);
expect(ui).toRender(
panel([
heading('Signature request'),
@@ -205,7 +207,7 @@ describe('onRpcRequest', () => {
});
const ui = await response.getInterface();
- assert(ui.type === 'confirmation');
+ assertIsConfirmationDialog(ui);
await ui.cancel();
expect(await response).toRespondWithError({
diff --git a/packages/examples/packages/bip44/snap.manifest.json b/packages/examples/packages/bip44/snap.manifest.json
index 919ec21e0f..4b507f1c3b 100644
--- a/packages/examples/packages/bip44/snap.manifest.json
+++ b/packages/examples/packages/bip44/snap.manifest.json
@@ -7,7 +7,7 @@
"url": "https://github.com/MetaMask/snaps.git"
},
"source": {
- "shasum": "jJuoqF4p++cEfWb3YdEfuwlOEw7bByfpEMh6adH8sOI=",
+ "shasum": "yzw5UM4aWmD8vLglR9gazcNC0yKntyPp5A5sPSmouL4=",
"location": {
"npm": {
"filePath": "dist/bundle.js",
diff --git a/packages/examples/packages/bip44/src/index.test.ts b/packages/examples/packages/bip44/src/index.test.ts
index 7986891bb7..92f8794857 100644
--- a/packages/examples/packages/bip44/src/index.test.ts
+++ b/packages/examples/packages/bip44/src/index.test.ts
@@ -1,7 +1,6 @@
import { expect } from '@jest/globals';
-import { installSnap } from '@metamask/snaps-jest';
+import { assertIsConfirmationDialog, installSnap } from '@metamask/snaps-jest';
import { copyable, heading, panel, text } from '@metamask/snaps-sdk';
-import { assert } from '@metamask/utils';
describe('onRpcRequest', () => {
it('throws an error if the requested method does not exist', async () => {
@@ -84,6 +83,8 @@ describe('onRpcRequest', () => {
});
const ui = await response.getInterface();
+ assertIsConfirmationDialog(ui);
+
expect(ui).toRender(
panel([
heading('Signature request'),
@@ -114,6 +115,8 @@ describe('onRpcRequest', () => {
});
const ui = await response.getInterface();
+ assertIsConfirmationDialog(ui);
+
expect(ui).toRender(
panel([
heading('Signature request'),
@@ -144,7 +147,8 @@ describe('onRpcRequest', () => {
});
const ui = await response.getInterface();
- assert(ui.type === 'confirmation');
+ assertIsConfirmationDialog(ui);
+
await ui.cancel();
expect(await response).toRespondWithError({
diff --git a/packages/examples/packages/browserify-plugin/snap.manifest.json b/packages/examples/packages/browserify-plugin/snap.manifest.json
index b27aa6c458..ab2c6affa9 100644
--- a/packages/examples/packages/browserify-plugin/snap.manifest.json
+++ b/packages/examples/packages/browserify-plugin/snap.manifest.json
@@ -7,7 +7,7 @@
"url": "https://github.com/MetaMask/snaps.git"
},
"source": {
- "shasum": "nBoIUO4s8aakybthHavEuRjxCoNVzevKVv1mr1u2uNM=",
+ "shasum": "a+jOIwfxC3ITF6MbF5ltQvM0ZvCJ5/NQJlbEsqWTQrk=",
"location": {
"npm": {
"filePath": "dist/bundle.js",
diff --git a/packages/examples/packages/browserify/snap.manifest.json b/packages/examples/packages/browserify/snap.manifest.json
index 3209fa4f40..a7cb3b7d50 100644
--- a/packages/examples/packages/browserify/snap.manifest.json
+++ b/packages/examples/packages/browserify/snap.manifest.json
@@ -7,7 +7,7 @@
"url": "https://github.com/MetaMask/snaps.git"
},
"source": {
- "shasum": "Kq4I9q6BdkSUrUsjysqqsfjlQ1gu6vM4sXSo7pgNruI=",
+ "shasum": "HPCtZ7reZrbut7cl09SnaoAHkzrvlvjc60leUfkcsVI=",
"location": {
"npm": {
"filePath": "dist/bundle.js",
diff --git a/packages/examples/packages/client-status/snap.manifest.json b/packages/examples/packages/client-status/snap.manifest.json
index e418f0e16b..cbaa5b762a 100644
--- a/packages/examples/packages/client-status/snap.manifest.json
+++ b/packages/examples/packages/client-status/snap.manifest.json
@@ -7,7 +7,7 @@
"url": "https://github.com/MetaMask/snaps.git"
},
"source": {
- "shasum": "RNHnGSZaZRAEkH7h4eqFfmic8Ixq30CXj4JWnhWkQbI=",
+ "shasum": "7BLeIk79FlkIK9fNKHcCdX8UChmjU7kOkGVLxOc29Kw=",
"location": {
"npm": {
"filePath": "dist/bundle.js",
diff --git a/packages/examples/packages/cronjobs/snap.manifest.json b/packages/examples/packages/cronjobs/snap.manifest.json
index 214ad6018a..39a629f55f 100644
--- a/packages/examples/packages/cronjobs/snap.manifest.json
+++ b/packages/examples/packages/cronjobs/snap.manifest.json
@@ -7,7 +7,7 @@
"url": "https://github.com/MetaMask/snaps.git"
},
"source": {
- "shasum": "0YLZlM7w5GpjDWYmmJFxiYB2kJnNG55ru3/8ywKVT9I=",
+ "shasum": "DMmgoUR+Q1/eYJTjZQ96jI406CpgObFs454UR1nKGM4=",
"location": {
"npm": {
"filePath": "dist/bundle.js",
diff --git a/packages/examples/packages/dialogs/package.json b/packages/examples/packages/dialogs/package.json
index 229d73fd8b..9a7f03eca1 100644
--- a/packages/examples/packages/dialogs/package.json
+++ b/packages/examples/packages/dialogs/package.json
@@ -42,7 +42,6 @@
"@metamask/eslint-config-typescript": "^12.1.0",
"@metamask/snaps-cli": "workspace:^",
"@metamask/snaps-jest": "workspace:^",
- "@metamask/utils": "^8.3.0",
"@swc/core": "1.3.78",
"@swc/jest": "^0.2.26",
"@typescript-eslint/eslint-plugin": "^5.42.1",
diff --git a/packages/examples/packages/dialogs/snap.config.ts b/packages/examples/packages/dialogs/snap.config.ts
index 666bf6eb00..559cfc9b90 100644
--- a/packages/examples/packages/dialogs/snap.config.ts
+++ b/packages/examples/packages/dialogs/snap.config.ts
@@ -2,7 +2,7 @@ import type { SnapConfig } from '@metamask/snaps-cli';
import { resolve } from 'path';
const config: SnapConfig = {
- input: resolve(__dirname, 'src/index.ts'),
+ input: resolve(__dirname, 'src/index.tsx'),
server: {
port: 8005,
},
diff --git a/packages/examples/packages/dialogs/snap.manifest.json b/packages/examples/packages/dialogs/snap.manifest.json
index d2dec66ab4..a7eecfb84d 100644
--- a/packages/examples/packages/dialogs/snap.manifest.json
+++ b/packages/examples/packages/dialogs/snap.manifest.json
@@ -7,7 +7,7 @@
"url": "https://github.com/MetaMask/snaps.git"
},
"source": {
- "shasum": "5cc1fSlz4S5sDjlkjWhBUNjOQu6VdCqS5ln/DJjssSk=",
+ "shasum": "F5K30FZGcmERlgvw8D8v4Vuqh2FP2fxSeq0KVOT4LYg=",
"location": {
"npm": {
"filePath": "dist/bundle.js",
diff --git a/packages/examples/packages/dialogs/src/components/custom.tsx b/packages/examples/packages/dialogs/src/components/custom.tsx
new file mode 100644
index 0000000000..70473b8952
--- /dev/null
+++ b/packages/examples/packages/dialogs/src/components/custom.tsx
@@ -0,0 +1,32 @@
+import {
+ Box,
+ Button,
+ Container,
+ Footer,
+ Heading,
+ Input,
+ Text,
+ type SnapComponent,
+} from '@metamask/snaps-sdk/jsx';
+
+/**
+ * A custom dialog component.
+ *
+ * @returns The custom dialog component.
+ */
+export const CustomDialog: SnapComponent = () => (
+
+
+ Custom Dialog
+
+ This is a custom dialog. It has a custom Footer and can be resolved to
+ any value.
+
+
+
+
+
+);
diff --git a/packages/examples/packages/dialogs/src/components/index.ts b/packages/examples/packages/dialogs/src/components/index.ts
new file mode 100644
index 0000000000..c7226ae4e2
--- /dev/null
+++ b/packages/examples/packages/dialogs/src/components/index.ts
@@ -0,0 +1 @@
+export * from './custom';
diff --git a/packages/examples/packages/dialogs/src/index.test.ts b/packages/examples/packages/dialogs/src/index.test.tsx
similarity index 76%
rename from packages/examples/packages/dialogs/src/index.test.ts
rename to packages/examples/packages/dialogs/src/index.test.tsx
index cf53bc4475..0c28a1de84 100644
--- a/packages/examples/packages/dialogs/src/index.test.ts
+++ b/packages/examples/packages/dialogs/src/index.test.tsx
@@ -1,7 +1,14 @@
import { expect } from '@jest/globals';
-import { installSnap } from '@metamask/snaps-jest';
+import {
+ assertIsAlertDialog,
+ assertIsConfirmationDialog,
+ assertIsCustomDialog,
+ assertIsPromptDialog,
+ installSnap,
+} from '@metamask/snaps-jest';
import { heading, panel, text } from '@metamask/snaps-sdk';
-import { assert } from '@metamask/utils';
+
+import { CustomDialog } from './components';
describe('onRpcRequest', () => {
it('throws an error if the requested method does not exist', async () => {
@@ -31,7 +38,7 @@ describe('onRpcRequest', () => {
});
const ui = await response.getInterface();
- assert(ui.type === 'alert');
+ assertIsAlertDialog(ui);
expect(ui).toRender(
panel([
@@ -55,7 +62,7 @@ describe('onRpcRequest', () => {
});
const ui = await response.getInterface();
- assert(ui.type === 'confirmation');
+ assertIsConfirmationDialog(ui);
expect(ui).toRender(
panel([
@@ -79,7 +86,7 @@ describe('onRpcRequest', () => {
});
const ui = await response.getInterface();
- assert(ui.type === 'confirmation');
+ assertIsConfirmationDialog(ui);
await ui.cancel();
expect(await response).toRespondWith(false);
@@ -95,7 +102,7 @@ describe('onRpcRequest', () => {
});
const ui = await response.getInterface();
- assert(ui.type === 'prompt');
+ assertIsPromptDialog(ui);
expect(ui).toRender(
panel([
@@ -119,10 +126,34 @@ describe('onRpcRequest', () => {
});
const ui = await response.getInterface();
- assert(ui.type === 'prompt');
+ assertIsPromptDialog(ui);
+
await ui.cancel();
expect(await response).toRespondWith(null);
});
});
+
+ describe('showCustom', () => {
+ it('shows a custom dialog and can return the input value', async () => {
+ const value = 'Hello, world!';
+
+ const { request } = await installSnap();
+
+ const response = request({
+ method: 'showCustom',
+ });
+
+ const ui = await response.getInterface();
+ assertIsCustomDialog(ui);
+
+ expect(ui).toRender();
+
+ await ui.typeInField('custom-input', value);
+
+ await ui.clickElement('confirm');
+
+ expect(await response).toRespondWith(value);
+ });
+ });
});
diff --git a/packages/examples/packages/dialogs/src/index.ts b/packages/examples/packages/dialogs/src/index.tsx
similarity index 63%
rename from packages/examples/packages/dialogs/src/index.ts
rename to packages/examples/packages/dialogs/src/index.tsx
index b367d39a5e..a1849576e3 100644
--- a/packages/examples/packages/dialogs/src/index.ts
+++ b/packages/examples/packages/dialogs/src/index.tsx
@@ -1,12 +1,18 @@
-import type { OnRpcRequestHandler } from '@metamask/snaps-sdk';
+import type {
+ OnRpcRequestHandler,
+ OnUserInputHandler,
+} from '@metamask/snaps-sdk';
import {
DialogType,
panel,
text,
heading,
MethodNotFoundError,
+ UserInputEventType,
} from '@metamask/snaps-sdk';
+import { CustomDialog } from './components';
+
/**
* Handle incoming JSON-RPC requests from the dapp, sent through the
* `wallet_invokeSnap` method. This handler handles three methods, one for each
@@ -15,6 +21,7 @@ import {
* - `showAlert`: Show an alert dialog.
* - `showConfirmation`: Show a confirmation dialog.
* - `showPrompt`: Show a prompt dialog.
+ * - `showCustom`: Show a custom dialog with the resolution handled by the snap.
*
* The dialogs are shown using the [`snap_dialog`](https://docs.metamask.io/snaps/reference/rpc-api/#snap_dialog)
* method.
@@ -75,7 +82,62 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => {
},
});
+ case 'showCustom':
+ return snap.request({
+ method: 'snap_dialog',
+ params: {
+ content: ,
+ },
+ });
+
default:
throw new MethodNotFoundError({ method: request.method });
}
};
+
+/**
+ * Handle incoming user events coming from the MetaMask clients open interfaces.
+ *
+ * @param params - The event parameters.
+ * @param params.id - The Snap interface ID where the event was fired.
+ * @param params.event - The event object containing the event type, name and value.
+ * @see https://docs.metamask.io/snaps/reference/exports/#onuserinput
+ */
+export const onUserInput: OnUserInputHandler = async ({ id, event }) => {
+ if (event.type === UserInputEventType.ButtonClickEvent) {
+ switch (event.name) {
+ case 'cancel':
+ await snap.request({
+ method: 'snap_resolveInterface',
+ params: {
+ id,
+ value: null,
+ },
+ });
+ break;
+
+ case 'confirm': {
+ const state = await snap.request({
+ method: 'snap_getInterfaceState',
+ params: {
+ id,
+ },
+ });
+
+ const value = state['custom-input'];
+
+ await snap.request({
+ method: 'snap_resolveInterface',
+ params: {
+ id,
+ value,
+ },
+ });
+ break;
+ }
+
+ default:
+ break;
+ }
+ }
+};
diff --git a/packages/examples/packages/dialogs/tsconfig.json b/packages/examples/packages/dialogs/tsconfig.json
index 1cb4c3315f..32e2e013f3 100644
--- a/packages/examples/packages/dialogs/tsconfig.json
+++ b/packages/examples/packages/dialogs/tsconfig.json
@@ -2,7 +2,10 @@
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": "./",
+ "jsx": "react-jsxdev",
"paths": {
+ "@metamask/*/jsx": ["../../../*/src/jsx"],
+ "@metamask/*/jsx-dev-runtime": ["../../../*/src/jsx"],
"@metamask/*": ["../../../*/src"]
}
},
diff --git a/packages/examples/packages/ethereum-provider/snap.manifest.json b/packages/examples/packages/ethereum-provider/snap.manifest.json
index 18ae1cb98d..b1fd03740b 100644
--- a/packages/examples/packages/ethereum-provider/snap.manifest.json
+++ b/packages/examples/packages/ethereum-provider/snap.manifest.json
@@ -7,7 +7,7 @@
"url": "https://github.com/MetaMask/snaps.git"
},
"source": {
- "shasum": "8L6HiuUzKAQVeog5uIIYiqBOuEb2Qm3+9HH+fXs5PlM=",
+ "shasum": "iHt9Se2I/fFZeIQW6p5qyNGt+wD7S2NKlNPLYjSTZMc=",
"location": {
"npm": {
"filePath": "dist/bundle.js",
diff --git a/packages/examples/packages/ethers-js/package.json b/packages/examples/packages/ethers-js/package.json
index 3289714eca..451602ed1d 100644
--- a/packages/examples/packages/ethers-js/package.json
+++ b/packages/examples/packages/ethers-js/package.json
@@ -43,7 +43,6 @@
"@metamask/eslint-config-typescript": "^12.1.0",
"@metamask/snaps-cli": "workspace:^",
"@metamask/snaps-jest": "workspace:^",
- "@metamask/utils": "^8.3.0",
"@swc/core": "1.3.78",
"@swc/jest": "^0.2.26",
"@typescript-eslint/eslint-plugin": "^5.42.1",
diff --git a/packages/examples/packages/ethers-js/snap.manifest.json b/packages/examples/packages/ethers-js/snap.manifest.json
index 9b4c451f4a..881b8871b0 100644
--- a/packages/examples/packages/ethers-js/snap.manifest.json
+++ b/packages/examples/packages/ethers-js/snap.manifest.json
@@ -7,7 +7,7 @@
"url": "https://github.com/MetaMask/snaps.git"
},
"source": {
- "shasum": "5fi+IIMm/FubpDnpYP+YGW/dCUgTT4bVIxxDmcuybr8=",
+ "shasum": "K1gDsaTcrhG5vRH5Tb1uswHg6QnxYIqKjynyvzLlbfM=",
"location": {
"npm": {
"filePath": "dist/bundle.js",
diff --git a/packages/examples/packages/ethers-js/src/index.test.ts b/packages/examples/packages/ethers-js/src/index.test.ts
index e9acffcae7..b4c4ac5a30 100644
--- a/packages/examples/packages/ethers-js/src/index.test.ts
+++ b/packages/examples/packages/ethers-js/src/index.test.ts
@@ -1,7 +1,6 @@
import { expect } from '@jest/globals';
-import { installSnap } from '@metamask/snaps-jest';
+import { assertIsConfirmationDialog, installSnap } from '@metamask/snaps-jest';
import { copyable, heading, panel, text } from '@metamask/snaps-sdk';
-import { assert } from '@metamask/utils';
describe('onRpcRequest', () => {
it('throws an error if the requested method does not exist', async () => {
@@ -48,6 +47,8 @@ describe('onRpcRequest', () => {
});
const ui = await response.getInterface();
+ assertIsConfirmationDialog(ui);
+
expect(ui).toRender(
panel([
heading('Signature request'),
@@ -74,7 +75,7 @@ describe('onRpcRequest', () => {
});
const ui = await response.getInterface();
- assert(ui.type === 'confirmation');
+ assertIsConfirmationDialog(ui);
await ui.cancel();
expect(await response).toRespondWithError({
diff --git a/packages/examples/packages/file-upload/snap.manifest.json b/packages/examples/packages/file-upload/snap.manifest.json
index 003b56b552..36d27cf828 100644
--- a/packages/examples/packages/file-upload/snap.manifest.json
+++ b/packages/examples/packages/file-upload/snap.manifest.json
@@ -7,7 +7,7 @@
"url": "https://github.com/MetaMask/snaps.git"
},
"source": {
- "shasum": "+yioSnJPHDQRsStHxbK9pm9bUTAgvCFrpWjwvWZNWqE=",
+ "shasum": "XWQyeVfzXAXLSNiE9je6OjMU8dqlDw0Mw4q6QApCgFo=",
"location": {
"npm": {
"filePath": "dist/bundle.js",
diff --git a/packages/examples/packages/get-entropy/snap.manifest.json b/packages/examples/packages/get-entropy/snap.manifest.json
index f04ad0eeaa..e157546178 100644
--- a/packages/examples/packages/get-entropy/snap.manifest.json
+++ b/packages/examples/packages/get-entropy/snap.manifest.json
@@ -7,7 +7,7 @@
"url": "https://github.com/MetaMask/snaps.git"
},
"source": {
- "shasum": "nlhNiocqRC/yaz73W2wMGxIXswHm6YCCigdd6X/maeU=",
+ "shasum": "WXUTcCOLbtQnTMVKVz2X2IeQG8ICXkmDozXlil/Z3rI=",
"location": {
"npm": {
"filePath": "dist/bundle.js",
diff --git a/packages/examples/packages/get-file/snap.manifest.json b/packages/examples/packages/get-file/snap.manifest.json
index b64a2641b0..ac0669a257 100644
--- a/packages/examples/packages/get-file/snap.manifest.json
+++ b/packages/examples/packages/get-file/snap.manifest.json
@@ -7,7 +7,7 @@
"url": "https://github.com/MetaMask/snaps.git"
},
"source": {
- "shasum": "opNYkgiLjqm7OBH2Wi4eAewEntFg0trEfw8yk8sQJGk=",
+ "shasum": "dhEHU8KvnRAcZlEq4cnrhaVaEeRux9b0HA8LrA5GPnI=",
"location": {
"npm": {
"filePath": "dist/bundle.js",
diff --git a/packages/examples/packages/home-page/snap.manifest.json b/packages/examples/packages/home-page/snap.manifest.json
index 3c686aeb78..8f21ca554b 100644
--- a/packages/examples/packages/home-page/snap.manifest.json
+++ b/packages/examples/packages/home-page/snap.manifest.json
@@ -7,7 +7,7 @@
"url": "https://github.com/MetaMask/snaps.git"
},
"source": {
- "shasum": "undUBL7PBSvSR81E5AfodCp7nY0658OuaCe6eY2Xjaw=",
+ "shasum": "YYz045/kftDaFs5MVv7EJzcMOtu7KBZa9RgceCoD9Gc=",
"location": {
"npm": {
"filePath": "dist/bundle.js",
diff --git a/packages/examples/packages/images/snap.manifest.json b/packages/examples/packages/images/snap.manifest.json
index a080b64903..b86659ec87 100644
--- a/packages/examples/packages/images/snap.manifest.json
+++ b/packages/examples/packages/images/snap.manifest.json
@@ -7,7 +7,7 @@
"url": "https://github.com/MetaMask/snaps.git"
},
"source": {
- "shasum": "yOiOfKpriNZCSbXVfddcEw8EOkKd+bjPlvtl/yowGNo=",
+ "shasum": "17xibkzbvbonxdKRhYc1/hcy07SMuF1kUnWTww01Wh8=",
"location": {
"npm": {
"filePath": "dist/bundle.js",
diff --git a/packages/examples/packages/interactive-ui/snap.manifest.json b/packages/examples/packages/interactive-ui/snap.manifest.json
index 58c199dd59..c3551b8670 100644
--- a/packages/examples/packages/interactive-ui/snap.manifest.json
+++ b/packages/examples/packages/interactive-ui/snap.manifest.json
@@ -7,7 +7,7 @@
"url": "https://github.com/MetaMask/snaps.git"
},
"source": {
- "shasum": "qonHJAYzv7XPR96X+J3qOs5wxnWkfmWkUqK3ooYdkGI=",
+ "shasum": "RjotFvCYvRLQkQh0/MfN+KnVdoYaX/iG5xJ72hgea+8=",
"location": {
"npm": {
"filePath": "dist/bundle.js",
diff --git a/packages/examples/packages/interactive-ui/src/index.test.tsx b/packages/examples/packages/interactive-ui/src/index.test.tsx
index b2bc211989..2bc197547c 100644
--- a/packages/examples/packages/interactive-ui/src/index.test.tsx
+++ b/packages/examples/packages/interactive-ui/src/index.test.tsx
@@ -1,6 +1,5 @@
import { expect } from '@jest/globals';
-import { installSnap } from '@metamask/snaps-jest';
-import { assert } from '@metamask/utils';
+import { assertIsConfirmationDialog, installSnap } from '@metamask/snaps-jest';
import {
Insight,
@@ -37,7 +36,6 @@ describe('onRpcRequest', () => {
});
const formScreen = await response.getInterface();
- assert(formScreen.type === 'confirmation');
expect(formScreen).toRender();
@@ -50,6 +48,7 @@ describe('onRpcRequest', () => {
await formScreen.clickElement('submit');
const resultScreen = await response.getInterface();
+ assertIsConfirmationDialog(resultScreen);
expect(resultScreen).toRender(
{
});
const formScreen = await response.getInterface();
- assert(formScreen.type === 'confirmation');
expect(formScreen).toRender();
await formScreen.clickElement('submit');
const resultScreen = await response.getInterface();
+ assertIsConfirmationDialog(resultScreen);
expect(resultScreen).toRender(
{
// This is useful if you're using TypeScript, since it will infer the type
// of the user interface.
- assert(ui.type === 'alert');
+ assertIsAlertDialog(ui);
expect(ui).toRender(text('Hello, world!'));
// "Click" the OK button.
diff --git a/packages/snaps-jest/src/helpers.test.tsx b/packages/snaps-jest/src/helpers.test.tsx
index f1703ce90e..5429a84721 100644
--- a/packages/snaps-jest/src/helpers.test.tsx
+++ b/packages/snaps-jest/src/helpers.test.tsx
@@ -2,9 +2,13 @@ import { NodeProcessExecutionService } from '@metamask/snaps-controllers/node';
import { DialogType } from '@metamask/snaps-sdk';
import { Text } from '@metamask/snaps-sdk/jsx';
import { getSnapManifest } from '@metamask/snaps-utils/test-utils';
-import { assert } from '@metamask/utils';
-import { installSnap } from './helpers';
+import {
+ assertIsAlertDialog,
+ assertIsConfirmationDialog,
+ assertIsPromptDialog,
+ installSnap,
+} from './helpers';
import type { InstallSnapOptions } from './internals';
import { handleInstallSnap } from './internals';
import { getMockServer } from './test-utils';
@@ -397,7 +401,7 @@ describe('installSnap', () => {
});
const ui = await response.getInterface();
- assert(ui.type === DialogType.Prompt);
+ assertIsPromptDialog(ui);
expect(ui).toStrictEqual({
type: DialogType.Prompt,
content: Hello, world!,
@@ -457,7 +461,7 @@ describe('installSnap', () => {
});
const ui = await response.getInterface();
- assert(ui.type === DialogType.Confirmation);
+ assertIsConfirmationDialog(ui);
expect(ui).toStrictEqual({
type: DialogType.Confirmation,
content: Hello, world!,
@@ -517,6 +521,7 @@ describe('installSnap', () => {
});
const ui = await response.getInterface();
+ assertIsAlertDialog(ui);
expect(ui).toStrictEqual({
type: DialogType.Alert,
content: Hello, world!,
diff --git a/packages/snaps-jest/src/helpers.ts b/packages/snaps-jest/src/helpers.ts
index 438c892dac..16869acdce 100644
--- a/packages/snaps-jest/src/helpers.ts
+++ b/packages/snaps-jest/src/helpers.ts
@@ -1,7 +1,13 @@
import type { AbstractExecutionService } from '@metamask/snaps-controllers';
-import type { SnapId } from '@metamask/snaps-sdk';
-import { HandlerType, logInfo } from '@metamask/snaps-utils';
-import { assertStruct, createModuleLogger } from '@metamask/utils';
+import { DialogType, type SnapId } from '@metamask/snaps-sdk';
+import type { FooterElement } from '@metamask/snaps-sdk/jsx';
+import { HandlerType, getJsxChildren, logInfo } from '@metamask/snaps-utils';
+import {
+ assertStruct,
+ createModuleLogger,
+ hasProperty,
+ assert,
+} from '@metamask/utils';
import { create } from 'superstruct';
import {
@@ -12,6 +18,7 @@ import {
JsonRpcMockOptionsStruct,
SignatureOptionsStruct,
SnapResponseWithInterfaceStruct,
+ getElementByType,
} from './internals';
import type { InstallSnapOptions } from './internals';
import {
@@ -25,6 +32,15 @@ import type {
Snap,
SnapResponse,
TransactionOptions,
+ SnapInterface,
+ SnapAlertInterface,
+ SnapInterfaceActions,
+ SnapConfirmationInterface,
+ SnapPromptInterface,
+ DefaultSnapInterface,
+ DefaultSnapInterfaceWithFooter,
+ DefaultSnapInterfaceWithPartialFooter,
+ DefaultSnapInterfaceWithoutFooter,
} from './types';
const log = createModuleLogger(rootLogger, 'helpers');
@@ -62,6 +78,89 @@ function assertIsResponseWithInterface(
assertStruct(response, SnapResponseWithInterfaceStruct);
}
+/**
+ * Ensure that the actual interface is an alert dialog.
+ *
+ * @param ui - The interface to verify.
+ */
+export function assertIsAlertDialog(
+ ui: SnapInterface,
+): asserts ui is SnapAlertInterface & SnapInterfaceActions {
+ assert(hasProperty(ui, 'type') && ui.type === DialogType.Alert);
+}
+
+/**
+ * Ensure that the actual interface is a confirmation dialog.
+ *
+ * @param ui - The interface to verify.
+ */
+export function assertIsConfirmationDialog(
+ ui: SnapInterface,
+): asserts ui is SnapConfirmationInterface & SnapInterfaceActions {
+ assert(hasProperty(ui, 'type') && ui.type === DialogType.Confirmation);
+}
+
+/**
+ * Ensure that the actual interface is a Prompt dialog.
+ *
+ * @param ui - The interface to verify.
+ */
+export function assertIsPromptDialog(
+ ui: SnapInterface,
+): asserts ui is SnapPromptInterface & SnapInterfaceActions {
+ assert(hasProperty(ui, 'type') && ui.type === DialogType.Prompt);
+}
+
+/**
+ * Ensure that the actual interface is a custom dialog.
+ *
+ * @param ui - The interface to verify.
+ */
+export function assertIsCustomDialog(
+ ui: SnapInterface,
+): asserts ui is DefaultSnapInterface & SnapInterfaceActions {
+ assert(!hasProperty(ui, 'type'));
+}
+
+/**
+ * Ensure that the actual interface is a custom dialog with a complete footer.
+ *
+ * @param ui - The interface to verify.
+ */
+export function assertCustomDialogHasFooter(
+ ui: DefaultSnapInterface & SnapInterfaceActions,
+): asserts ui is DefaultSnapInterfaceWithFooter & SnapInterfaceActions {
+ const footer = getElementByType(ui.content, 'Footer');
+
+ assert(footer && getJsxChildren(footer).length === 2);
+}
+
+/**
+ * Ensure that the actual interface is a custom dialog with a partial footer.
+ *
+ * @param ui - The interface to verify.
+ */
+export function assertCustomDialogHasPartialFooter(
+ ui: DefaultSnapInterface & SnapInterfaceActions,
+): asserts ui is DefaultSnapInterfaceWithPartialFooter & SnapInterfaceActions {
+ const footer = getElementByType(ui.content, 'Footer');
+
+ assert(footer && getJsxChildren(footer).length === 1);
+}
+
+/**
+ * Ensure that the actual interface is a custom dialog without a footer.
+ *
+ * @param ui - The interface to verify.
+ */
+export function assertCustomDialogHasNoFooter(
+ ui: DefaultSnapInterface & SnapInterfaceActions,
+): asserts ui is DefaultSnapInterfaceWithoutFooter & SnapInterfaceActions {
+ const footer = getElementByType(ui.content, 'Footer');
+
+ assert(!footer);
+}
+
/**
* Load a snap into the environment. This is the main entry point for testing
* snaps: It returns a {@link Snap} object that can be used to interact with the
diff --git a/packages/snaps-jest/src/internals/simulation/controllers.test.ts b/packages/snaps-jest/src/internals/simulation/controllers.test.ts
index 0c1ccab84a..968e5d8a9c 100644
--- a/packages/snaps-jest/src/internals/simulation/controllers.test.ts
+++ b/packages/snaps-jest/src/internals/simulation/controllers.test.ts
@@ -9,11 +9,13 @@ import { getControllers } from './controllers';
import type { MiddlewareHooks } from './simulation';
const MOCK_HOOKS: MiddlewareHooks = {
+ getIsLocked: jest.fn(),
getMnemonic: jest.fn(),
getSnapFile: jest.fn(),
createInterface: jest.fn(),
updateInterface: jest.fn(),
getInterfaceState: jest.fn(),
+ resolveInterface: jest.fn(),
};
describe('getControllers', () => {
diff --git a/packages/snaps-jest/src/internals/simulation/controllers.ts b/packages/snaps-jest/src/internals/simulation/controllers.ts
index 633e76bfcd..804279c36d 100644
--- a/packages/snaps-jest/src/internals/simulation/controllers.ts
+++ b/packages/snaps-jest/src/internals/simulation/controllers.ts
@@ -82,6 +82,8 @@ export function getControllers(options: GetControllersOptions): Controllers {
allowedActions: [
'PhishingController:maybeUpdateState',
'PhishingController:testOrigin',
+ 'ApprovalController:hasRequest',
+ 'ApprovalController:acceptRequest',
],
allowedEvents: [],
}),
diff --git a/packages/snaps-jest/src/internals/simulation/interface.test.tsx b/packages/snaps-jest/src/internals/simulation/interface.test.tsx
index 9e89e88e01..a177b647c1 100644
--- a/packages/snaps-jest/src/internals/simulation/interface.test.tsx
+++ b/packages/snaps-jest/src/internals/simulation/interface.test.tsx
@@ -20,6 +20,8 @@ import {
FileInput,
Checkbox,
Form,
+ Container,
+ Footer,
} from '@metamask/snaps-sdk/jsx';
import {
getJsxElementFromComponent,
@@ -27,10 +29,18 @@ import {
WrappedSnapError,
} from '@metamask/snaps-utils';
import { MOCK_SNAP_ID } from '@metamask/snaps-utils/test-utils';
-import { assert } from '@metamask/utils';
import type { SagaIterator } from 'redux-saga';
import { take } from 'redux-saga/effects';
+import {
+ assertIsAlertDialog,
+ assertIsConfirmationDialog,
+ assertIsCustomDialog,
+ assertIsPromptDialog,
+ assertCustomDialogHasFooter,
+ assertCustomDialogHasPartialFooter,
+ assertCustomDialogHasNoFooter,
+} from '../../helpers';
import {
getMockOptions,
getRestrictedSnapInterfaceControllerMessenger,
@@ -42,6 +52,7 @@ import {
getInterface,
getInterfaceResponse,
mergeValue,
+ resolveWithSaga,
selectInDropdown,
typeInField,
uploadFile,
@@ -79,6 +90,7 @@ describe('getInterfaceResponse', () => {
foo,
interfaceActions,
);
+ assertIsAlertDialog(response);
expect(response).toStrictEqual({
type: DialogType.Alert,
@@ -104,6 +116,7 @@ describe('getInterfaceResponse', () => {
interfaceActions,
);
+ assertIsConfirmationDialog(response);
expect(response).toStrictEqual({
type: DialogType.Confirmation,
content: foo,
@@ -129,7 +142,7 @@ describe('getInterfaceResponse', () => {
interfaceActions,
);
- assert(response.type === DialogType.Confirmation);
+ assertIsConfirmationDialog(response);
expect(response).toStrictEqual({
type: DialogType.Confirmation,
content: foo,
@@ -155,6 +168,7 @@ describe('getInterfaceResponse', () => {
interfaceActions,
);
+ assertIsPromptDialog(response);
expect(response).toStrictEqual({
type: DialogType.Prompt,
content: foo,
@@ -180,6 +194,7 @@ describe('getInterfaceResponse', () => {
interfaceActions,
);
+ assertIsPromptDialog(response);
expect(response).toStrictEqual({
type: DialogType.Prompt,
content: foo,
@@ -205,7 +220,7 @@ describe('getInterfaceResponse', () => {
interfaceActions,
);
- assert(response.type === DialogType.Prompt);
+ assertIsPromptDialog(response);
expect(response).toStrictEqual({
type: DialogType.Prompt,
content: foo,
@@ -222,6 +237,116 @@ describe('getInterfaceResponse', () => {
expect(await promise).toBeNull();
});
+ it('returns no `ok` or `cancel` functions for custom dialogs with a complete footer', () => {
+ const { runSaga } = createStore(getMockOptions());
+ const response = getInterfaceResponse(
+ runSaga,
+ DIALOG_APPROVAL_TYPES.default,
+
+
+ foo
+
+
+ ,
+ interfaceActions,
+ );
+
+ assertIsCustomDialog(response);
+ assertCustomDialogHasFooter(response);
+
+ expect(response).toStrictEqual({
+ content: (
+
+
+ foo
+
+
+
+ ),
+ clickElement: expect.any(Function),
+ typeInField: expect.any(Function),
+ selectInDropdown: expect.any(Function),
+ uploadFile: expect.any(Function),
+ });
+ });
+
+ it('returns a `cancel` functions for custom dialogs with a partial footer', () => {
+ const { runSaga } = createStore(getMockOptions());
+ const response = getInterfaceResponse(
+ runSaga,
+ DIALOG_APPROVAL_TYPES.default,
+
+
+ foo
+
+
+ ,
+ interfaceActions,
+ );
+
+ assertIsCustomDialog(response);
+ assertCustomDialogHasPartialFooter(response);
+
+ expect(response).toStrictEqual({
+ content: (
+
+
+ foo
+
+
+
+ ),
+ clickElement: expect.any(Function),
+ typeInField: expect.any(Function),
+ selectInDropdown: expect.any(Function),
+ uploadFile: expect.any(Function),
+ cancel: expect.any(Function),
+ });
+ });
+
+ it('returns a `ok` and `cancel` functions for custom dialogs without a footer', () => {
+ const { runSaga } = createStore(getMockOptions());
+ const response = getInterfaceResponse(
+ runSaga,
+ DIALOG_APPROVAL_TYPES.default,
+
+
+ foo
+
+ ,
+ interfaceActions,
+ );
+
+ assertIsCustomDialog(response);
+ assertCustomDialogHasNoFooter(response);
+
+ expect(response).toStrictEqual({
+ content: (
+
+
+ foo
+
+
+ ),
+ clickElement: expect.any(Function),
+ typeInField: expect.any(Function),
+ selectInDropdown: expect.any(Function),
+ uploadFile: expect.any(Function),
+ cancel: expect.any(Function),
+ ok: expect.any(Function),
+ });
+ });
+
it('throws an error for unknown dialog types', () => {
const { runSaga } = createStore(getMockOptions());
@@ -232,6 +357,27 @@ describe('getInterfaceResponse', () => {
});
});
+describe('resolveWithSaga', () => {
+ it('resolves the user interface', async () => {
+ const { runSaga, store } = createStore(
+ getMockOptions({
+ state: {
+ ui: {
+ current: {
+ type: DIALOG_APPROVAL_TYPES.default,
+ id: 'foo',
+ },
+ },
+ },
+ }),
+ );
+
+ await runSaga(resolveWithSaga, 'hello').toPromise();
+
+ expect(store.getState().ui.current).toBeNull();
+ });
+});
+
describe('getElement', () => {
it('gets an element at the root', () => {
const content = button({ value: 'foo', name: 'bar' });
diff --git a/packages/snaps-jest/src/internals/simulation/interface.ts b/packages/snaps-jest/src/internals/simulation/interface.ts
index 2fd7850275..e3d529391f 100644
--- a/packages/snaps-jest/src/internals/simulation/interface.ts
+++ b/packages/snaps-jest/src/internals/simulation/interface.ts
@@ -9,7 +9,8 @@ import type {
File,
} from '@metamask/snaps-sdk';
import { DialogType, UserInputEventType, assert } from '@metamask/snaps-sdk';
-import type { FormElement, JSXElement } from '@metamask/snaps-sdk/jsx';
+import type { FooterElement } from '@metamask/snaps-sdk/jsx';
+import { type FormElement, type JSXElement } from '@metamask/snaps-sdk/jsx';
import {
HandlerType,
getJsxChildren,
@@ -47,7 +48,7 @@ const MAX_FILE_SIZE = 10_000_000; // 10 MB
*/
export function getInterfaceResponse(
runSaga: RunSagaFunction,
- type: DialogApprovalTypes[DialogType],
+ type: DialogApprovalTypes[DialogType | 'default'],
content: JSXElement,
interfaceActions: SnapInterfaceActions,
): SnapInterface {
@@ -80,11 +81,52 @@ export function getInterfaceResponse(
cancel: resolveWith(runSaga, null),
};
+ case DIALOG_APPROVAL_TYPES.default: {
+ const footer = getElementByType(content, 'Footer');
+
+ // No Footer defined so we apply a default footer.
+ if (!footer) {
+ return {
+ ...interfaceActions,
+ content,
+
+ ok: resolveWith(runSaga, null),
+ cancel: resolveWith(runSaga, null),
+ };
+ }
+
+ // Only one button in footer so we apply a default cancel button.
+ if (getJsxChildren(footer).length === 1) {
+ return {
+ ...interfaceActions,
+ content,
+
+ cancel: resolveWith(runSaga, null),
+ };
+ }
+
+ // We have two buttons in the footer so we assume the snap handles the approval of the interface.
+ return {
+ ...interfaceActions,
+ content,
+ };
+ }
+
default:
throw new Error(`Unknown or unsupported dialog type: "${String(type)}".`);
}
}
+/**
+ * Resolve the current user interface with the given value.
+ *
+ * @param value - The value to resolve the user interface with.
+ * @yields Puts the resolve user interface action.
+ */
+export function* resolveWithSaga(value: unknown): SagaIterator {
+ yield put(resolveInterface(value));
+}
+
/**
* Resolve the current user interface with the given value. This returns a
* function that can be used to resolve the user interface.
@@ -94,17 +136,8 @@ export function getInterfaceResponse(
* @returns A function that can be used to resolve the user interface.
*/
function resolveWith(runSaga: RunSagaFunction, value: unknown) {
- /**
- * Resolve the current user interface with the given value.
- *
- * @yields Puts the resolve user interface action.
- */
- function* resolveWithSaga(): SagaIterator {
- yield put(resolveInterface(value));
- }
-
return async () => {
- await runSaga(resolveWithSaga).toPromise();
+ await runSaga(resolveWithSaga, value).toPromise();
};
}
@@ -116,16 +149,6 @@ function resolveWith(runSaga: RunSagaFunction, value: unknown) {
* @returns A function that can be used to resolve the user interface.
*/
function resolveWithInput(runSaga: RunSagaFunction) {
- /**
- * Resolve the current user interface with the given value.
- *
- * @param value - The value to resolve the user interface with.
- * @yields Puts the resolve user interface action.
- */
- function* resolveWithSaga(value: string): SagaIterator {
- yield put(resolveInterface(value));
- }
-
return async (value = '') => {
await runSaga(resolveWithSaga, value).toPromise();
};
@@ -242,6 +265,27 @@ export function getElement(
return undefined;
});
}
+
+/**
+ * Get an element from a JSX tree with the given type.
+ *
+ * @param content - The interface content.
+ * @param type - The element type.
+ * @returns The element with the given type.
+ */
+export function getElementByType(
+ content: JSXElement,
+ type: string,
+) {
+ return walkJsx(content, (element) => {
+ if (element.type === type) {
+ return element as Element;
+ }
+
+ return undefined;
+ });
+}
+
/**
* Handle submitting event requests to OnUserInput including unwrapping potential errors.
*
@@ -711,7 +755,7 @@ export function* getInterface(
runSaga: RunSagaFunction,
snapId: SnapId,
controllerMessenger: RootControllerMessenger,
-): SagaIterator {
+): SagaIterator {
const storedInterface = yield call(
getStoredInterface,
controllerMessenger,
diff --git a/packages/snaps-jest/src/internals/simulation/simulation.test.ts b/packages/snaps-jest/src/internals/simulation/simulation.test.ts
index 2e9d1968dc..5482742032 100644
--- a/packages/snaps-jest/src/internals/simulation/simulation.test.ts
+++ b/packages/snaps-jest/src/internals/simulation/simulation.test.ts
@@ -5,6 +5,7 @@ import {
NodeThreadExecutionService,
SnapInterfaceController,
} from '@metamask/snaps-controllers/node';
+import { DIALOG_APPROVAL_TYPES } from '@metamask/snaps-rpc-methods';
import { AuxiliaryFileEncoding, text } from '@metamask/snaps-sdk';
import { VirtualFile } from '@metamask/snaps-utils';
import { getSnapManifest } from '@metamask/snaps-utils/test-utils';
@@ -17,6 +18,7 @@ import {
} from '../../test-utils';
import { DEFAULT_SRP } from './constants';
import { getHooks, handleInstallSnap, registerActions } from './simulation';
+import { createStore, setInterface } from './store';
describe('handleInstallSnap', () => {
it('installs a Snap and returns the execution service', async () => {
@@ -203,6 +205,57 @@ describe('getHooks', () => {
await close();
});
+ it('returns the `resolveInterface` hook', async () => {
+ // eslint-disable-next-line no-new
+ const snapInterfaceController = new SnapInterfaceController({
+ messenger:
+ getRestrictedSnapInterfaceControllerMessenger(controllerMessenger),
+ });
+
+ jest.spyOn(controllerMessenger, 'call');
+
+ const { snapId, close } = await getMockServer({
+ manifest: getSnapManifest(),
+ });
+ const id = await snapInterfaceController.createInterface(
+ snapId,
+ text('foo'),
+ );
+
+ const location = detectSnapLocation(snapId, {
+ allowLocal: true,
+ });
+ const snapFiles = await fetchSnap(snapId, location);
+
+ const { resolveInterface } = getHooks(
+ getMockOptions({
+ state: {
+ ui: {
+ current: {
+ id,
+ type: DIALOG_APPROVAL_TYPES.default,
+ },
+ },
+ },
+ }),
+ snapFiles,
+ snapId,
+ controllerMessenger,
+ );
+
+ await resolveInterface(id, 'foobar');
+
+ expect(controllerMessenger.call).toHaveBeenNthCalledWith(
+ 2,
+ 'SnapInterfaceController:resolveInterface',
+ snapId,
+ id,
+ 'foobar',
+ );
+
+ await close();
+ });
+
it('returns the `getIsLocked` hook', async () => {
const { snapId, close } = await getMockServer();
@@ -224,12 +277,42 @@ describe('getHooks', () => {
});
describe('registerActions', () => {
+ const { runSaga, store } = createStore(getMockOptions());
const controllerMessenger = getRootControllerMessenger(false);
+
it('registers `PhishingController:testOrigin`', async () => {
- registerActions(controllerMessenger);
+ registerActions(controllerMessenger, runSaga);
expect(
controllerMessenger.call('PhishingController:testOrigin', 'foo'),
).toStrictEqual({ result: false, type: 'all' });
});
+
+ it('registers `ApprovalController:hasRequest`', async () => {
+ registerActions(controllerMessenger, runSaga);
+
+ store.dispatch(
+ setInterface({ type: DIALOG_APPROVAL_TYPES.default, id: 'foo' }),
+ );
+
+ expect(
+ controllerMessenger.call('ApprovalController:hasRequest', { id: 'foo' }),
+ ).toBe(true);
+ });
+
+ it('registers `ApprovalController:acceptRequest`', async () => {
+ registerActions(controllerMessenger, runSaga);
+
+ store.dispatch(
+ setInterface({ type: DIALOG_APPROVAL_TYPES.default, id: 'foo' }),
+ );
+
+ expect(
+ await controllerMessenger.call(
+ 'ApprovalController:acceptRequest',
+ 'foo',
+ 'bar',
+ ),
+ ).toStrictEqual({ value: 'bar' });
+ });
});
diff --git a/packages/snaps-jest/src/internals/simulation/simulation.ts b/packages/snaps-jest/src/internals/simulation/simulation.ts
index 93cbce907c..beafcdaf82 100644
--- a/packages/snaps-jest/src/internals/simulation/simulation.ts
+++ b/packages/snaps-jest/src/internals/simulation/simulation.ts
@@ -12,6 +12,7 @@ import {
NodeThreadExecutionService,
setupMultiplex,
} from '@metamask/snaps-controllers/node';
+import { DIALOG_APPROVAL_TYPES } from '@metamask/snaps-rpc-methods';
import type {
SnapId,
AuxiliaryFileEncoding,
@@ -20,18 +21,22 @@ import type {
} from '@metamask/snaps-sdk';
import type { FetchedSnapFiles } from '@metamask/snaps-utils';
import { logError } from '@metamask/snaps-utils';
+import type { Json } from '@metamask/utils';
import type { Duplex } from 'readable-stream';
import { pipeline } from 'readable-stream';
+import type { SagaIterator } from 'redux-saga';
+import { select } from 'redux-saga/effects';
import type { RootControllerMessenger } from './controllers';
import { getControllers, registerSnap } from './controllers';
import { getSnapFile } from './files';
+import { resolveWithSaga } from './interface';
import { getEndowments } from './methods';
import { createJsonRpcEngine } from './middleware';
import type { SimulationOptions, SimulationUserOptions } from './options';
import { getOptions } from './options';
-import type { RunSagaFunction, Store } from './store';
-import { createStore } from './store';
+import type { Interface, RunSagaFunction, Store } from './store';
+import { createStore, getCurrentInterface } from './store';
/**
* Options for the execution service, without the options that are shared
@@ -110,6 +115,7 @@ export type MiddlewareHooks = {
createInterface: (content: Component) => Promise;
updateInterface: (id: string, content: Component) => Promise;
getInterfaceState: (id: string) => InterfaceState;
+ resolveInterface: (id: string, value: Json) => Promise;
};
/**
@@ -152,7 +158,7 @@ export async function handleInstallSnap<
const controllerMessenger = new ControllerMessenger();
- registerActions(controllerMessenger);
+ registerActions(controllerMessenger, runSaga);
// Set up controllers and JSON-RPC stack.
const hooks = getHooks(options, snapFiles, snapId, controllerMessenger);
@@ -258,6 +264,12 @@ export function getHooks(
snapId,
...args,
).state,
+ resolveInterface: async (...args) =>
+ controllerMessenger.call(
+ 'SnapInterfaceController:resolveInterface',
+ snapId,
+ ...args,
+ ),
};
}
@@ -265,8 +277,12 @@ export function getHooks(
* Register mocked action handlers.
*
* @param controllerMessenger - The controller messenger.
+ * @param runSaga - The run saga function.
*/
-export function registerActions(controllerMessenger: RootControllerMessenger) {
+export function registerActions(
+ controllerMessenger: RootControllerMessenger,
+ runSaga: RunSagaFunction,
+) {
controllerMessenger.registerActionHandler(
'PhishingController:maybeUpdateState',
async () => Promise.resolve(),
@@ -276,4 +292,37 @@ export function registerActions(controllerMessenger: RootControllerMessenger) {
'PhishingController:testOrigin',
() => ({ result: false, type: 'all' }),
);
+
+ controllerMessenger.registerActionHandler(
+ 'ApprovalController:hasRequest',
+ (opts) => {
+ /**
+ * Get the current interface from the store.
+ *
+ * @yields Selects the current interface from the store.
+ * @returns The current interface.
+ */
+ function* getCurrentInterfaceSaga(): SagaIterator {
+ const currentInterface: Interface = yield select(getCurrentInterface);
+ return currentInterface;
+ }
+
+ const currentInterface: Interface | undefined = runSaga(
+ getCurrentInterfaceSaga,
+ ).result();
+ return (
+ currentInterface?.type === DIALOG_APPROVAL_TYPES.default &&
+ currentInterface?.id === opts?.id
+ );
+ },
+ );
+
+ controllerMessenger.registerActionHandler(
+ 'ApprovalController:acceptRequest',
+ async (_id: string, value: unknown) => {
+ await runSaga(resolveWithSaga, value).toPromise();
+
+ return { value };
+ },
+ );
}
diff --git a/packages/snaps-jest/src/internals/simulation/store/ui.ts b/packages/snaps-jest/src/internals/simulation/store/ui.ts
index 9c12a4ec13..e1f4d85829 100644
--- a/packages/snaps-jest/src/internals/simulation/store/ui.ts
+++ b/packages/snaps-jest/src/internals/simulation/store/ui.ts
@@ -6,7 +6,7 @@ import { createAction, createSelector, createSlice } from '@reduxjs/toolkit';
import type { ApplicationState } from './store';
export type Interface = {
- type: DialogApprovalTypes[DialogType];
+ type: DialogApprovalTypes[DialogType | 'default'];
id: string;
};
diff --git a/packages/snaps-jest/src/test-utils/controller.ts b/packages/snaps-jest/src/test-utils/controller.ts
index 8fe8d0fbe8..7dc0f08e4e 100644
--- a/packages/snaps-jest/src/test-utils/controller.ts
+++ b/packages/snaps-jest/src/test-utils/controller.ts
@@ -24,6 +24,16 @@ export const getRootControllerMessenger = (mocked = true) => {
'ExecutionService:handleRpcRequest',
jest.fn(),
);
+
+ messenger.registerActionHandler(
+ 'ApprovalController:hasRequest',
+ () => true,
+ );
+
+ messenger.registerActionHandler(
+ 'ApprovalController:acceptRequest',
+ async (_id: string, value: unknown) => ({ value }),
+ );
}
return messenger;
@@ -43,6 +53,8 @@ export const getRestrictedSnapInterfaceControllerMessenger = (
allowedActions: [
'PhishingController:testOrigin',
'PhishingController:maybeUpdateState',
+ 'ApprovalController:hasRequest',
+ 'ApprovalController:acceptRequest',
],
allowedEvents: [],
});
diff --git a/packages/snaps-jest/src/types.ts b/packages/snaps-jest/src/types.ts
index 5cd9be62fd..81ba68520e 100644
--- a/packages/snaps-jest/src/types.ts
+++ b/packages/snaps-jest/src/types.ts
@@ -216,10 +216,52 @@ export type SnapPromptInterface = {
cancel(): Promise;
};
+/**
+ * A `snap_dialog` default interface that has a Footer with two buttons defined.
+ * The approval of this confirmation is handled by the snap.
+ */
+export type DefaultSnapInterfaceWithFooter = {
+ /**
+ * The content to show in the interface.
+ */
+ content: JSXElement;
+};
+
+/**
+ * A `snap_dialog` default interface that has a Footer with one button defined.
+ * A cancel button is automatically applied to the interface in this case.
+ */
+export type DefaultSnapInterfaceWithPartialFooter =
+ DefaultSnapInterfaceWithFooter & {
+ /**
+ * Cancel the dialog.
+ */
+ cancel(): Promise;
+ };
+
+/**
+ * A `snap_dialog` default interface that has no Footer defined.
+ * A cancel and ok button is automatically applied to the interface in this case.
+ */
+export type DefaultSnapInterfaceWithoutFooter =
+ DefaultSnapInterfaceWithPartialFooter & {
+ /**
+ * Close the dialog.
+ *
+ */
+ ok(): Promise;
+ };
+
+export type DefaultSnapInterface =
+ | DefaultSnapInterfaceWithFooter
+ | DefaultSnapInterfaceWithPartialFooter
+ | DefaultSnapInterfaceWithoutFooter;
+
export type SnapInterface = (
| SnapAlertInterface
| SnapConfirmationInterface
| SnapPromptInterface
+ | DefaultSnapInterface
) &
SnapInterfaceActions;
diff --git a/packages/snaps-sdk/src/jsx/validation.ts b/packages/snaps-sdk/src/jsx/validation.ts
index befdbd5508..afa480d954 100644
--- a/packages/snaps-sdk/src/jsx/validation.ts
+++ b/packages/snaps-sdk/src/jsx/validation.ts
@@ -494,7 +494,10 @@ export const BoxChildStruct = nullUnion([
* For now, the allowed JSX elements at the root are the same as the allowed
* children of the Box component.
*/
-export const RootJSXElementStruct = BoxChildStruct;
+export const RootJSXElementStruct = nullUnion([
+ BoxChildStruct,
+ ContainerStruct,
+]);
/**
* A struct for the {@link JSXElement} type.
diff --git a/yarn.lock b/yarn.lock
index af52e093a5..2c52d7f368 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4291,7 +4291,6 @@ __metadata:
"@metamask/snaps-cli": "workspace:^"
"@metamask/snaps-jest": "workspace:^"
"@metamask/snaps-sdk": "workspace:^"
- "@metamask/utils": ^8.3.0
"@swc/core": 1.3.78
"@swc/jest": ^0.2.26
"@typescript-eslint/eslint-plugin": ^5.42.1
@@ -4526,7 +4525,6 @@ __metadata:
"@metamask/snaps-cli": "workspace:^"
"@metamask/snaps-jest": "workspace:^"
"@metamask/snaps-sdk": "workspace:^"
- "@metamask/utils": ^8.3.0
"@swc/core": 1.3.78
"@swc/jest": ^0.2.26
"@typescript-eslint/eslint-plugin": ^5.42.1