From 0267d91399518fe81c8c26ddc224a5f5420b68e3 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 25 Oct 2024 12:46:40 +0200 Subject: [PATCH 01/11] Add device API interfaces --- packages/snaps-sdk/src/types/device.ts | 57 +++++++++++++++++++ packages/snaps-sdk/src/types/index.ts | 1 + .../src/types/methods/list-devices.ts | 12 ++++ .../snaps-sdk/src/types/methods/methods.ts | 11 ++++ .../src/types/methods/read-device.ts | 41 +++++++++++++ .../src/types/methods/request-device.ts | 34 +++++++++++ .../src/types/methods/write-device.ts | 40 +++++++++++++ 7 files changed, 196 insertions(+) create mode 100644 packages/snaps-sdk/src/types/device.ts create mode 100644 packages/snaps-sdk/src/types/methods/list-devices.ts create mode 100644 packages/snaps-sdk/src/types/methods/read-device.ts create mode 100644 packages/snaps-sdk/src/types/methods/request-device.ts create mode 100644 packages/snaps-sdk/src/types/methods/write-device.ts diff --git a/packages/snaps-sdk/src/types/device.ts b/packages/snaps-sdk/src/types/device.ts new file mode 100644 index 0000000000..00a1625c5f --- /dev/null +++ b/packages/snaps-sdk/src/types/device.ts @@ -0,0 +1,57 @@ +/** + * The type of the device. + */ +export type DeviceType = 'hid' | 'bluetooth'; + +/** + * The ID of the device. It consists of the type of the device, the vendor ID, + * and the product ID. + */ +export type DeviceId = `${DeviceType}:${string}:${string}`; + +/** + * The ID of the device that is scoped to the type of the device. + * + * @example + * type HidDeviceId = ScopedDeviceId<'hid'>; + * // => `hid:${string}:${string}` + */ +export type ScopedDeviceId = + `${Type}:${string}:${string}` extends DeviceId + ? `${Type}:${string}:${string}` + : never; + +/** + * A device that is available to the Snap. + */ +export type Device = { + /** + * The ID of the device. + */ + id: DeviceId; + + /** + * The type of the device. + */ + type: DeviceType; + + /** + * The name of the device. + */ + name: string; + + /** + * The vendor ID of the device. + */ + vendorId: string; + + /** + * The product ID of the device. + */ + productId: string; + + /** + * Whether the device is available. + */ + available: boolean; +}; diff --git a/packages/snaps-sdk/src/types/index.ts b/packages/snaps-sdk/src/types/index.ts index fafc24fe65..d0bafb284a 100644 --- a/packages/snaps-sdk/src/types/index.ts +++ b/packages/snaps-sdk/src/types/index.ts @@ -5,6 +5,7 @@ import './images'; /* eslint-enable import/no-unassigned-import */ export * from './caip'; +export * from './device'; export * from './handlers'; export * from './methods'; export * from './permissions'; diff --git a/packages/snaps-sdk/src/types/methods/list-devices.ts b/packages/snaps-sdk/src/types/methods/list-devices.ts new file mode 100644 index 0000000000..25a25e698f --- /dev/null +++ b/packages/snaps-sdk/src/types/methods/list-devices.ts @@ -0,0 +1,12 @@ +import type { Device } from '../device'; + +/** + * The request parameters for the `snap_listDevices` method. + */ +export type ListDevicesParams = never; + +/** + * The result returned by the `snap_readDevice` method. This is a list of + * devices that are available to the Snap. + */ +export type ListDevicesResult = Device[]; diff --git a/packages/snaps-sdk/src/types/methods/methods.ts b/packages/snaps-sdk/src/types/methods/methods.ts index 4d9e1f33c6..b46d0be15e 100644 --- a/packages/snaps-sdk/src/types/methods/methods.ts +++ b/packages/snaps-sdk/src/types/methods/methods.ts @@ -37,12 +37,18 @@ import type { InvokeKeyringResult, } from './invoke-keyring'; import type { InvokeSnapParams, InvokeSnapResult } from './invoke-snap'; +import type { ListDevicesParams, ListDevicesResult } from './list-devices'; import type { ManageAccountsParams, ManageAccountsResult, } from './manage-accounts'; import type { ManageStateParams, ManageStateResult } from './manage-state'; import type { NotifyParams, NotifyResult } from './notify'; +import type { ReadDeviceParams, ReadDeviceResult } from './read-device'; +import type { + RequestDeviceParams, + RequestDeviceResult, +} from './request-device'; import type { RequestSnapsParams, RequestSnapsResult } from './request-snaps'; import type { ResolveInterfaceParams, @@ -52,6 +58,7 @@ import type { UpdateInterfaceParams, UpdateInterfaceResult, } from './update-interface'; +import type { WriteDeviceParams, WriteDeviceResult } from './write-device'; /** * The methods that are available to the Snap. Each method is a tuple of the @@ -68,9 +75,13 @@ export type SnapMethods = { snap_getFile: [GetFileParams, GetFileResult]; snap_getLocale: [GetLocaleParams, GetLocaleResult]; snap_getPreferences: [GetPreferencesParams, GetPreferencesResult]; + snap_listDevices: [ListDevicesParams, ListDevicesResult]; snap_manageAccounts: [ManageAccountsParams, ManageAccountsResult]; snap_manageState: [ManageStateParams, ManageStateResult]; snap_notify: [NotifyParams, NotifyResult]; + snap_readDevice: [ReadDeviceParams, ReadDeviceResult]; + snap_requestDevice: [RequestDeviceParams, RequestDeviceResult]; + snap_writeDevice: [WriteDeviceParams, WriteDeviceResult]; snap_createInterface: [CreateInterfaceParams, CreateInterfaceResult]; snap_updateInterface: [UpdateInterfaceParams, UpdateInterfaceResult]; snap_getInterfaceState: [GetInterfaceStateParams, GetInterfaceStateResult]; diff --git a/packages/snaps-sdk/src/types/methods/read-device.ts b/packages/snaps-sdk/src/types/methods/read-device.ts new file mode 100644 index 0000000000..34ab447813 --- /dev/null +++ b/packages/snaps-sdk/src/types/methods/read-device.ts @@ -0,0 +1,41 @@ +import type { Hex } from '@metamask/utils'; + +import type { ScopedDeviceId } from '../device'; + +/** + * The request parameters for the `snap_readDevice` method reading from an HID + * device. + */ +type HidReadParams = { + /** + * The type of the device. + */ + type: 'hid'; + + /** + * The ID of the device to read from. + */ + id: ScopedDeviceId<'hid'>; + + /** + * The type of the data to read. This is either an output report or a feature + * report. It defaults to `output` if not provided. + */ + reportType?: 'output' | 'feature'; + + /** + * The report ID to read from. This is only required for devices that use + * report IDs, and defaults to `0` if not provided. + */ + reportId?: number; +}; + +/** + * The request parameters for the `snap_readDevice` method. + */ +export type ReadDeviceParams = HidReadParams; + +/** + * The result returned by the `snap_readDevice` method. + */ +export type ReadDeviceResult = Hex; diff --git a/packages/snaps-sdk/src/types/methods/request-device.ts b/packages/snaps-sdk/src/types/methods/request-device.ts new file mode 100644 index 0000000000..4166752e1a --- /dev/null +++ b/packages/snaps-sdk/src/types/methods/request-device.ts @@ -0,0 +1,34 @@ +import type { Device, DeviceType } from '../device'; + +type DeviceFilter = { + /** + * The type of the device. + */ + vendorId?: string; + + /** + * The product ID of the device. + */ + productId?: string; +}; + +/** + * The request parameters for the `snap_requestDevice` method. + */ +export type RequestDeviceParams = { + /** + * The type of the device to request. + */ + type: DeviceType; + + /** + * The filter to apply to the devices. + */ + filter?: DeviceFilter; +}; + +/** + * The result returned by the `snap_requestDevice` method. This can be a single + * device, or `null` if no device was provided. + */ +export type RequestDeviceResult = Device | null; diff --git a/packages/snaps-sdk/src/types/methods/write-device.ts b/packages/snaps-sdk/src/types/methods/write-device.ts new file mode 100644 index 0000000000..f9303a64ef --- /dev/null +++ b/packages/snaps-sdk/src/types/methods/write-device.ts @@ -0,0 +1,40 @@ +import type { Hex } from '@metamask/utils'; + +import type { ScopedDeviceId } from '../device'; + +/** + * The request parameters for the `snap_writeDevice` method when writing to a + * HID device. + */ +type HidWriteParams = { + /** + * The type of the device. + */ + type: 'hid'; + + /** + * The ID of the device to write to. + */ + id: ScopedDeviceId<'hid'>; + + /** + * The data to write to the device. + */ + data: Hex; + + /** + * The report ID to write to. This is only required for devices that use + * report IDs, and defaults to `0` if not provided. + */ + reportId?: number; +}; + +/** + * The request parameters for the `snap_writeDevice` method. + */ +export type WriteDeviceParams = HidWriteParams; + +/** + * The result returned by the `snap_writeDevice` method. + */ +export type WriteDeviceResult = void; From fc1201487253de033ba0c9ca977711588290ff99 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 25 Oct 2024 12:55:37 +0200 Subject: [PATCH 02/11] Allow multiple device filters --- packages/snaps-sdk/src/types/methods/request-device.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/snaps-sdk/src/types/methods/request-device.ts b/packages/snaps-sdk/src/types/methods/request-device.ts index 4166752e1a..08ab180cb0 100644 --- a/packages/snaps-sdk/src/types/methods/request-device.ts +++ b/packages/snaps-sdk/src/types/methods/request-device.ts @@ -24,7 +24,7 @@ export type RequestDeviceParams = { /** * The filter to apply to the devices. */ - filter?: DeviceFilter; + filter?: DeviceFilter[]; }; /** From eed921d9072e0697f75197dbf92dcb13ea5acdae Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 25 Oct 2024 15:20:22 +0200 Subject: [PATCH 03/11] Add Ledger example transport --- .../examples/packages/ledger/.depcheckrc.json | 18 ++ .../examples/packages/ledger/.eslintrc.js | 7 + .../examples/packages/ledger/CHANGELOG.md | 9 + .../examples/packages/ledger/LICENSE.APACHE2 | 201 +++++++++++++++ .../examples/packages/ledger/LICENSE.MIT0 | 16 ++ packages/examples/packages/ledger/README.md | 30 +++ .../examples/packages/ledger/jest.config.js | 36 +++ .../examples/packages/ledger/package.json | 90 +++++++ .../examples/packages/ledger/snap.config.ts | 13 + .../packages/ledger/snap.manifest.json | 24 ++ .../packages/ledger/src/index.test.ts | 13 + .../examples/packages/ledger/src/index.ts | 17 ++ .../examples/packages/ledger/src/transport.ts | 240 ++++++++++++++++++ .../examples/packages/ledger/tsconfig.json | 21 ++ packages/snaps-sdk/src/types/device.ts | 11 +- .../types/methods/get-supported-devices.ts | 11 + packages/snaps-sdk/src/types/methods/index.ts | 4 + .../src/types/methods/list-devices.ts | 9 +- .../snaps-sdk/src/types/methods/methods.ts | 8 + .../src/types/methods/request-device.ts | 10 +- yarn.lock | 96 +++++++ 21 files changed, 875 insertions(+), 9 deletions(-) create mode 100644 packages/examples/packages/ledger/.depcheckrc.json create mode 100644 packages/examples/packages/ledger/.eslintrc.js create mode 100644 packages/examples/packages/ledger/CHANGELOG.md create mode 100644 packages/examples/packages/ledger/LICENSE.APACHE2 create mode 100644 packages/examples/packages/ledger/LICENSE.MIT0 create mode 100644 packages/examples/packages/ledger/README.md create mode 100644 packages/examples/packages/ledger/jest.config.js create mode 100644 packages/examples/packages/ledger/package.json create mode 100644 packages/examples/packages/ledger/snap.config.ts create mode 100644 packages/examples/packages/ledger/snap.manifest.json create mode 100644 packages/examples/packages/ledger/src/index.test.ts create mode 100644 packages/examples/packages/ledger/src/index.ts create mode 100644 packages/examples/packages/ledger/src/transport.ts create mode 100644 packages/examples/packages/ledger/tsconfig.json create mode 100644 packages/snaps-sdk/src/types/methods/get-supported-devices.ts diff --git a/packages/examples/packages/ledger/.depcheckrc.json b/packages/examples/packages/ledger/.depcheckrc.json new file mode 100644 index 0000000000..c437c59cd2 --- /dev/null +++ b/packages/examples/packages/ledger/.depcheckrc.json @@ -0,0 +1,18 @@ +{ + "ignore-patterns": ["dist", "coverage"], + "ignores": [ + "@lavamoat/allow-scripts", + "@lavamoat/preinstall-always-fail", + "@metamask/auto-changelog", + "@metamask/eslint-*", + "@types/*", + "@typescript-eslint/*", + "eslint-config-*", + "eslint-plugin-*", + "jest-silent-reporter", + "prettier-plugin-packagejson", + "ts-node", + "typedoc", + "typescript" + ] +} diff --git a/packages/examples/packages/ledger/.eslintrc.js b/packages/examples/packages/ledger/.eslintrc.js new file mode 100644 index 0000000000..a47fd0b65d --- /dev/null +++ b/packages/examples/packages/ledger/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['../../.eslintrc.js'], + + parserOptions: { + tsconfigRootDir: __dirname, + }, +}; diff --git a/packages/examples/packages/ledger/CHANGELOG.md b/packages/examples/packages/ledger/CHANGELOG.md new file mode 100644 index 0000000000..aa399df1be --- /dev/null +++ b/packages/examples/packages/ledger/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +[Unreleased]: https://github.com/MetaMask/snaps/ diff --git a/packages/examples/packages/ledger/LICENSE.APACHE2 b/packages/examples/packages/ledger/LICENSE.APACHE2 new file mode 100644 index 0000000000..5fb887469b --- /dev/null +++ b/packages/examples/packages/ledger/LICENSE.APACHE2 @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2024 ConsenSys Software Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/examples/packages/ledger/LICENSE.MIT0 b/packages/examples/packages/ledger/LICENSE.MIT0 new file mode 100644 index 0000000000..1a8536859a --- /dev/null +++ b/packages/examples/packages/ledger/LICENSE.MIT0 @@ -0,0 +1,16 @@ +MIT No Attribution + +Copyright 2024 ConsenSys Software Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this +software and associated documentation files (the "Software"), to deal in the Software +without restriction, including without limitation the rights to use, copy, modify, +merge, publish, distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/examples/packages/ledger/README.md b/packages/examples/packages/ledger/README.md new file mode 100644 index 0000000000..a740b83472 --- /dev/null +++ b/packages/examples/packages/ledger/README.md @@ -0,0 +1,30 @@ +# `@metamask/lifecylce-hooks-example-snap` + +This snap demonstrates how to use the `onInstall` and `onUpdate` lifecycle +hooks. + +## Snap manifest + +> **Note**: Using lifecycle hooks requires the `endowment:lifecycle-hooks` +> permissions. Refer to [the documentation](https://docs.metamask.io/snaps/reference/permissions/#endowmentlifecycle-hooks) +> for more information. + +Along with other permissions, the manifest of this snap includes the +`endowment:lifecycle-hooks` permission: + +```json +{ + "initialPermissions": { + "endowment:lifecycle-hooks": {} + } +} +``` + +## Snap usage + +This snap exposes the `onInstall` and `onUpdate` lifecycle hooks. These hooks +are called when the snap is installed or updated, respectively, and cannot be +called manually. + +For more information, you can refer to +[the end-to-end tests](./src/index.test.ts). diff --git a/packages/examples/packages/ledger/jest.config.js b/packages/examples/packages/ledger/jest.config.js new file mode 100644 index 0000000000..f473a91b83 --- /dev/null +++ b/packages/examples/packages/ledger/jest.config.js @@ -0,0 +1,36 @@ +const deepmerge = require('deepmerge'); + +const baseConfig = require('../../../../jest.config.base'); + +module.exports = deepmerge(baseConfig, { + preset: '@metamask/snaps-jest', + + // Since `@metamask/snaps-jest` runs in the browser, we can't collect + // coverage information. + collectCoverage: false, + + // This is required for the tests to run inside the `MetaMask/snaps` + // repository. You don't need this in your own project. + moduleNameMapper: { + '^@metamask/(.+)/production/jsx-runtime': [ + '/../../../$1/src/jsx/production/jsx-runtime', + '/../../../../node_modules/@metamask/$1/jsx/production/jsx-runtime', + '/node_modules/@metamask/$1/jsx/production/jsx-runtime', + ], + '^@metamask/(.+)/jsx': [ + '/../../../$1/src/jsx', + '/../../../../node_modules/@metamask/$1/jsx', + '/node_modules/@metamask/$1/jsx', + ], + '^@metamask/(.+)/node$': [ + '/../../../$1/src/node', + '/../../../../node_modules/@metamask/$1/node', + '/node_modules/@metamask/$1/node', + ], + '^@metamask/(.+)$': [ + '/../../../$1/src', + '/../../../../node_modules/@metamask/$1', + '/node_modules/@metamask/$1', + ], + }, +}); diff --git a/packages/examples/packages/ledger/package.json b/packages/examples/packages/ledger/package.json new file mode 100644 index 0000000000..d3da711a3a --- /dev/null +++ b/packages/examples/packages/ledger/package.json @@ -0,0 +1,90 @@ +{ + "name": "@metamask/ledger-example-snap", + "version": "2.1.3", + "description": "MetaMask example Snap demonstrating how to communicate with a Ledger hardware wallet", + "keywords": [ + "MetaMask", + "Snaps", + "Ethereum" + ], + "homepage": "https://github.com/MetaMask/snaps/tree/main/packages/examples/packages/ledger#readme", + "bugs": { + "url": "https://github.com/MetaMask/snaps/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/snaps.git" + }, + "license": "(MIT-0 OR Apache-2.0)", + "sideEffects": false, + "main": "./dist/bundle.js", + "files": [ + "dist", + "snap.manifest.json" + ], + "scripts": { + "build": "mm-snap build", + "build:clean": "yarn clean && yarn build", + "changelog:update": "../../../../scripts/update-changelog.sh @metamask/ledger-example-snap", + "changelog:validate": "../../../../scripts/validate-changelog.sh @metamask/ledger-example-snap", + "clean": "rimraf \"dist\"", + "lint": "yarn lint:eslint && yarn lint:misc --check && yarn changelog:validate && yarn lint:dependencies", + "lint:ci": "yarn lint", + "lint:dependencies": "depcheck", + "lint:eslint": "eslint . --cache --ext js,ts,jsx,tsx", + "lint:fix": "yarn lint:eslint --fix && yarn lint:misc --write", + "lint:misc": "prettier --no-error-on-unmatched-pattern --loglevel warn \"**/*.json\" \"**/*.md\" \"**/*.html\" \"!CHANGELOG.md\" \"!snap.manifest.json\" --ignore-path ../../../../.gitignore", + "publish:preview": "yarn npm publish --tag preview", + "since-latest-release": "../../../../scripts/since-latest-release.sh", + "start": "mm-snap watch", + "test": "jest --reporters=jest-silent-reporter", + "test:clean": "jest --clearCache", + "test:verbose": "jest --verbose", + "test:watch": "jest --watch" + }, + "dependencies": { + "@ledgerhq/devices": "^8.4.4", + "@ledgerhq/errors": "^6.19.1", + "@ledgerhq/hw-transport": "^6.31.4", + "@metamask/snaps-sdk": "workspace:^", + "@metamask/utils": "^10.0.0" + }, + "devDependencies": { + "@jest/globals": "^29.5.0", + "@lavamoat/allow-scripts": "^3.0.4", + "@metamask/auto-changelog": "^3.4.4", + "@metamask/eslint-config": "^12.1.0", + "@metamask/eslint-config-jest": "^12.1.0", + "@metamask/eslint-config-nodejs": "^12.1.0", + "@metamask/eslint-config-typescript": "^12.1.0", + "@metamask/snaps-cli": "workspace:^", + "@metamask/snaps-jest": "workspace:^", + "@swc/core": "1.3.78", + "@swc/jest": "^0.2.26", + "@typescript-eslint/eslint-plugin": "^5.42.1", + "@typescript-eslint/parser": "^6.21.0", + "deepmerge": "^4.2.2", + "depcheck": "^1.4.7", + "eslint": "^8.27.0", + "eslint-config-prettier": "^8.5.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-jest": "^27.1.5", + "eslint-plugin-jsdoc": "^41.1.2", + "eslint-plugin-n": "^15.7.0", + "eslint-plugin-prettier": "^4.2.1", + "eslint-plugin-promise": "^6.1.1", + "jest": "^29.0.2", + "jest-silent-reporter": "^0.6.0", + "prettier": "^2.8.8", + "prettier-plugin-packagejson": "^2.5.2", + "ts-node": "^10.9.1", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.16 || >=20" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/examples/packages/ledger/snap.config.ts b/packages/examples/packages/ledger/snap.config.ts new file mode 100644 index 0000000000..1be4ccfa2b --- /dev/null +++ b/packages/examples/packages/ledger/snap.config.ts @@ -0,0 +1,13 @@ +import type { SnapConfig } from '@metamask/snaps-cli'; + +const config: SnapConfig = { + input: './src/index.ts', + server: { + port: 8032, + }, + stats: { + buffer: false, + }, +}; + +export default config; diff --git a/packages/examples/packages/ledger/snap.manifest.json b/packages/examples/packages/ledger/snap.manifest.json new file mode 100644 index 0000000000..023c79aacb --- /dev/null +++ b/packages/examples/packages/ledger/snap.manifest.json @@ -0,0 +1,24 @@ +{ + "version": "2.1.3", + "description": "MetaMask example snap demonstrating the use of the `onInstall` and `onUpdate` lifecycle hooks.", + "proposedName": "Lifecycle Hooks Example Snap", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/snaps.git" + }, + "source": { + "shasum": "TSu0FIqVXvJG6WzqtKPx5kN2fjveQ8EypKCk/jAShmM=", + "location": { + "npm": { + "filePath": "dist/bundle.js", + "packageName": "@metamask/lifecycle-hooks-example-snap", + "registry": "https://registry.npmjs.org" + } + } + }, + "initialPermissions": { + "snap_dialog": {}, + "endowment:lifecycle-hooks": {} + }, + "manifestVersion": "0.1" +} diff --git a/packages/examples/packages/ledger/src/index.test.ts b/packages/examples/packages/ledger/src/index.test.ts new file mode 100644 index 0000000000..90046df641 --- /dev/null +++ b/packages/examples/packages/ledger/src/index.test.ts @@ -0,0 +1,13 @@ +import { describe, it } from '@jest/globals'; + +describe('onInstall', () => { + // TODO: The Snaps testing framework does not currently support testing the + // onInstall handler. + it.todo('shows a dialog when the snap is installed'); +}); + +describe('onUpdate', () => { + // TODO: The Snaps testing framework does not currently support testing the + // onUpdate handler. + it.todo('shows a dialog when the snap is updated'); +}); diff --git a/packages/examples/packages/ledger/src/index.ts b/packages/examples/packages/ledger/src/index.ts new file mode 100644 index 0000000000..3a6254b88e --- /dev/null +++ b/packages/examples/packages/ledger/src/index.ts @@ -0,0 +1,17 @@ +import type { OnRpcRequestHandler } from '@metamask/snaps-sdk'; +import { MethodNotFoundError } from '@metamask/snaps-sdk'; + +import TransportSnapsHID from './transport'; + +export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { + switch (request.method) { + case 'request': + await TransportSnapsHID.request(); + return null; + + default: + throw new MethodNotFoundError({ + method: request.method, + }); + } +}; diff --git a/packages/examples/packages/ledger/src/transport.ts b/packages/examples/packages/ledger/src/transport.ts new file mode 100644 index 0000000000..9c378e03e8 --- /dev/null +++ b/packages/examples/packages/ledger/src/transport.ts @@ -0,0 +1,240 @@ +import type { DeviceModel } from '@ledgerhq/devices'; +import { identifyUSBProductId, ledgerUSBVendorId } from '@ledgerhq/devices'; +import hidFraming from '@ledgerhq/devices/hid-framing'; +import { TransportOpenUserCancelled } from '@ledgerhq/errors'; +import type { + DescriptorEvent, + Observer, + Subscription, +} from '@ledgerhq/hw-transport'; +import Transport from '@ledgerhq/hw-transport'; +import type { HidDevice } from '@metamask/snaps-sdk'; +import { bytesToHex } from '@metamask/utils'; + +/** + * Request a Ledger device using Snaps. + * + * @returns A promise that resolves to a device, or `null` if no device was + * provided. + */ +async function requestDevice() { + return (await snap.request({ + method: 'snap_requestDevice', + params: { type: 'hid', filters: [{ vendorId: ledgerUSBVendorId }] }, + })) as HidDevice; +} + +export default class TransportSnapsHID extends Transport { + readonly device: HidDevice; + + readonly deviceModel: DeviceModel | null | undefined; + + #channel = Math.floor(Math.random() * 0xffff); + + #packetSize = 64; + + constructor(device: HidDevice) { + super(); + + this.device = device; + this.deviceModel = identifyUSBProductId(device.productId); + } + + /** + * Check if the transport is supported by the current environment. + * + * @returns A promise that resolves to `true` if the transport is supported, + * or `false` otherwise. + */ + static async isSupported() { + const types = await snap.request({ + method: 'snap_getSupportedDevices', + }); + + return types.includes('hid'); + } + + /** + * List the HID devices that were previously authorised by the user. + * + * @returns A promise that resolves to an array of devices. + */ + static async list() { + const devices = (await snap.request({ + method: 'snap_listDevices', + params: { type: 'hid' }, + })) as HidDevice[]; + + return devices.filter( + (device) => device.vendorId === ledgerUSBVendorId && device.available, + ); + } + + /** + * Get the first Ledger device that was previously authorised by the user, or + * request a new device if none are available. + * + * @param observer - The observer to notify when a device is found. + * @returns A subscription that can be used to unsubscribe from the observer. + */ + static listen(observer: Observer>): Subscription { + let unsubscribed = false; + + /** + * Unsubscribe from the subscription. + */ + function unsubscribe() { + unsubscribed = true; + } + + /** + * Emit a device to the observer. + * + * @param device - The device to emit. + */ + function emit(device: HidDevice) { + observer.next({ + type: 'add', + descriptor: device, + deviceModel: identifyUSBProductId(device.productId), + }); + + observer.complete(); + } + + this.list() + .then((devices) => { + if (unsubscribed) { + return; + } + + if (devices.length > 0) { + emit(devices[0]); + return; + } + + requestDevice() + .then((device) => { + if (unsubscribed) { + return; + } + + if (!device) { + observer.error( + new TransportOpenUserCancelled( + 'No device was provided to connect to.', + ), + ); + + return; + } + + emit(device); + }) + .catch((error) => { + observer.error(new TransportOpenUserCancelled(error.message)); + }); + }) + .catch((error) => { + observer.error(new TransportOpenUserCancelled(error.message)); + }); + + return { unsubscribe }; + } + + /** + * Request to connect to a Ledger device. This will always prompt the user to + * connect a device. + * + * @returns A promise that resolves to a transport. + */ + static async request() { + const device = await requestDevice(); + if (!device) { + throw new TransportOpenUserCancelled( + 'No device was provided to connect to.', + ); + } + + return this.open(device); + } + + /** + * Create a transport with a previously connected device. Returns `null` if no + * device was found. + * + * @returns A promise that resolves to a transport, or `null` if no device was + * found. + */ + static async openConnected() { + const devices = await this.list(); + if (devices.length > 0) { + return this.open(devices[0]); + } + + return null; + } + + /** + * Create a transport with a specific device. + * + * @param device - The device to connect to. + * @returns A transport. + */ + static async open(device: HidDevice) { + return new TransportSnapsHID(device); + } + + /** + * Close the connection to the transport device. + */ + async close() { + // Snaps devices cannot be closed. + } + + /** + * Exchange with the device using APDU protocol. + * + * @param apdu - The APDU command to send to the device. + * @returns The response from the device. + */ + exchange = async (apdu: Buffer): Promise => { + return await this.exchangeAtomicImpl(async () => { + const framing = hidFraming(this.#channel, this.#packetSize); + const blocks = framing.makeBlocks(apdu); + + for (const block of blocks) { + await snap.request({ + method: 'snap_writeDevice', + params: { + type: 'hid', + id: this.device.id, + data: bytesToHex(block), + }, + }); + } + + let result; + let accumulator = null; + + while (!(result = framing.getReducedResult(accumulator))) { + const bytes = await snap.request({ + method: 'snap_readDevice', + params: { + type: 'hid', + id: this.device.id, + }, + }); + + const buffer = Buffer.from(bytes, 'hex'); + accumulator = framing.reduceResponse(accumulator, buffer); + } + + return result; + }); + }; + + setScrambleKey() { + // This transport does not support setting a scramble key. + } +} diff --git a/packages/examples/packages/ledger/tsconfig.json b/packages/examples/packages/ledger/tsconfig.json new file mode 100644 index 0000000000..1cb4c3315f --- /dev/null +++ b/packages/examples/packages/ledger/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": "./", + "paths": { + "@metamask/*": ["../../../*/src"] + } + }, + "include": ["src", "snap.config.ts"], + "references": [ + { + "path": "../../../snaps-sdk" + }, + { + "path": "../../../snaps-jest" + }, + { + "path": "../../../snaps-cli" + } + ] +} diff --git a/packages/snaps-sdk/src/types/device.ts b/packages/snaps-sdk/src/types/device.ts index 00a1625c5f..f1a4b3b532 100644 --- a/packages/snaps-sdk/src/types/device.ts +++ b/packages/snaps-sdk/src/types/device.ts @@ -43,15 +43,22 @@ export type Device = { /** * The vendor ID of the device. */ - vendorId: string; + vendorId: number; /** * The product ID of the device. */ - productId: string; + productId: number; /** * Whether the device is available. */ available: boolean; }; + +type ScopedDevice = Device & { + type: Type; + id: ScopedDeviceId; +}; + +export type HidDevice = ScopedDevice<'hid'>; diff --git a/packages/snaps-sdk/src/types/methods/get-supported-devices.ts b/packages/snaps-sdk/src/types/methods/get-supported-devices.ts new file mode 100644 index 0000000000..6f33ec6648 --- /dev/null +++ b/packages/snaps-sdk/src/types/methods/get-supported-devices.ts @@ -0,0 +1,11 @@ +import type { DeviceType } from '@metamask/snaps-sdk'; + +/** + * The request parameters for the `snap_getSupportedDevices` method. + */ +export type GetSupportedDevicesParams = never; + +/** + * The result returned by the `snap_getSupportedDevices` method. + */ +export type GetSupportedDevicesResult = DeviceType[]; diff --git a/packages/snaps-sdk/src/types/methods/index.ts b/packages/snaps-sdk/src/types/methods/index.ts index 766910160c..8022e80f35 100644 --- a/packages/snaps-sdk/src/types/methods/index.ts +++ b/packages/snaps-sdk/src/types/methods/index.ts @@ -12,12 +12,16 @@ export * from './get-preferences'; export * from './get-snaps'; export * from './invoke-keyring'; export * from './invoke-snap'; +export * from './list-devices'; export * from './manage-accounts'; export * from './manage-state'; export * from './methods'; export * from './notify'; +export * from './read-device'; export * from './request-snaps'; export * from './update-interface'; export * from './resolve-interface'; +export * from './get-supported-devices'; export * from './get-currency-rate'; export * from './provider-request'; +export * from './write-device'; diff --git a/packages/snaps-sdk/src/types/methods/list-devices.ts b/packages/snaps-sdk/src/types/methods/list-devices.ts index 25a25e698f..3668e0c963 100644 --- a/packages/snaps-sdk/src/types/methods/list-devices.ts +++ b/packages/snaps-sdk/src/types/methods/list-devices.ts @@ -1,9 +1,14 @@ -import type { Device } from '../device'; +import type { Device, DeviceType } from '../device'; /** * The request parameters for the `snap_listDevices` method. */ -export type ListDevicesParams = never; +export type ListDevicesParams = { + /** + * The type of the device to list. If not provided, all devices are listed. + */ + type?: DeviceType; +}; /** * The result returned by the `snap_readDevice` method. This is a list of diff --git a/packages/snaps-sdk/src/types/methods/methods.ts b/packages/snaps-sdk/src/types/methods/methods.ts index b46d0be15e..20ccdd951a 100644 --- a/packages/snaps-sdk/src/types/methods/methods.ts +++ b/packages/snaps-sdk/src/types/methods/methods.ts @@ -32,6 +32,10 @@ import type { GetPreferencesResult, } from './get-preferences'; import type { GetSnapsParams, GetSnapsResult } from './get-snaps'; +import type { + GetSupportedDevicesParams, + GetSupportedDevicesResult, +} from './get-supported-devices'; import type { InvokeKeyringParams, InvokeKeyringResult, @@ -74,6 +78,10 @@ export type SnapMethods = { snap_getEntropy: [GetEntropyParams, GetEntropyResult]; snap_getFile: [GetFileParams, GetFileResult]; snap_getLocale: [GetLocaleParams, GetLocaleResult]; + snap_getSupportedDevices: [ + GetSupportedDevicesParams, + GetSupportedDevicesResult, + ]; snap_getPreferences: [GetPreferencesParams, GetPreferencesResult]; snap_listDevices: [ListDevicesParams, ListDevicesResult]; snap_manageAccounts: [ManageAccountsParams, ManageAccountsResult]; diff --git a/packages/snaps-sdk/src/types/methods/request-device.ts b/packages/snaps-sdk/src/types/methods/request-device.ts index 08ab180cb0..0da339b287 100644 --- a/packages/snaps-sdk/src/types/methods/request-device.ts +++ b/packages/snaps-sdk/src/types/methods/request-device.ts @@ -2,14 +2,14 @@ import type { Device, DeviceType } from '../device'; type DeviceFilter = { /** - * The type of the device. + * The vendor ID of the device. */ - vendorId?: string; + vendorId?: number; /** * The product ID of the device. */ - productId?: string; + productId?: number; }; /** @@ -22,9 +22,9 @@ export type RequestDeviceParams = { type: DeviceType; /** - * The filter to apply to the devices. + * The filters to apply to the devices. */ - filter?: DeviceFilter[]; + filters?: DeviceFilter[]; }; /** diff --git a/yarn.lock b/yarn.lock index e9770eb180..c8797cf43c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3807,6 +3807,44 @@ __metadata: languageName: node linkType: hard +"@ledgerhq/devices@npm:^8.4.4": + version: 8.4.4 + resolution: "@ledgerhq/devices@npm:8.4.4" + dependencies: + "@ledgerhq/errors": "npm:^6.19.1" + "@ledgerhq/logs": "npm:^6.12.0" + rxjs: "npm:^7.8.1" + semver: "npm:^7.3.5" + checksum: 10/57136fc45ae2fa42b3cf93eb7cc3542fd84010390b3d0a536d342c7e92f90e475d608b1774f17a547419edddd7df0d0b1b1dbd6d2c778009ebab0fc3ec313f67 + languageName: node + linkType: hard + +"@ledgerhq/errors@npm:^6.19.1": + version: 6.19.1 + resolution: "@ledgerhq/errors@npm:6.19.1" + checksum: 10/8265c6d73c314a4aabbe060ec29e2feebb4e904fe811bf7a9c53cde08e713dcbceded9d927ebb2f0ffc47a7b16524379d4a7e9aa3d61945b8a832be7cd5cf69b + languageName: node + linkType: hard + +"@ledgerhq/hw-transport@npm:^6.31.4": + version: 6.31.4 + resolution: "@ledgerhq/hw-transport@npm:6.31.4" + dependencies: + "@ledgerhq/devices": "npm:^8.4.4" + "@ledgerhq/errors": "npm:^6.19.1" + "@ledgerhq/logs": "npm:^6.12.0" + events: "npm:^3.3.0" + checksum: 10/cf101e5b818e95e59031241d556dbec24658f54104910e414be493bc4b90b0aea50f5d4b3339a237dd0b12845bb2683c845f3a82f2ea9da4e077b68d1e1f7e48 + languageName: node + linkType: hard + +"@ledgerhq/logs@npm:^6.12.0": + version: 6.12.0 + resolution: "@ledgerhq/logs@npm:6.12.0" + checksum: 10/a0a01f5d6edb0c14e7a42d24ab67ce362219517f6a50d0572c917f4f7988a1e2e9363e3d0fb170fe267f054e1e30a111564de44276e01c538b258d902c546421 + languageName: node + linkType: hard + "@leichtgewicht/ip-codec@npm:^2.0.1": version: 2.0.4 resolution: "@leichtgewicht/ip-codec@npm:2.0.4" @@ -5043,6 +5081,47 @@ __metadata: languageName: node linkType: hard +"@metamask/ledger-example-snap@workspace:packages/examples/packages/ledger": + version: 0.0.0-use.local + resolution: "@metamask/ledger-example-snap@workspace:packages/examples/packages/ledger" + dependencies: + "@jest/globals": "npm:^29.5.0" + "@lavamoat/allow-scripts": "npm:^3.0.4" + "@ledgerhq/devices": "npm:^8.4.4" + "@ledgerhq/errors": "npm:^6.19.1" + "@ledgerhq/hw-transport": "npm:^6.31.4" + "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/eslint-config": "npm:^12.1.0" + "@metamask/eslint-config-jest": "npm:^12.1.0" + "@metamask/eslint-config-nodejs": "npm:^12.1.0" + "@metamask/eslint-config-typescript": "npm:^12.1.0" + "@metamask/snaps-cli": "workspace:^" + "@metamask/snaps-jest": "workspace:^" + "@metamask/snaps-sdk": "workspace:^" + "@metamask/utils": "npm:^10.0.0" + "@swc/core": "npm:1.3.78" + "@swc/jest": "npm:^0.2.26" + "@typescript-eslint/eslint-plugin": "npm:^5.42.1" + "@typescript-eslint/parser": "npm:^6.21.0" + deepmerge: "npm:^4.2.2" + depcheck: "npm:^1.4.7" + eslint: "npm:^8.27.0" + eslint-config-prettier: "npm:^8.5.0" + eslint-plugin-import: "npm:^2.26.0" + eslint-plugin-jest: "npm:^27.1.5" + eslint-plugin-jsdoc: "npm:^41.1.2" + eslint-plugin-n: "npm:^15.7.0" + eslint-plugin-prettier: "npm:^4.2.1" + eslint-plugin-promise: "npm:^6.1.1" + jest: "npm:^29.0.2" + jest-silent-reporter: "npm:^0.6.0" + prettier: "npm:^2.8.8" + prettier-plugin-packagejson: "npm:^2.5.2" + ts-node: "npm:^10.9.1" + typescript: "npm:~5.3.3" + languageName: unknown + linkType: soft + "@metamask/lifecycle-hooks-example-snap@workspace:^, @metamask/lifecycle-hooks-example-snap@workspace:packages/examples/packages/lifecycle-hooks": version: 0.0.0-use.local resolution: "@metamask/lifecycle-hooks-example-snap@workspace:packages/examples/packages/lifecycle-hooks" @@ -6442,6 +6521,23 @@ __metadata: languageName: unknown linkType: soft +"@metamask/utils@npm:^10.0.0": + version: 10.0.0 + resolution: "@metamask/utils@npm:10.0.0" + dependencies: + "@ethereumjs/tx": "npm:^4.2.0" + "@metamask/superstruct": "npm:^3.1.0" + "@noble/hashes": "npm:^1.3.1" + "@scure/base": "npm:^1.1.3" + "@types/debug": "npm:^4.1.7" + debug: "npm:^4.3.4" + pony-cause: "npm:^2.1.10" + semver: "npm:^7.5.4" + uuid: "npm:^9.0.1" + checksum: 10/9c2e6421f685d8a45145b6026a6f9fd0701eb5a2e8490fc6d18e64c103d5a62097f301cbc797790da52ceb5853bd9f65845c934b00299e69e5e6736c52b32f0f + languageName: node + linkType: hard + "@metamask/utils@npm:^9.0.0, @metamask/utils@npm:^9.1.0, @metamask/utils@npm:^9.2.1": version: 9.2.1 resolution: "@metamask/utils@npm:9.2.1" From 17b87e511d8b2035423e2aabde66697256ffb394 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Wed, 30 Oct 2024 11:43:48 +0100 Subject: [PATCH 04/11] Add snap_readDevice, snap_writeDevice, snap_requestDevice implementations --- .../src/permitted/handlers.ts | 7 +- .../snaps-rpc-methods/src/permitted/index.ts | 8 +- .../src/permitted/readDevice.ts | 85 +++++++++++++++++++ .../src/permitted/requestDevice.ts | 78 ++++++++++------- .../src/permitted/writeDevice.ts | 83 ++++++++++++++++++ packages/snaps-sdk/src/index.ts | 2 +- packages/snaps-sdk/src/types/device.ts | 33 +++++++ packages/snaps-sdk/src/types/methods/index.ts | 1 + .../src/types/methods/request-device.ts | 13 ++- .../src/types/methods/write-device.ts | 2 +- 10 files changed, 273 insertions(+), 39 deletions(-) create mode 100644 packages/snaps-rpc-methods/src/permitted/readDevice.ts create mode 100644 packages/snaps-rpc-methods/src/permitted/writeDevice.ts diff --git a/packages/snaps-rpc-methods/src/permitted/handlers.ts b/packages/snaps-rpc-methods/src/permitted/handlers.ts index 37b0771190..05f72b9285 100644 --- a/packages/snaps-rpc-methods/src/permitted/handlers.ts +++ b/packages/snaps-rpc-methods/src/permitted/handlers.ts @@ -1,6 +1,5 @@ import { createInterfaceHandler } from './createInterface'; import { providerRequestHandler } from './experimentalProviderRequest'; -import { providerRequestHandler as requestDeviceHandler } from './requestDevice'; import { getAllSnapsHandler } from './getAllSnaps'; import { getClientStatusHandler } from './getClientStatus'; import { getCurrencyRateHandler } from './getCurrencyRate'; @@ -9,10 +8,12 @@ import { getInterfaceStateHandler } from './getInterfaceState'; import { getSnapsHandler } from './getSnaps'; import { invokeKeyringHandler } from './invokeKeyring'; import { invokeSnapSugarHandler } from './invokeSnapSugar'; +import { readDeviceHandler } from './readDevice'; +import { requestDeviceHandler } from './requestDevice'; import { requestSnapsHandler } from './requestSnaps'; import { resolveInterfaceHandler } from './resolveInterface'; import { updateInterfaceHandler } from './updateInterface'; - +import { writeDeviceHandler } from './writeDevice'; /* eslint-disable @typescript-eslint/naming-convention */ export const methodHandlers = { @@ -29,7 +30,9 @@ export const methodHandlers = { snap_resolveInterface: resolveInterfaceHandler, snap_getCurrencyRate: getCurrencyRateHandler, snap_experimentalProviderRequest: providerRequestHandler, + snap_readDevice: readDeviceHandler, snap_requestDevice: requestDeviceHandler, + snap_writeDevice: writeDeviceHandler, }; /* eslint-enable @typescript-eslint/naming-convention */ diff --git a/packages/snaps-rpc-methods/src/permitted/index.ts b/packages/snaps-rpc-methods/src/permitted/index.ts index 5aa676fce3..155b211935 100644 --- a/packages/snaps-rpc-methods/src/permitted/index.ts +++ b/packages/snaps-rpc-methods/src/permitted/index.ts @@ -5,9 +5,12 @@ import type { GetClientStatusHooks } from './getClientStatus'; import type { GetCurrencyRateMethodHooks } from './getCurrencyRate'; import type { GetInterfaceStateMethodHooks } from './getInterfaceState'; import type { GetSnapsHooks } from './getSnaps'; +import type { ReadDeviceHooks } from './readDevice'; +import type { RequestDeviceHooks } from './requestDevice'; import type { RequestSnapsHooks } from './requestSnaps'; import type { ResolveInterfaceMethodHooks } from './resolveInterface'; import type { UpdateInterfaceMethodHooks } from './updateInterface'; +import type { WriteDeviceHooks } from './writeDevice'; export type PermittedRpcMethodHooks = GetAllSnapsHooks & GetClientStatusHooks & @@ -18,7 +21,10 @@ export type PermittedRpcMethodHooks = GetAllSnapsHooks & GetInterfaceStateMethodHooks & ResolveInterfaceMethodHooks & GetCurrencyRateMethodHooks & - ProviderRequestMethodHooks; + ProviderRequestMethodHooks & + ReadDeviceHooks & + RequestDeviceHooks & + WriteDeviceHooks; export * from './handlers'; export * from './middleware'; diff --git a/packages/snaps-rpc-methods/src/permitted/readDevice.ts b/packages/snaps-rpc-methods/src/permitted/readDevice.ts new file mode 100644 index 0000000000..e098e226d1 --- /dev/null +++ b/packages/snaps-rpc-methods/src/permitted/readDevice.ts @@ -0,0 +1,85 @@ +import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; +import type { PermittedHandlerExport } from '@metamask/permission-controller'; +import type { + JsonRpcRequest, + ReadDeviceParams, + ReadDeviceResult, +} from '@metamask/snaps-sdk'; +import { deviceId } from '@metamask/snaps-sdk'; +import type { InferMatching } from '@metamask/snaps-utils'; +import { + literal, + number, + object, + optional, + union, +} from '@metamask/superstruct'; +import { assertStruct, type PendingJsonRpcResponse } from '@metamask/utils'; + +import type { MethodHooksObject } from '../utils'; + +const hookNames: MethodHooksObject = { + readDevice: true, +}; + +export type ReadDeviceHooks = { + /** + * A hook to request a device. + * + * @returns The requested device, or `null` if no device was provided. + */ + readDevice: (params: ReadDeviceParams) => Promise; +}; + +export const readDeviceHandler: PermittedHandlerExport< + ReadDeviceHooks, + ReadDeviceParams, + ReadDeviceResult +> = { + methodNames: ['snap_readDevice'], + implementation: readDeviceImplementation, + hookNames, +}; + +const ReadDeviceParametersStruct = object({ + type: literal('hid'), + id: deviceId('hid'), + reportType: union([literal('output'), literal('feature')]), + reportId: optional(number()), +}); + +export type ReadDeviceParameters = InferMatching< + typeof ReadDeviceParametersStruct, + ReadDeviceParams +>; + +/** + * Handles the `snap_readDevice` method. + * + * @param request - The JSON-RPC request object. + * @param response - The JSON-RPC response object. + * @param _next - The `json-rpc-engine` "next" callback. Not used by this + * method. + * @param end - The `json-rpc-engine` "end" callback. + * @param hooks - The RPC method hooks. + * @param hooks.readDevice - The function to request a device. + * @returns Nothing. + */ +async function readDeviceImplementation( + request: JsonRpcRequest, + response: PendingJsonRpcResponse, + _next: unknown, + end: JsonRpcEngineEndCallback, + { readDevice }: ReadDeviceHooks, +): Promise { + const { params } = request; + assertStruct(params, ReadDeviceParametersStruct); + + try { + response.result = await readDevice(params); + } catch (error) { + return end(error); + } + + return end(); +} diff --git a/packages/snaps-rpc-methods/src/permitted/requestDevice.ts b/packages/snaps-rpc-methods/src/permitted/requestDevice.ts index e49c542bb2..61080c19e9 100644 --- a/packages/snaps-rpc-methods/src/permitted/requestDevice.ts +++ b/packages/snaps-rpc-methods/src/permitted/requestDevice.ts @@ -2,61 +2,73 @@ import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; import type { PermittedHandlerExport } from '@metamask/permission-controller'; import type { JsonRpcRequest, - ProviderRequestParams, - ProviderRequestResult, + RequestDeviceParams, + RequestDeviceResult, } from '@metamask/snaps-sdk'; -import { type InferMatching } from '@metamask/snaps-utils'; -import { object, optional, string, type } from '@metamask/superstruct'; -import { - type PendingJsonRpcResponse, - CaipChainIdStruct, - JsonRpcParamsStruct, -} from '@metamask/utils'; +import { DeviceFilterStruct, DeviceTypeStruct } from '@metamask/snaps-sdk'; +import type { InferMatching } from '@metamask/snaps-utils'; +import { object, optional, array } from '@metamask/superstruct'; +import { assertStruct, type PendingJsonRpcResponse } from '@metamask/utils'; import type { MethodHooksObject } from '../utils'; -const hookNames: MethodHooksObject = { - requestDevices: true, +const hookNames: MethodHooksObject = { + requestDevice: true, }; -export type ProviderRequestMethodHooks = { - requestDevices: () => any; +export type RequestDeviceHooks = { + /** + * A hook to request a device. + * + * @returns The requested device, or `null` if no device was provided. + */ + requestDevice: (params: RequestDeviceParams) => Promise; }; -export const providerRequestHandler: PermittedHandlerExport< - ProviderRequestMethodHooks, - ProviderRequestParameters, - ProviderRequestResult +export const requestDeviceHandler: PermittedHandlerExport< + RequestDeviceHooks, + RequestDeviceParams, + RequestDeviceResult > = { methodNames: ['snap_requestDevice'], - implementation: providerRequestImplementation, + implementation: requestDeviceImplementation, hookNames, }; -const ProviderRequestParametersStruct = object({ - chainId: CaipChainIdStruct, - request: type({ - method: string(), - params: optional(JsonRpcParamsStruct), - }), +const RequestDeviceParametersStruct = object({ + type: DeviceTypeStruct, + filters: optional(array(DeviceFilterStruct)), }); -export type ProviderRequestParameters = InferMatching< - typeof ProviderRequestParametersStruct, - ProviderRequestParams +export type RequestDeviceParameters = InferMatching< + typeof RequestDeviceParametersStruct, + RequestDeviceParams >; -async function providerRequestImplementation( - req: JsonRpcRequest, - res: PendingJsonRpcResponse, +/** + * Handles the `snap_requestDevice` method. + * + * @param request - The JSON-RPC request object. + * @param response - The JSON-RPC response object. + * @param _next - The `json-rpc-engine` "next" callback. Not used by this + * method. + * @param end - The `json-rpc-engine` "end" callback. + * @param hooks - The RPC method hooks. + * @param hooks.requestDevice - The function to request a device. + * @returns Nothing. + */ +async function requestDeviceImplementation( + request: JsonRpcRequest, + response: PendingJsonRpcResponse, _next: unknown, end: JsonRpcEngineEndCallback, - { requestDevices }: ProviderRequestMethodHooks, + { requestDevice }: RequestDeviceHooks, ): Promise { - const { params } = req; + const { params } = request; + assertStruct(params, RequestDeviceParametersStruct); try { - res.result = await requestDevices(); + response.result = await requestDevice(params); } catch (error) { return end(error); } diff --git a/packages/snaps-rpc-methods/src/permitted/writeDevice.ts b/packages/snaps-rpc-methods/src/permitted/writeDevice.ts new file mode 100644 index 0000000000..f0ac05a474 --- /dev/null +++ b/packages/snaps-rpc-methods/src/permitted/writeDevice.ts @@ -0,0 +1,83 @@ +import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; +import type { PermittedHandlerExport } from '@metamask/permission-controller'; +import type { + JsonRpcRequest, + WriteDeviceParams, + WriteDeviceResult, +} from '@metamask/snaps-sdk'; +import { deviceId } from '@metamask/snaps-sdk'; +import type { InferMatching } from '@metamask/snaps-utils'; +import { literal, number, object, optional } from '@metamask/superstruct'; +import { + assertStruct, + type PendingJsonRpcResponse, + StrictHexStruct, +} from '@metamask/utils'; + +import type { MethodHooksObject } from '../utils'; + +const hookNames: MethodHooksObject = { + writeDevice: true, +}; + +export type WriteDeviceHooks = { + /** + * A hook to request a device. + * + * @returns The requested device, or `null` if no device was provided. + */ + writeDevice: (params: WriteDeviceParams) => Promise; +}; + +export const writeDeviceHandler: PermittedHandlerExport< + WriteDeviceHooks, + WriteDeviceParams, + WriteDeviceResult +> = { + methodNames: ['snap_writeDevice'], + implementation: writeDeviceImplementation, + hookNames, +}; + +const WriteDeviceParametersStruct = object({ + type: literal('hid'), + id: deviceId('hid'), + data: StrictHexStruct, + reportId: optional(number()), +}); + +export type WriteDeviceParameters = InferMatching< + typeof WriteDeviceParametersStruct, + WriteDeviceParams +>; + +/** + * Handles the `snap_writeDevice` method. + * + * @param request - The JSON-RPC request object. + * @param response - The JSON-RPC response object. + * @param _next - The `json-rpc-engine` "next" callback. Not used by this + * method. + * @param end - The `json-rpc-engine` "end" callback. + * @param hooks - The RPC method hooks. + * @param hooks.writeDevice - The function to request a device. + * @returns Nothing. + */ +async function writeDeviceImplementation( + request: JsonRpcRequest, + response: PendingJsonRpcResponse, + _next: unknown, + end: JsonRpcEngineEndCallback, + { writeDevice }: WriteDeviceHooks, +): Promise { + const { params } = request; + assertStruct(params, WriteDeviceParametersStruct); + + try { + response.result = await writeDevice(params); + } catch (error) { + return end(error); + } + + return end(); +} diff --git a/packages/snaps-sdk/src/index.ts b/packages/snaps-sdk/src/index.ts index c90eb4c6fa..41cddbad65 100644 --- a/packages/snaps-sdk/src/index.ts +++ b/packages/snaps-sdk/src/index.ts @@ -1,5 +1,5 @@ // Only internals that are used by other Snaps packages should be exported here. -export type { EnumToUnion } from './internals'; +export type { Describe, EnumToUnion } from './internals'; export { getErrorData, getErrorMessage, diff --git a/packages/snaps-sdk/src/types/device.ts b/packages/snaps-sdk/src/types/device.ts index f1a4b3b532..1d21459ddf 100644 --- a/packages/snaps-sdk/src/types/device.ts +++ b/packages/snaps-sdk/src/types/device.ts @@ -1,8 +1,21 @@ +import type { Struct } from '@metamask/superstruct'; +import { literal, refine, string, union } from '@metamask/superstruct'; + +import type { Describe } from '../internals'; + /** * The type of the device. */ export type DeviceType = 'hid' | 'bluetooth'; +/** + * A struct that represents the `DeviceType` type. + */ +export const DeviceTypeStruct: Describe = union([ + literal('hid'), + literal('bluetooth'), +]); + /** * The ID of the device. It consists of the type of the device, the vendor ID, * and the product ID. @@ -21,6 +34,26 @@ export type ScopedDeviceId = ? `${Type}:${string}:${string}` : never; +/** + * A struct that represents the `DeviceId` type. + * + * @param type - The type of the device. + * @returns A struct that represents the `DeviceId` type. + */ +export function deviceId( + type?: Type, +): Type extends DeviceType ? Struct> : Struct { + return refine(string(), 'device ID', (value) => { + if (type) { + return value.startsWith(`${type}:`) && value.split(':').length === 3; + } + + return value.split(':').length === 3; + }) as Type extends DeviceType + ? Struct> + : Struct; +} + /** * A device that is available to the Snap. */ diff --git a/packages/snaps-sdk/src/types/methods/index.ts b/packages/snaps-sdk/src/types/methods/index.ts index 8022e80f35..36fa923670 100644 --- a/packages/snaps-sdk/src/types/methods/index.ts +++ b/packages/snaps-sdk/src/types/methods/index.ts @@ -18,6 +18,7 @@ export * from './manage-state'; export * from './methods'; export * from './notify'; export * from './read-device'; +export * from './request-device'; export * from './request-snaps'; export * from './update-interface'; export * from './resolve-interface'; diff --git a/packages/snaps-sdk/src/types/methods/request-device.ts b/packages/snaps-sdk/src/types/methods/request-device.ts index 0da339b287..f2df8cc6db 100644 --- a/packages/snaps-sdk/src/types/methods/request-device.ts +++ b/packages/snaps-sdk/src/types/methods/request-device.ts @@ -1,6 +1,9 @@ +import { number, object, optional } from '@metamask/superstruct'; + +import type { Describe } from '../../internals'; import type { Device, DeviceType } from '../device'; -type DeviceFilter = { +export type DeviceFilter = { /** * The vendor ID of the device. */ @@ -12,6 +15,14 @@ type DeviceFilter = { productId?: number; }; +/** + * A struct that represents the `DeviceFilter` type. + */ +export const DeviceFilterStruct: Describe = object({ + vendorId: optional(number()), + productId: optional(number()), +}); + /** * The request parameters for the `snap_requestDevice` method. */ diff --git a/packages/snaps-sdk/src/types/methods/write-device.ts b/packages/snaps-sdk/src/types/methods/write-device.ts index f9303a64ef..07e9b58505 100644 --- a/packages/snaps-sdk/src/types/methods/write-device.ts +++ b/packages/snaps-sdk/src/types/methods/write-device.ts @@ -37,4 +37,4 @@ export type WriteDeviceParams = HidWriteParams; /** * The result returned by the `snap_writeDevice` method. */ -export type WriteDeviceResult = void; +export type WriteDeviceResult = never; From 911b279451d003a2068889759733230faeafac13 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 1 Nov 2024 12:58:08 +0100 Subject: [PATCH 05/11] Add device IDs endowment --- .../packages/ledger/snap.manifest.json | 9 +- .../src/endowments/devices.ts | 126 ++++++++++++++++++ .../snaps-rpc-methods/src/endowments/enum.ts | 1 + .../snaps-rpc-methods/src/endowments/index.ts | 6 + packages/snaps-utils/src/caveats.ts | 5 + packages/snaps-utils/src/devices.ts | 50 +++++++ packages/snaps-utils/src/index.ts | 1 + 7 files changed, 193 insertions(+), 5 deletions(-) create mode 100644 packages/snaps-rpc-methods/src/endowments/devices.ts create mode 100644 packages/snaps-utils/src/devices.ts diff --git a/packages/examples/packages/ledger/snap.manifest.json b/packages/examples/packages/ledger/snap.manifest.json index 023c79aacb..e6159ac897 100644 --- a/packages/examples/packages/ledger/snap.manifest.json +++ b/packages/examples/packages/ledger/snap.manifest.json @@ -1,7 +1,7 @@ { "version": "2.1.3", - "description": "MetaMask example snap demonstrating the use of the `onInstall` and `onUpdate` lifecycle hooks.", - "proposedName": "Lifecycle Hooks Example Snap", + "description": "MetaMask example Snap demonstrating how to communicate with a Ledger hardware wallet.", + "proposedName": "Ledger Example Snap", "repository": { "type": "git", "url": "https://github.com/MetaMask/snaps.git" @@ -11,14 +11,13 @@ "location": { "npm": { "filePath": "dist/bundle.js", - "packageName": "@metamask/lifecycle-hooks-example-snap", + "packageName": "@metamask/ledger-example-snap", "registry": "https://registry.npmjs.org" } } }, "initialPermissions": { - "snap_dialog": {}, - "endowment:lifecycle-hooks": {} + "endowment:devices": {} }, "manifestVersion": "0.1" } diff --git a/packages/snaps-rpc-methods/src/endowments/devices.ts b/packages/snaps-rpc-methods/src/endowments/devices.ts new file mode 100644 index 0000000000..2c04549b0a --- /dev/null +++ b/packages/snaps-rpc-methods/src/endowments/devices.ts @@ -0,0 +1,126 @@ +import type { + Caveat, + CaveatSpecificationConstraint, + EndowmentGetterParams, + PermissionConstraint, + PermissionSpecificationBuilder, + ValidPermissionSpecification, +} from '@metamask/permission-controller'; +import { PermissionType, SubjectType } from '@metamask/permission-controller'; +import { rpcErrors } from '@metamask/rpc-errors'; +import type { DeviceSpecification } from '@metamask/snaps-utils'; +import { + SnapCaveatType, + isDeviceSpecificationArray, +} from '@metamask/snaps-utils'; +import { hasProperty, isPlainObject, assert } from '@metamask/utils'; + +import { SnapEndowments } from './enum'; + +const permissionName = SnapEndowments.Devices; + +type DevicesEndowmentSpecification = ValidPermissionSpecification<{ + permissionType: PermissionType.Endowment; + targetName: typeof permissionName; + endowmentGetter: (_options?: any) => null; + allowedCaveats: [SnapCaveatType.DeviceIds]; +}>; + +/** + * The `endowment:devices` permission is intended to be used as a flag to + * determine whether the Snap wants to access the devices API. + * + * @param _builderOptions - Optional specification builder options. + * @returns The specification for the network endowment. + */ +const specificationBuilder: PermissionSpecificationBuilder< + PermissionType.Endowment, + any, + DevicesEndowmentSpecification +> = (_builderOptions?: any) => { + return { + permissionType: PermissionType.Endowment, + targetName: permissionName, + allowedCaveats: [SnapCaveatType.DeviceIds], + endowmentGetter: (_getterOptions?: EndowmentGetterParams) => null, + subjectTypes: [SubjectType.Snap], + }; +}; + +export const devicesEndowmentBuilder = Object.freeze({ + targetName: permissionName, + specificationBuilder, +} as const); + +/** + * Getter function to get the permitted device IDs from a permission + * specification. + * + * This does basic validation of the caveat, but does not validate the type or + * value of the namespaces object itself, as this is handled by the + * `PermissionsController` when the permission is requested. + * + * @param permission - The permission to get the keyring namespaces from. + * @returns The device IDs, or `null` if the permission does not have a + * device IDs caveat. + */ +export function getPermittedDeviceIds( + permission?: PermissionConstraint, +): DeviceSpecification[] | null { + if (!permission?.caveats) { + return null; + } + + assert(permission.caveats.length === 1); + assert(permission.caveats[0].type === SnapCaveatType.DeviceIds); + + const caveat = permission.caveats[0] as Caveat< + string, + { devices: DeviceSpecification[] } + >; + + return caveat.value?.devices ?? null; +} + +/** + * Validate the cronjob specification values associated with a caveat. + * This validates that the value is a non-empty array with valid device + * specification objects. + * + * @param caveat - The caveat to validate. + * @throws If the value is invalid. + */ +export function validateDeviceIdsCaveat(caveat: Caveat) { + if (!hasProperty(caveat, 'value') || !isPlainObject(caveat.value)) { + throw rpcErrors.invalidParams({ + message: 'Expected a plain object.', + }); + } + + const { value } = caveat; + + if (!hasProperty(value, 'devices') || !isPlainObject(value)) { + throw rpcErrors.invalidParams({ + message: 'Expected a plain object.', + }); + } + + if (!isDeviceSpecificationArray(value.jobs)) { + throw rpcErrors.invalidParams({ + message: 'Expected a valid device specification array.', + }); + } +} + +/** + * Caveat specification for the device IDs caveat. + */ +export const deviceIdsCaveatSpecifications: Record< + SnapCaveatType.DeviceIds, + CaveatSpecificationConstraint +> = { + [SnapCaveatType.DeviceIds]: Object.freeze({ + type: SnapCaveatType.DeviceIds, + validator: (caveat) => validateDeviceIdsCaveat(caveat), + }), +}; diff --git a/packages/snaps-rpc-methods/src/endowments/enum.ts b/packages/snaps-rpc-methods/src/endowments/enum.ts index f0d1577c6f..40d8bb651f 100644 --- a/packages/snaps-rpc-methods/src/endowments/enum.ts +++ b/packages/snaps-rpc-methods/src/endowments/enum.ts @@ -10,4 +10,5 @@ export enum SnapEndowments { LifecycleHooks = 'endowment:lifecycle-hooks', Keyring = 'endowment:keyring', HomePage = 'endowment:page-home', + Devices = 'endowment:devices', } diff --git a/packages/snaps-rpc-methods/src/endowments/index.ts b/packages/snaps-rpc-methods/src/endowments/index.ts index faa0c8fe06..b96e3bd7d6 100644 --- a/packages/snaps-rpc-methods/src/endowments/index.ts +++ b/packages/snaps-rpc-methods/src/endowments/index.ts @@ -12,6 +12,10 @@ import { cronjobEndowmentBuilder, getCronjobCaveatMapper, } from './cronjob'; +import { + deviceIdsCaveatSpecifications, + devicesEndowmentBuilder, +} from './devices'; import { ethereumProviderEndowmentBuilder } from './ethereum-provider'; import { homePageEndowmentBuilder } from './home-page'; import { @@ -48,6 +52,7 @@ export const endowmentPermissionBuilders = { [transactionInsightEndowmentBuilder.targetName]: transactionInsightEndowmentBuilder, [cronjobEndowmentBuilder.targetName]: cronjobEndowmentBuilder, + [devicesEndowmentBuilder.targetName]: devicesEndowmentBuilder, [ethereumProviderEndowmentBuilder.targetName]: ethereumProviderEndowmentBuilder, [rpcEndowmentBuilder.targetName]: rpcEndowmentBuilder, @@ -62,6 +67,7 @@ export const endowmentPermissionBuilders = { export const endowmentCaveatSpecifications = { ...cronjobCaveatSpecifications, + ...deviceIdsCaveatSpecifications, ...transactionInsightCaveatSpecifications, ...rpcCaveatSpecifications, ...nameLookupCaveatSpecifications, diff --git a/packages/snaps-utils/src/caveats.ts b/packages/snaps-utils/src/caveats.ts index 61bd80910e..70479b2096 100644 --- a/packages/snaps-utils/src/caveats.ts +++ b/packages/snaps-utils/src/caveats.ts @@ -53,4 +53,9 @@ export enum SnapCaveatType { * Caveat specifying the max request time for a handler endowment. */ MaxRequestTime = 'maxRequestTime', + + /** + * Caveat specifying the device IDs that can be interacted with. + */ + DeviceIds = 'deviceIds', } diff --git a/packages/snaps-utils/src/devices.ts b/packages/snaps-utils/src/devices.ts new file mode 100644 index 0000000000..f1152c3d31 --- /dev/null +++ b/packages/snaps-utils/src/devices.ts @@ -0,0 +1,50 @@ +import { deviceId } from '@metamask/snaps-sdk'; +import type { Infer } from '@metamask/superstruct'; +import { array, is, object } from '@metamask/superstruct'; + +export const DeviceSpecificationStruct = object({ + /** + * The device ID that the Snap has permission to access. + */ + deviceId: deviceId(), +}); + +/** + * A device specification, which is used as caveat value. + */ +export type DeviceSpecification = Infer; + +/** + * Check if the given value is a {@link DeviceSpecification} object. + * + * @param value - The value to check. + * @returns Whether the value is a {@link DeviceSpecification} object. + */ +export function isDeviceSpecification( + value: unknown, +): value is DeviceSpecification { + return is(value, DeviceSpecificationStruct); +} + +export const DeviceSpecificationArrayStruct = object({ + devices: array(DeviceSpecificationStruct), +}); + +/** + * A device specification array, which is used as caveat value. + */ +export type DeviceSpecificationArray = Infer< + typeof DeviceSpecificationArrayStruct +>; + +/** + * Check if the given value is a {@link DeviceSpecificationArray} object. + * + * @param value - The value to check. + * @returns Whether the value is a {@link DeviceSpecificationArray} object. + */ +export function isDeviceSpecificationArray( + value: unknown, +): value is DeviceSpecificationArray { + return is(value, DeviceSpecificationArrayStruct); +} diff --git a/packages/snaps-utils/src/index.ts b/packages/snaps-utils/src/index.ts index c3488c1d33..8f6d9d8658 100644 --- a/packages/snaps-utils/src/index.ts +++ b/packages/snaps-utils/src/index.ts @@ -10,6 +10,7 @@ export * from './currency'; export * from './deep-clone'; export * from './default-endowments'; export * from './derivation-paths'; +export * from './devices'; export * from './entropy'; export * from './errors'; export * from './handlers'; From 7ba28427459d6f3cb62405592648c074bd7d8121 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 1 Nov 2024 12:58:35 +0100 Subject: [PATCH 06/11] Update example version --- packages/examples/packages/ledger/package.json | 2 +- packages/examples/packages/ledger/snap.manifest.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/examples/packages/ledger/package.json b/packages/examples/packages/ledger/package.json index d3da711a3a..7e49707302 100644 --- a/packages/examples/packages/ledger/package.json +++ b/packages/examples/packages/ledger/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/ledger-example-snap", - "version": "2.1.3", + "version": "0.0.0", "description": "MetaMask example Snap demonstrating how to communicate with a Ledger hardware wallet", "keywords": [ "MetaMask", diff --git a/packages/examples/packages/ledger/snap.manifest.json b/packages/examples/packages/ledger/snap.manifest.json index e6159ac897..f5a0fca87e 100644 --- a/packages/examples/packages/ledger/snap.manifest.json +++ b/packages/examples/packages/ledger/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "2.1.3", + "version": "0.0.0", "description": "MetaMask example Snap demonstrating how to communicate with a Ledger hardware wallet.", "proposedName": "Ledger Example Snap", "repository": { From cadde59d9a208287380a067a9bbe129c0deb6327 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 1 Nov 2024 13:16:57 +0100 Subject: [PATCH 07/11] Update jsdoc --- packages/snaps-rpc-methods/src/endowments/devices.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/snaps-rpc-methods/src/endowments/devices.ts b/packages/snaps-rpc-methods/src/endowments/devices.ts index 2c04549b0a..5e13281769 100644 --- a/packages/snaps-rpc-methods/src/endowments/devices.ts +++ b/packages/snaps-rpc-methods/src/endowments/devices.ts @@ -27,8 +27,8 @@ type DevicesEndowmentSpecification = ValidPermissionSpecification<{ }>; /** - * The `endowment:devices` permission is intended to be used as a flag to - * determine whether the Snap wants to access the devices API. + * The `endowment:devices` permission is granted to a Snap when it requested + * access to a specific device. The device IDs are specified in the caveat. * * @param _builderOptions - Optional specification builder options. * @returns The specification for the network endowment. From 4ad9403c41915175cdde4f385e2532491601f9c9 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 1 Nov 2024 13:17:55 +0100 Subject: [PATCH 08/11] Update example permissions --- packages/examples/packages/ledger/snap.manifest.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/examples/packages/ledger/snap.manifest.json b/packages/examples/packages/ledger/snap.manifest.json index f5a0fca87e..cbf1a9233e 100644 --- a/packages/examples/packages/ledger/snap.manifest.json +++ b/packages/examples/packages/ledger/snap.manifest.json @@ -17,7 +17,9 @@ } }, "initialPermissions": { - "endowment:devices": {} + "endowment:rpc": { + "dapps": true + } }, "manifestVersion": "0.1" } From 9ad7ecda017ab004e37c9750b6ba684499510d89 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Tue, 5 Nov 2024 11:29:22 +0100 Subject: [PATCH 09/11] Clean up example a bit --- packages/examples/packages/ledger/README.md | 32 ++----------------- .../packages/ledger/src/index.test.ts | 13 -------- 2 files changed, 3 insertions(+), 42 deletions(-) delete mode 100644 packages/examples/packages/ledger/src/index.test.ts diff --git a/packages/examples/packages/ledger/README.md b/packages/examples/packages/ledger/README.md index a740b83472..46431b550d 100644 --- a/packages/examples/packages/ledger/README.md +++ b/packages/examples/packages/ledger/README.md @@ -1,30 +1,4 @@ -# `@metamask/lifecylce-hooks-example-snap` +# `@metamask/ledger-example-snap` -This snap demonstrates how to use the `onInstall` and `onUpdate` lifecycle -hooks. - -## Snap manifest - -> **Note**: Using lifecycle hooks requires the `endowment:lifecycle-hooks` -> permissions. Refer to [the documentation](https://docs.metamask.io/snaps/reference/permissions/#endowmentlifecycle-hooks) -> for more information. - -Along with other permissions, the manifest of this snap includes the -`endowment:lifecycle-hooks` permission: - -```json -{ - "initialPermissions": { - "endowment:lifecycle-hooks": {} - } -} -``` - -## Snap usage - -This snap exposes the `onInstall` and `onUpdate` lifecycle hooks. These hooks -are called when the snap is installed or updated, respectively, and cannot be -called manually. - -For more information, you can refer to -[the end-to-end tests](./src/index.test.ts). +This Snap demonstrates how to communicate with Ledger hardware wallets using +the Snaps Device API. diff --git a/packages/examples/packages/ledger/src/index.test.ts b/packages/examples/packages/ledger/src/index.test.ts deleted file mode 100644 index 90046df641..0000000000 --- a/packages/examples/packages/ledger/src/index.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, it } from '@jest/globals'; - -describe('onInstall', () => { - // TODO: The Snaps testing framework does not currently support testing the - // onInstall handler. - it.todo('shows a dialog when the snap is installed'); -}); - -describe('onUpdate', () => { - // TODO: The Snaps testing framework does not currently support testing the - // onUpdate handler. - it.todo('shows a dialog when the snap is updated'); -}); From 42ecc2ad4d62332e6330548c3a744f377835580f Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Wed, 6 Nov 2024 11:42:17 +0100 Subject: [PATCH 10/11] Remove bluetooth device type --- packages/snaps-sdk/src/types/device.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/snaps-sdk/src/types/device.ts b/packages/snaps-sdk/src/types/device.ts index 1d21459ddf..5942a9739b 100644 --- a/packages/snaps-sdk/src/types/device.ts +++ b/packages/snaps-sdk/src/types/device.ts @@ -1,20 +1,17 @@ import type { Struct } from '@metamask/superstruct'; -import { literal, refine, string, union } from '@metamask/superstruct'; +import { literal, refine, string } from '@metamask/superstruct'; import type { Describe } from '../internals'; /** - * The type of the device. + * The type of the device. Currently, only `hid` is supported. */ -export type DeviceType = 'hid' | 'bluetooth'; +export type DeviceType = 'hid'; /** * A struct that represents the `DeviceType` type. */ -export const DeviceTypeStruct: Describe = union([ - literal('hid'), - literal('bluetooth'), -]); +export const DeviceTypeStruct: Describe = literal('hid'); /** * The ID of the device. It consists of the type of the device, the vendor ID, From 0f04db97ba8948eda0246be4ee1d694fb7cab617 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Wed, 6 Nov 2024 12:02:55 +0100 Subject: [PATCH 11/11] Update RPC methods and endowment --- .../src/endowments/devices.ts | 4 +- .../src/permitted/listDevices.ts | 76 +++++++++++++++++++ .../src/permitted/readDevice.ts | 7 +- .../src/permitted/requestDevice.ts | 1 + .../src/permitted/writeDevice.ts | 8 +- .../src/types/methods/list-devices.ts | 4 +- 6 files changed, 90 insertions(+), 10 deletions(-) create mode 100644 packages/snaps-rpc-methods/src/permitted/listDevices.ts diff --git a/packages/snaps-rpc-methods/src/endowments/devices.ts b/packages/snaps-rpc-methods/src/endowments/devices.ts index 5e13281769..970cc7aeab 100644 --- a/packages/snaps-rpc-methods/src/endowments/devices.ts +++ b/packages/snaps-rpc-methods/src/endowments/devices.ts @@ -60,7 +60,7 @@ export const devicesEndowmentBuilder = Object.freeze({ * value of the namespaces object itself, as this is handled by the * `PermissionsController` when the permission is requested. * - * @param permission - The permission to get the keyring namespaces from. + * @param permission - The permission to get the device IDs from. * @returns The device IDs, or `null` if the permission does not have a * device IDs caveat. */ @@ -83,7 +83,7 @@ export function getPermittedDeviceIds( } /** - * Validate the cronjob specification values associated with a caveat. + * Validate the device IDs specification values associated with a caveat. * This validates that the value is a non-empty array with valid device * specification objects. * diff --git a/packages/snaps-rpc-methods/src/permitted/listDevices.ts b/packages/snaps-rpc-methods/src/permitted/listDevices.ts new file mode 100644 index 0000000000..eb14e87a6f --- /dev/null +++ b/packages/snaps-rpc-methods/src/permitted/listDevices.ts @@ -0,0 +1,76 @@ +import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; +import type { PermittedHandlerExport } from '@metamask/permission-controller'; +import type { + JsonRpcRequest, + ListDevicesParams, + ListDevicesResult, +} from '@metamask/snaps-sdk'; +import type { InferMatching } from '@metamask/snaps-utils'; +import { array, literal, object, optional, union } from '@metamask/superstruct'; +import { assertStruct, type PendingJsonRpcResponse } from '@metamask/utils'; + +import type { MethodHooksObject } from '../utils'; + +const hookNames: MethodHooksObject = { + listDevices: true, +}; + +export type ListDevicesHooks = { + /** + * A hook to list the available devices. + * + * @param params - The parameters for reading data from the device. + * @returns The data read from the device. + */ + listDevices: (params: ListDevicesParams) => Promise; +}; + +export const listDevicesHandler: PermittedHandlerExport< + ListDevicesHooks, + ListDevicesParams, + ListDevicesResult +> = { + methodNames: ['snap_listDevices'], + implementation: listDevicesImplementation, + hookNames, +}; + +const ListDevicesParametersStruct = object({ + type: optional(union([literal('hid'), array(literal('hid'))])), +}); + +export type ListDevicesParameters = InferMatching< + typeof ListDevicesParametersStruct, + ListDevicesParams +>; + +/** + * Handles the `snap_listDevices` method. + * + * @param request - The JSON-RPC request object. + * @param response - The JSON-RPC response object. + * @param _next - The `json-rpc-engine` "next" callback. Not used by this + * method. + * @param end - The `json-rpc-engine` "end" callback. + * @param hooks - The RPC method hooks. + * @param hooks.listDevices - The function to read data from a device. + * @returns Nothing. + */ +async function listDevicesImplementation( + request: JsonRpcRequest, + response: PendingJsonRpcResponse, + _next: unknown, + end: JsonRpcEngineEndCallback, + { listDevices }: ListDevicesHooks, +): Promise { + const { params } = request; + assertStruct(params, ListDevicesParametersStruct); + + try { + response.result = await listDevices(params); + } catch (error) { + return end(error); + } + + return end(); +} diff --git a/packages/snaps-rpc-methods/src/permitted/readDevice.ts b/packages/snaps-rpc-methods/src/permitted/readDevice.ts index e098e226d1..9d864fc376 100644 --- a/packages/snaps-rpc-methods/src/permitted/readDevice.ts +++ b/packages/snaps-rpc-methods/src/permitted/readDevice.ts @@ -24,9 +24,10 @@ const hookNames: MethodHooksObject = { export type ReadDeviceHooks = { /** - * A hook to request a device. + * A hook to read data from a device. * - * @returns The requested device, or `null` if no device was provided. + * @param params - The parameters for reading data from the device. + * @returns The data read from the device. */ readDevice: (params: ReadDeviceParams) => Promise; }; @@ -62,7 +63,7 @@ export type ReadDeviceParameters = InferMatching< * method. * @param end - The `json-rpc-engine` "end" callback. * @param hooks - The RPC method hooks. - * @param hooks.readDevice - The function to request a device. + * @param hooks.readDevice - The function to read data from a device. * @returns Nothing. */ async function readDeviceImplementation( diff --git a/packages/snaps-rpc-methods/src/permitted/requestDevice.ts b/packages/snaps-rpc-methods/src/permitted/requestDevice.ts index 61080c19e9..40ee9b6218 100644 --- a/packages/snaps-rpc-methods/src/permitted/requestDevice.ts +++ b/packages/snaps-rpc-methods/src/permitted/requestDevice.ts @@ -20,6 +20,7 @@ export type RequestDeviceHooks = { /** * A hook to request a device. * + * @param params - The parameters for requesting a device. * @returns The requested device, or `null` if no device was provided. */ requestDevice: (params: RequestDeviceParams) => Promise; diff --git a/packages/snaps-rpc-methods/src/permitted/writeDevice.ts b/packages/snaps-rpc-methods/src/permitted/writeDevice.ts index f0ac05a474..3058f0a3a1 100644 --- a/packages/snaps-rpc-methods/src/permitted/writeDevice.ts +++ b/packages/snaps-rpc-methods/src/permitted/writeDevice.ts @@ -22,9 +22,11 @@ const hookNames: MethodHooksObject = { export type WriteDeviceHooks = { /** - * A hook to request a device. + * A hook to write data to a device. * - * @returns The requested device, or `null` if no device was provided. + * @param params - The parameters for writing data to the device. + * @returns A promise that resolves when the data has been written to the + * device. */ writeDevice: (params: WriteDeviceParams) => Promise; }; @@ -60,7 +62,7 @@ export type WriteDeviceParameters = InferMatching< * method. * @param end - The `json-rpc-engine` "end" callback. * @param hooks - The RPC method hooks. - * @param hooks.writeDevice - The function to request a device. + * @param hooks.writeDevice - The function to write data to a device. * @returns Nothing. */ async function writeDeviceImplementation( diff --git a/packages/snaps-sdk/src/types/methods/list-devices.ts b/packages/snaps-sdk/src/types/methods/list-devices.ts index 3668e0c963..6ccdf8bb57 100644 --- a/packages/snaps-sdk/src/types/methods/list-devices.ts +++ b/packages/snaps-sdk/src/types/methods/list-devices.ts @@ -5,9 +5,9 @@ import type { Device, DeviceType } from '../device'; */ export type ListDevicesParams = { /** - * The type of the device to list. If not provided, all devices are listed. + * The type(s) of the device to list. If not provided, all devices are listed. */ - type?: DeviceType; + type?: DeviceType | DeviceType[]; }; /**