diff --git a/packages/examples/packages/browserify-plugin/snap.manifest.json b/packages/examples/packages/browserify-plugin/snap.manifest.json index 5e76ac113b..63862c493a 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": "HEAbfXBUqw5fNP+sJVyvUpXucZy6CwiCFXJi36CEfUw=", + "shasum": "0SVjl4Z8ECRoFFjmcqh9C7PT29LKUT8+Y8DYD7/lzQ8=", "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 be6a71ec6d..5fa8c951c2 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": "mCoDlMSdhDJAXd9zT74ST7jHysifHdQ8r0++b8uPbOs=", + "shasum": "XNNYTsORJu7+K/g/oQlRLpN5RYbOzSY6WRHKWwi4mVg=", "location": { "npm": { "filePath": "dist/bundle.js", 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..720e00537e --- /dev/null +++ b/packages/examples/packages/ledger/CHANGELOG.md @@ -0,0 +1,10 @@ +# 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..46431b550d --- /dev/null +++ b/packages/examples/packages/ledger/README.md @@ -0,0 +1,4 @@ +# `@metamask/ledger-example-snap` + +This Snap demonstrates how to communicate with Ledger hardware wallets using +the Snaps Device API. 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..76dfaeab8f --- /dev/null +++ b/packages/examples/packages/ledger/package.json @@ -0,0 +1,91 @@ +{ + "name": "@metamask/ledger-example-snap", + "version": "0.0.0", + "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-app-eth": "^6.41.0", + "@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..56bfb1ce00 --- /dev/null +++ b/packages/examples/packages/ledger/snap.config.ts @@ -0,0 +1,16 @@ +import type { SnapConfig } from '@metamask/snaps-cli'; + +const config: SnapConfig = { + input: './src/index.tsx', + server: { + port: 8032, + }, + polyfills: { + buffer: true, + }, + 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..793f3dd1c2 --- /dev/null +++ b/packages/examples/packages/ledger/snap.manifest.json @@ -0,0 +1,26 @@ +{ + "version": "0.0.0", + "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" + }, + "source": { + "shasum": "OvH5LaGRY+j5/bDleyl7BHu7ces0k59Wmssm+fU2rfs=", + "location": { + "npm": { + "filePath": "dist/bundle.js", + "packageName": "@metamask/ledger-example-snap", + "registry": "https://registry.npmjs.org" + } + } + }, + "initialPermissions": { + "endowment:rpc": { + "dapps": true + }, + "snap_dialog": {} + }, + "manifestVersion": "0.1" +} diff --git a/packages/examples/packages/ledger/src/components/ConnectHID.tsx b/packages/examples/packages/ledger/src/components/ConnectHID.tsx new file mode 100644 index 0000000000..bce169292a --- /dev/null +++ b/packages/examples/packages/ledger/src/components/ConnectHID.tsx @@ -0,0 +1,9 @@ +import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; +import { Box, Button, Heading } from '@metamask/snaps-sdk/jsx'; + +export const ConnectHID: SnapComponent = () => ( + + Connect with HID + + +); diff --git a/packages/examples/packages/ledger/src/components/Unsupported.tsx b/packages/examples/packages/ledger/src/components/Unsupported.tsx new file mode 100644 index 0000000000..78afa354d2 --- /dev/null +++ b/packages/examples/packages/ledger/src/components/Unsupported.tsx @@ -0,0 +1,9 @@ +import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; +import { Box, Heading, Text } from '@metamask/snaps-sdk/jsx'; + +export const Unsupported: SnapComponent = () => ( + + Unsupported + Ledger hardware wallets are not supported in this browser. + +); diff --git a/packages/examples/packages/ledger/src/components/index.ts b/packages/examples/packages/ledger/src/components/index.ts new file mode 100644 index 0000000000..025a22330c --- /dev/null +++ b/packages/examples/packages/ledger/src/components/index.ts @@ -0,0 +1,2 @@ +export * from './ConnectHID'; +export * from './Unsupported'; diff --git a/packages/examples/packages/ledger/src/index.test.tsx b/packages/examples/packages/ledger/src/index.test.tsx new file mode 100644 index 0000000000..636a9fb13e --- /dev/null +++ b/packages/examples/packages/ledger/src/index.test.tsx @@ -0,0 +1,22 @@ +import { describe, expect } from '@jest/globals'; +import { installSnap } from '@metamask/snaps-jest'; + +describe('onRpcRequest', () => { + it('throws an error if the requested method does not exist', async () => { + const { request } = await installSnap(); + + const response = await request({ + method: 'foo', + }); + + expect(response).toRespondWithError({ + code: -32601, + message: 'The method does not exist / is not available.', + stack: expect.any(String), + data: { + method: 'foo', + cause: null, + }, + }); + }); +}); diff --git a/packages/examples/packages/ledger/src/index.tsx b/packages/examples/packages/ledger/src/index.tsx new file mode 100644 index 0000000000..b951fe869e --- /dev/null +++ b/packages/examples/packages/ledger/src/index.tsx @@ -0,0 +1,99 @@ +import Eth from '@ledgerhq/hw-app-eth'; +import type { + OnRpcRequestHandler, + OnUserInputHandler, +} from '@metamask/snaps-sdk'; +import { MethodNotFoundError } from '@metamask/snaps-sdk'; +import { Box, Button, Text, Copyable } from '@metamask/snaps-sdk/jsx'; +import { bytesToHex, stringToBytes } from '@metamask/utils'; + +import { ConnectHID, Unsupported } from './components'; +import TransportSnapsHID from './transport'; +import { signatureToHex } from './utils'; + +/** + * Handle incoming JSON-RPC requests from the dapp, sent through the + * `wallet_invokeSnap` method. This handler handles one method: + * + * - `request`: Display a dialog with a button to request a Ledger device. This + * demonstrates how to request a device using Snaps, and how to handle user + * input events, in order to sign a message with the device. + * + * Note that this only works in browsers that support the WebHID API, and + * the Ledger device must be connected and unlocked. + * + * @param params - The request parameters. + * @param params.request - The JSON-RPC request object. + * @returns The JSON-RPC response. + * @see https://docs.metamask.io/snaps/reference/exports/#onrpcrequest + */ +export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { + switch (request.method) { + case 'request': { + const Component = (await TransportSnapsHID.isSupported()) + ? ConnectHID + : Unsupported; + + return snap.request({ + method: 'snap_dialog', + params: { + content: , + }, + }); + } + + default: + throw new MethodNotFoundError({ + method: request.method, + }); + } +}; + +/** + * Handle incoming user events coming from the Snap interface. This handler + * handles one event: + * + * - `connect-hid`: Request a Ledger device, sign a message, and display the + * signature in the Snap interface. + * + * @param params - The event parameters. + * @param params.id - The Snap interface ID where the event was fired. + * @see https://docs.metamask.io/snaps/reference/exports/#onuserinput + */ +export const onUserInput: OnUserInputHandler = async ({ id }) => { + // TODO: Handle errors (i.e., Ledger locked, disconnected, etc.) + const transport = await TransportSnapsHID.request(); + const eth = new Eth(transport); + + // TODO: Make this message configurable. + const message = 'test'; + const { address } = await eth.getAddress("44'/60'/0'/0/0"); + + const signature = await eth.signPersonalMessage( + "44'/60'/0'/0/0", + bytesToHex(stringToBytes(message)), + ); + + const signatureHex = signatureToHex(signature); + const signatureObject = { + address, + message, + signature: signatureHex, + }; + + await snap.request({ + method: 'snap_updateInterface', + params: { + id, + ui: ( + + + Signature: + + JSON: + + + ), + }, + }); +}; diff --git a/packages/examples/packages/ledger/src/transport.ts b/packages/examples/packages/ledger/src/transport.ts new file mode 100644 index 0000000000..4284094164 --- /dev/null +++ b/packages/examples/packages/ledger/src/transport.ts @@ -0,0 +1,260 @@ +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 { HidDeviceMetadata } from '@metamask/snaps-sdk'; +import { DeviceType } 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 HidDeviceMetadata; +} + +export default class TransportSnapsHID extends Transport { + /** + * The device metadata. + */ + readonly device: HidDeviceMetadata; + + /** + * The device model, if known. + */ + readonly deviceModel: DeviceModel | null | undefined; + + /** + * A random channel to use for communication with the device. + */ + #channel = Math.floor(Math.random() * 0xffff); + + /** + * The packet size to use for communication with the device. + */ + #packetSize = 64; + + constructor(device: HidDeviceMetadata) { + 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(DeviceType.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 HidDeviceMetadata[]; + + 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: HidDeviceMetadata) { + 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: HidDeviceMetadata) { + 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.slice(2), 'hex'); + accumulator = framing.reduceResponse(accumulator, buffer); + } + + return result; + }); + }; + + /** + * Set the scramble key for the transport. + * + * This is not supported by the Snaps transport. + */ + setScrambleKey() { + // This transport does not support setting a scramble key. + } +} diff --git a/packages/examples/packages/ledger/src/utils.ts b/packages/examples/packages/ledger/src/utils.ts new file mode 100644 index 0000000000..7984574ffd --- /dev/null +++ b/packages/examples/packages/ledger/src/utils.ts @@ -0,0 +1,15 @@ +/** + * Create a hexadecimal encoded signature from a signature object. + * + * @param signature - The signature object. + * @param signature.r - The `r` value of the signature. + * @param signature.s - The `s` value of the signature. + * @param signature.v - The `v` value of the signature. + * @returns The hexadecimal encoded signature. + */ +export function signatureToHex(signature: { r: string; s: string; v: number }) { + const adjustedV = signature.v - 27; + const hexV = adjustedV.toString(16).padStart(2, '0'); + + return `0x${signature.r}${signature.s}${hexV}`; +} 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-controllers/package.json b/packages/snaps-controllers/package.json index 3cf86b631f..5f7ba07918 100644 --- a/packages/snaps-controllers/package.json +++ b/packages/snaps-controllers/package.json @@ -93,7 +93,9 @@ "@metamask/snaps-sdk": "workspace:^", "@metamask/snaps-utils": "workspace:^", "@metamask/utils": "^9.2.1", + "@types/w3c-web-hid": "^1.0.6", "@xstate/fsm": "^2.0.0", + "async-mutex": "^0.4.0", "browserify-zlib": "^0.2.0", "concat-stream": "^2.0.0", "fast-deep-equal": "^3.1.3", diff --git a/packages/snaps-controllers/src/devices/DeviceController.test.ts b/packages/snaps-controllers/src/devices/DeviceController.test.ts new file mode 100644 index 0000000000..e9789f31b7 --- /dev/null +++ b/packages/snaps-controllers/src/devices/DeviceController.test.ts @@ -0,0 +1,79 @@ +import { MOCK_SNAP_ID } from '@metamask/snaps-utils/test-utils'; +import { bytesToHex } from '@metamask/utils'; + +import { + getRestrictedDeviceControllerMessenger, + MOCK_DEVICE_ID, +} from '../test-utils'; +import { DeviceController } from './DeviceController'; + +/** + * Mock the navigator object to return a mock HID device. + * + * @returns The mock navigator object and the mock HID device. + */ +function mockNavigator() { + const mockDevice = { + vendorId: 11415, + productId: 4117, + productName: 'Nano S', + open: jest.fn(), + sendReport: jest.fn(), + addEventListener: jest.fn().mockImplementation((_type, callback) => { + const array = new Uint8Array([10, 11, 12, 13, 14, 15]); + const data = new DataView(array.buffer); + callback({ reportId: 0, data }); + }), + }; + + const navigatorMock = { + hid: { + requestDevice: jest.fn().mockResolvedValue([mockDevice]), + getDevices: jest.fn().mockResolvedValue([mockDevice]), + }, + }; + + Object.defineProperty(globalThis, 'navigator', { value: navigatorMock }); + + return { hid: navigatorMock, device: mockDevice }; +} + +describe('DeviceController', () => { + it('can request a device and use read/write', async () => { + const { device } = mockNavigator(); + const messenger = getRestrictedDeviceControllerMessenger(); + + // eslint-disable-next-line no-new + new DeviceController({ messenger }); + + const pairingPromise = messenger.call( + 'DeviceController:requestDevice', + MOCK_SNAP_ID, + { type: 'hid' }, + ); + + messenger.call('DeviceController:resolvePairing', MOCK_DEVICE_ID); + + const { id: deviceId } = await pairingPromise; + + const array = new Uint8Array([1, 2, 3, 4]); + + await messenger.call('DeviceController:writeDevice', MOCK_SNAP_ID, { + type: 'hid', + id: deviceId, + data: bytesToHex(array), + }); + + expect(device.sendReport).toHaveBeenCalledWith(0, array); + + const data = await messenger.call( + 'DeviceController:readDevice', + MOCK_SNAP_ID, + { type: 'hid', id: deviceId }, + ); + + expect(data).toStrictEqual( + bytesToHex(new Uint8Array([10, 11, 12, 13, 14, 15])), + ); + }); +}); diff --git a/packages/snaps-controllers/src/devices/DeviceController.ts b/packages/snaps-controllers/src/devices/DeviceController.ts new file mode 100644 index 0000000000..72fd5165a1 --- /dev/null +++ b/packages/snaps-controllers/src/devices/DeviceController.ts @@ -0,0 +1,420 @@ +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + RestrictedControllerMessenger, +} from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import type { + GetPermissions, + GrantPermissionsIncremental, +} from '@metamask/permission-controller'; +import { rpcErrors } from '@metamask/rpc-errors'; +import { + getPermittedDeviceIds, + SnapCaveatType, + SnapEndowments, +} from '@metamask/snaps-rpc-methods'; +import type { + DeviceMetadata, + DeviceFilter, + DeviceId, + ListDevicesParams, + ReadDeviceParams, + RequestDeviceParams, + SnapId, + WriteDeviceParams, +} from '@metamask/snaps-sdk'; +import { DeviceType } from '@metamask/snaps-sdk'; +import { logError } from '@metamask/snaps-utils'; +import { assert, createDeferredPromise, hasProperty } from '@metamask/utils'; + +import { HIDManager } from './implementations'; +import type { Device, DeviceManager } from './implementations'; + +const controllerName = 'DeviceController'; + +export type DeviceControllerAllowedActions = + | GetPermissions + | GrantPermissionsIncremental; + +export type DeviceControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + DeviceControllerState +>; + +export type DeviceControllerRequestDeviceAction = { + type: `${typeof controllerName}:requestDevice`; + handler: DeviceController['requestDevice']; +}; + +export type DeviceControllerListDevicesAction = { + type: `${typeof controllerName}:listDevices`; + handler: DeviceController['listDevices']; +}; + +export type DeviceControllerReadDeviceAction = { + type: `${typeof controllerName}:readDevice`; + handler: DeviceController['readDevice']; +}; + +export type DeviceControllerWriteDeviceAction = { + type: `${typeof controllerName}:writeDevice`; + handler: DeviceController['writeDevice']; +}; + +export type DeviceControllerResolvePairingAction = { + type: `${typeof controllerName}:resolvePairing`; + handler: DeviceController['resolvePairing']; +}; + +export type DeviceControllerRejectPairingAction = { + type: `${typeof controllerName}:rejectPairing`; + handler: DeviceController['rejectPairing']; +}; + +export type DeviceControllerActions = + | DeviceControllerGetStateAction + | DeviceControllerResolvePairingAction + | DeviceControllerRejectPairingAction + | DeviceControllerRequestDeviceAction + | DeviceControllerListDevicesAction + | DeviceControllerWriteDeviceAction + | DeviceControllerReadDeviceAction; + +export type DeviceControllerStateChangeEvent = ControllerStateChangeEvent< + typeof controllerName, + DeviceControllerState +>; + +export type DeviceControllerEvents = DeviceControllerStateChangeEvent; + +export type DeviceControllerAllowedEvents = never; + +export type DeviceControllerMessenger = RestrictedControllerMessenger< + typeof controllerName, + DeviceControllerActions | DeviceControllerAllowedActions, + DeviceControllerEvents | DeviceControllerAllowedEvents, + DeviceControllerAllowedActions['type'], + DeviceControllerAllowedEvents['type'] +>; + +export type DeviceControllerState = { + devices: Record; + pairing: { + snapId: string; + type: DeviceType; + filters?: DeviceFilter[]; + } | null; +}; + +export type DeviceControllerArgs = { + messenger: DeviceControllerMessenger; + state?: DeviceControllerState; +}; +/** + * Controller for managing access to devices for Snaps. + */ +export class DeviceController extends BaseController< + typeof controllerName, + DeviceControllerState, + DeviceControllerMessenger +> { + #pairing?: { + promise: Promise; + resolve: (result: DeviceId) => void; + reject: (error: unknown) => void; + }; + + #managers: Record = { + [DeviceType.HID]: new HIDManager(), + }; + + #devices: Record = {}; + + constructor({ messenger, state }: DeviceControllerArgs) { + super({ + messenger, + metadata: { + devices: { persist: true, anonymous: false }, + pairing: { persist: false, anonymous: false }, + }, + name: controllerName, + state: { devices: {}, pairing: null, ...state }, + }); + + this.messagingSystem.registerActionHandler( + `${controllerName}:requestDevice`, + async (...args) => this.requestDevice(...args), + ); + + this.messagingSystem.registerActionHandler( + `${controllerName}:listDevices`, + async (...args) => this.listDevices(...args), + ); + + this.messagingSystem.registerActionHandler( + `${controllerName}:writeDevice`, + async (...args) => this.writeDevice(...args), + ); + + this.messagingSystem.registerActionHandler( + `${controllerName}:readDevice`, + async (...args) => this.readDevice(...args), + ); + + this.messagingSystem.registerActionHandler( + `${controllerName}:resolvePairing`, + async (...args) => this.resolvePairing(...args), + ); + + this.messagingSystem.registerActionHandler( + `${controllerName}:rejectPairing`, + async (...args) => this.rejectPairing(...args), + ); + + for (const manager of Object.values(this.#managers)) { + manager.on('connect', (device) => { + this.#addDevice(device); + }); + + manager.on('disconnect', (id) => { + this.#removeDevice(id); + }); + + this.#synchronize(manager).catch((error) => { + logError('Failed to synchronize device manager.', error); + }); + } + } + + async requestDevice(snapId: string, { type, filters }: RequestDeviceParams) { + const deviceId = await this.#requestPairing({ + snapId, + type: type as DeviceType, + filters, + }); + + // TODO: Figure out how to revoke these permissions again? + this.messagingSystem.call( + 'PermissionController:grantPermissionsIncremental', + { + subject: { origin: snapId }, + approvedPermissions: { + // TODO: Consider this format + [SnapEndowments.Devices]: { + caveats: [ + { + type: SnapCaveatType.DeviceIds, + value: { devices: [{ deviceId }] }, + }, + ], + }, + }, + }, + ); + + await this.#synchronize(this.#managers[type]); + + // TODO: Can a paired device be not connected? + return this.state.devices[deviceId]; + } + + async writeDevice(snapId: SnapId, params: WriteDeviceParams) { + const { id } = params; + if (!this.#hasPermission(snapId, id)) { + // TODO: Decide on error message + throw rpcErrors.invalidParams(); + } + + const device = this.#devices[id]; + assert(device, 'Device not found.'); + + await this.#openDevice(id); + await device.write(params); + + return null; + } + + async readDevice(snapId: SnapId, params: ReadDeviceParams) { + const { id } = params; + if (!this.#hasPermission(snapId, id)) { + // TODO: Decide on error message + throw rpcErrors.invalidParams(); + } + + const device = this.#devices[id]; + assert(device, 'Device not found.'); + + await this.#openDevice(id); + return await device.read(params); + } + + async listDevices(snapId: SnapId, { type }: ListDevicesParams) { + const permittedDevices = this.#getPermittedDevices(snapId); + const deviceData = permittedDevices.map( + (device) => this.state.devices[device.deviceId], + ); + + if (type) { + const types = Array.isArray(type) ? type : [type]; + return deviceData.filter((device) => types.includes(device.type)); + } + + return deviceData; + } + + #getPermittedDevices(snapId: SnapId) { + const permissions = this.messagingSystem.call( + 'PermissionController:getPermissions', + snapId, + ); + + if (!permissions || !hasProperty(permissions, SnapEndowments.Devices)) { + return []; + } + + const permission = permissions[SnapEndowments.Devices]; + const devices = getPermittedDeviceIds(permission); + return devices ?? []; + } + + #hasPermission(snapId: SnapId, deviceId: DeviceId) { + const devices = this.#getPermittedDevices(snapId); + return devices.some( + (permittedDevice) => permittedDevice.deviceId === deviceId, + ); + } + + #isPairing() { + return this.#pairing !== undefined; + } + + async #requestPairing({ + snapId, + type, + filters, + }: { + snapId: string; + type: DeviceType; + filters?: DeviceFilter[]; + }) { + if (this.#isPairing()) { + // TODO: Potentially await existing pairing flow? + throw new Error('A pairing is already underway.'); + } + + const { promise, resolve, reject } = createDeferredPromise(); + + this.#pairing = { promise, resolve, reject }; + + this.update((draftState) => { + draftState.pairing = { snapId, type, filters }; + }); + + return promise; + } + + resolvePairing(deviceId: DeviceId) { + if (!this.#isPairing()) { + return; + } + + this.#pairing?.resolve(deviceId); + this.#pairing = undefined; + this.update((draftState) => { + draftState.pairing = null; + }); + } + + rejectPairing() { + if (!this.#isPairing()) { + return; + } + + this.#pairing?.reject(new Error('Pairing rejected')); + this.#pairing = undefined; + this.update((draftState) => { + draftState.pairing = null; + }); + } + + /** + * Open a device, and set a timeout to close it if it is not used. + * + * @param id - The ID of the device to open. + * @returns A promise that resolves when the device is opened. + */ + async #openDevice(id: DeviceId) { + const device = this.#devices[id]; + assert(device, 'Device not found.'); + + await device.open(); + } + + /** + * Close a device. + * + * @param id - The ID of the device to close. + * @returns A promise that resolves when the device is closed. + */ + async #closeDevice(id: DeviceId) { + const device = this.#devices[id]; + assert(device, 'Device not found.'); + + await device.close(); + } + + /** + * Synchronize the state of the controller with the state of the device + * manager. + * + * @param manager - The device manager to synchronize with. + */ + async #synchronize(manager: DeviceManager) { + const metadata = await manager.getDeviceMetadata(); + for (const device of metadata) { + if (!this.state.devices[device.id]) { + this.update((draftState) => { + draftState.devices[device.id] = device; + }); + } + + if (!this.#devices[device.id]) { + const deviceImplementation = await manager.getDevice(device.id); + if (deviceImplementation) { + this.#addDevice(deviceImplementation); + } + } + } + } + + /** + * Add a device to the controller. + * + * @param device - The device to add. + */ + #addDevice(device: Device) { + this.#devices[device.id] = device; + + if (this.state.devices[device.id]) { + this.update((draftState) => { + draftState.devices[device.id].available = true; + }); + } + } + + /** + * Remove a device from the controller. + * + * @param id - The ID of the device to remove. + */ + #removeDevice(id: DeviceId) { + delete this.#devices[id]; + + if (this.state.devices[id]) { + this.update((draftState) => { + draftState.devices[id].available = false; + }); + } + } +} diff --git a/packages/snaps-controllers/src/devices/constants.ts b/packages/snaps-controllers/src/devices/constants.ts new file mode 100644 index 0000000000..ee4fba99e3 --- /dev/null +++ b/packages/snaps-controllers/src/devices/constants.ts @@ -0,0 +1,3 @@ +import { Duration, inMilliseconds } from '@metamask/utils'; + +export const CLOSE_DEVICE_TIMEOUT = inMilliseconds(5, Duration.Minute); diff --git a/packages/snaps-controllers/src/devices/implementations/device-manager.ts b/packages/snaps-controllers/src/devices/implementations/device-manager.ts new file mode 100644 index 0000000000..eef33a6766 --- /dev/null +++ b/packages/snaps-controllers/src/devices/implementations/device-manager.ts @@ -0,0 +1,40 @@ +import type { DeviceMetadata, DeviceId } from '@metamask/snaps-sdk'; + +import { TypedEventEmitter } from '../../types'; +import type { Device } from './device'; + +/** + * The events that a `DeviceManager` can emit. + */ +export type DeviceManagerEvents = { + /** + * Emitted when a device is connected. + * + * @param device - The device that is connected. + */ + connect: (device: Device) => void; + + /** + * Emitted when a device is disconnected. + * + * @param deviceId - The ID of the device that is disconnected. + */ + disconnect: (deviceId: DeviceId) => void; +}; + +// This is an abstract class to allow for extending `TypedEventEmitter`. +export abstract class DeviceManager extends TypedEventEmitter { + /** + * Synchronize the state with the current devices. This returns the current + * list of devices. + */ + abstract getDeviceMetadata(): Promise; + + /** + * Get a device by its ID. + * + * @param deviceId - The ID of the device to get. + * @returns The device, or `undefined` if the device is not found. + */ + abstract getDevice(deviceId: DeviceId): Promise; +} diff --git a/packages/snaps-controllers/src/devices/implementations/device.ts b/packages/snaps-controllers/src/devices/implementations/device.ts new file mode 100644 index 0000000000..a509215774 --- /dev/null +++ b/packages/snaps-controllers/src/devices/implementations/device.ts @@ -0,0 +1,130 @@ +import type { + DeviceId, + DeviceType, + ReadDeviceParams, + ReadDeviceResult, + WriteDeviceParams, +} from '@metamask/snaps-sdk'; +import { logError } from '@metamask/snaps-utils'; +import type { Hex } from '@metamask/utils'; +import { Mutex } from 'async-mutex'; + +import { TypedEventEmitter } from '../../types'; +import { CLOSE_DEVICE_TIMEOUT } from '../constants'; + +/** + * The events that a `Device` can emit. + */ +export type DeviceEvents = { + /** + * Emitted when data is read from the device. + * + * @param data - The data read from the device. + */ + data: (data: Hex) => void; +}; + +/** + * An abstract class that represents a device that is available to the Snap. + */ +export abstract class Device extends TypedEventEmitter { + /** + * The device type. + */ + abstract readonly type: DeviceType; + + /** + * The device ID. + */ + abstract readonly id: DeviceId; + + /** + * A timeout to close the device after a certain amount of time. + */ + #timeout: NodeJS.Timeout | null = null; + + protected constructor() { + super(); + + this.open = this.#withTimeout(this.open.bind(this), CLOSE_DEVICE_TIMEOUT); + this.read = this.#withMutex(this.read.bind(this)); + this.write = this.#withMutex(this.write.bind(this)); + } + + /** + * Read data from the device. + * + * @param params - The arguments to pass to the device. + * @returns The data read from the device. + */ + abstract read(params: ReadDeviceParams): Promise; + + /** + * Write data to the device. + * + * @param params - The arguments to pass to the device. + */ + abstract write(params: WriteDeviceParams): Promise; + + /** + * Open the connection to the device. This must be called before any read or + * write operations. + */ + abstract open(): Promise; + + /** + * Close the connection to the device. This should be called when the device + * is no longer needed, and may be called after a timeout. + */ + abstract close(): Promise; + + /** + * Run a function with an async mutex, ensuring that only one instance of the + * function can run at a time. + * + * @param fn - The function to run with a mutex. + * @returns The wrapped function. + * @template OriginalFunction - The original function type. This is inferred + * from the `fn` argument, and used to determine the return type of the + * wrapped function. + */ + #withMutex Promise, Type>( + fn: OriginalFunction, + ): (...args: Parameters) => Promise { + const mutex = new Mutex(); + + return async (...args: Parameters) => { + return await mutex.runExclusive(async () => await fn(...args)); + }; + } + + /** + * Run a function with a timeout, ensuring that the device is closed after a + * certain amount of time. + * + * @param fn - The function to run with a timeout. + * @param timeout - The timeout in milliseconds. + * @returns The wrapped function. + */ + #withTimeout< + OriginalFunction extends (...args: any[]) => Promise, + Type, + >( + fn: OriginalFunction, + timeout: number, + ): (...args: Parameters) => Promise { + return async (...args: Parameters) => { + if (this.#timeout) { + clearTimeout(this.#timeout); + } + + this.#timeout = setTimeout(() => { + this.close().catch((error) => { + logError('Failed to close device.', error); + }); + }, timeout); + + return await fn(...args); + }; + } +} diff --git a/packages/snaps-controllers/src/devices/implementations/hid-manager.ts b/packages/snaps-controllers/src/devices/implementations/hid-manager.ts new file mode 100644 index 0000000000..a5c81c3e09 --- /dev/null +++ b/packages/snaps-controllers/src/devices/implementations/hid-manager.ts @@ -0,0 +1,69 @@ +import type { DeviceId } from '@metamask/snaps-sdk'; +import { DeviceType } from '@metamask/snaps-sdk'; + +import { DeviceManager } from './device-manager'; +import { HIDSnapDevice } from './hid'; + +/** + * Get the device ID for an HID device, based on its vendor and product IDs. + * + * @param device - The HID device. + * @returns The device ID. + */ +function getDeviceId(device: HIDDevice): DeviceId { + return `${DeviceType.HID}:${device.vendorId.toString( + 16, + )}:${device.productId.toString(16)}`; +} + +/** + * A manager for HID devices. + */ +export class HIDManager extends DeviceManager { + constructor() { + super(); + + navigator.hid.addEventListener('connect', (event) => { + const device = new HIDSnapDevice(getDeviceId(event.device), event.device); + this.emit('connect', device); + }); + + navigator.hid.addEventListener('disconnect', (event) => { + this.emit('disconnect', getDeviceId(event.device)); + }); + } + + /** + * Get the device IDs for the currently connected HID devices. + * + * @returns The device IDs. + */ + async getDeviceMetadata() { + const devices = await navigator.hid.getDevices(); + return devices.map((device) => ({ + type: DeviceType.HID, + id: getDeviceId(device), + name: device.productName, + vendorId: device.vendorId, + productId: device.productId, + available: true, + })); + } + + /** + * Get a device by its ID. + * + * @param deviceId - The ID of the device to get. + * @returns The device, or `undefined` if the device is not found. + */ + async getDevice(deviceId: DeviceId) { + const devices = await navigator.hid.getDevices(); + const device = devices.find((item) => getDeviceId(item) === deviceId); + + if (device) { + return new HIDSnapDevice(deviceId, device); + } + + return undefined; + } +} diff --git a/packages/snaps-controllers/src/devices/implementations/hid.ts b/packages/snaps-controllers/src/devices/implementations/hid.ts new file mode 100644 index 0000000000..309f10b246 --- /dev/null +++ b/packages/snaps-controllers/src/devices/implementations/hid.ts @@ -0,0 +1,138 @@ +import { DeviceType } from '@metamask/snaps-sdk'; +import type { + ReadDeviceParams, + ScopedDeviceId, + WriteDeviceParams, +} from '@metamask/snaps-sdk'; +import type { Hex } from '@metamask/utils'; +import { hexToBytes, add0x, assert } from '@metamask/utils'; + +import { Device } from './device'; + +/** + * A device that is connected to the Snap via HID. + */ +export class HIDSnapDevice extends Device { + /** + * The device type. Always `hid`. + */ + readonly type = DeviceType.HID; + + /** + * The device ID. + */ + readonly id: ScopedDeviceId; + + /** + * The underlying `HIDDevice` instance. + */ + readonly #device: HIDDevice; + + /** + * A buffer to store incoming data. + */ + #buffer: { reportId: number; data: Hex }[] = []; + + constructor(id: ScopedDeviceId, device: HIDDevice) { + super(); + + this.id = id; + this.#device = device; + + device.addEventListener('inputreport', (event: HIDInputReportEvent) => { + const data = add0x(Buffer.from(event.data.buffer).toString('hex')); + + const result = { + reportId: event.reportId, + data, + }; + + this.#buffer.push(result); + + // TODO: Emit `reportId` as well? + this.emit('data', result.data); + }); + } + + /** + * Read data from the device. + * + * @param params - The arguments. + * @param params.type - The type of the device. + * @param params.reportType - The type of report to read. Defaults to + * `output`. + * @param params.reportId - The ID of the report to read. Defaults to `0`. + * @returns The data read from the device. + */ + async read({ type, reportType = 'output', reportId = 0 }: ReadDeviceParams) { + assert(type === this.type); + assert(this.#device.opened, 'Device is not open.'); + + if (reportType === 'feature') { + const view = await this.#device.receiveFeatureReport(reportId); + return add0x(Buffer.from(view.buffer).toString('hex')); + } + + return new Promise((resolve) => { + const buffer = this.#buffer.shift(); + if (buffer) { + return resolve(buffer.data); + } + + return this.once('data', () => { + const data = this.#buffer.shift(); + assert(data, 'Expected data to be present in the read buffer.'); + + resolve(data.data); + }); + }); + } + + /** + * Write data to the device. + * + * @param params - The arguments. + * @param params.type - The type of the device. + * @param params.reportType - The type of report to write. Defaults to + * `output`. + * @param params.reportId - The ID of the report to write. Defaults to `0`. + * @param params.data - The data to write to the device. + * @returns The result of the write operation. + */ + async write({ + type, + reportType = 'output', + reportId = 0, + data, + }: WriteDeviceParams) { + assert(type === this.type); + assert(this.#device.opened, 'Device is not open.'); + + const buffer = hexToBytes(data); + if (reportType === 'feature') { + return await this.#device.sendFeatureReport(reportId, buffer); + } + + return await this.#device.sendReport(reportId, buffer); + } + + /** + * Open the connection to the device. + */ + async open() { + if (!this.#device.opened) { + this.#buffer = []; + await this.#device.open(); + } + } + + /** + * Close the connection to the device. + */ + async close() { + if (this.#device.opened) { + this.#buffer = []; + await this.#device.close(); + } + } +} diff --git a/packages/snaps-controllers/src/devices/implementations/index.ts b/packages/snaps-controllers/src/devices/implementations/index.ts new file mode 100644 index 0000000000..2cc54ff8be --- /dev/null +++ b/packages/snaps-controllers/src/devices/implementations/index.ts @@ -0,0 +1,4 @@ +export { Device } from './device'; +export { DeviceManager } from './device-manager'; +export { HIDSnapDevice } from './hid'; +export { HIDManager } from './hid-manager'; diff --git a/packages/snaps-controllers/src/devices/index.ts b/packages/snaps-controllers/src/devices/index.ts new file mode 100644 index 0000000000..1e49eb0e35 --- /dev/null +++ b/packages/snaps-controllers/src/devices/index.ts @@ -0,0 +1 @@ +export * from './DeviceController'; diff --git a/packages/snaps-controllers/src/index.ts b/packages/snaps-controllers/src/index.ts index 46f68a7381..daeabcbf83 100644 --- a/packages/snaps-controllers/src/index.ts +++ b/packages/snaps-controllers/src/index.ts @@ -5,3 +5,4 @@ export * from './utils'; export * from './cronjob'; export * from './interface'; export * from './insights'; +export * from './devices'; diff --git a/packages/snaps-controllers/src/test-utils/controller.ts b/packages/snaps-controllers/src/test-utils/controller.ts index bcb7e27708..64d8b44783 100644 --- a/packages/snaps-controllers/src/test-utils/controller.ts +++ b/packages/snaps-controllers/src/test-utils/controller.ts @@ -38,6 +38,12 @@ import type { CronjobControllerActions, CronjobControllerEvents, } from '../cronjob'; +import type { + DeviceControllerActions, + DeviceControllerAllowedActions, + DeviceControllerAllowedEvents, + DeviceControllerEvents, +} from '../devices'; import type { SnapInsightsControllerAllowedActions, SnapInsightsControllerAllowedEvents, @@ -59,6 +65,7 @@ import type { import { SnapController } from '../snaps'; import type { KeyDerivationOptions } from '../types'; import { MOCK_CRONJOB_PERMISSION } from './cronjob'; +import { MOCK_DEVICE_PERMISSION } from './devices'; import { getNodeEES, getNodeEESMessenger } from './execution-environment'; import { MockSnapsRegistry } from './registry'; @@ -818,3 +825,55 @@ export const getRestrictedSnapInsightsControllerMessenger = ( return controllerMessenger; }; + +// Mock controller messenger for Device Controller +export const getRootDeviceControllerMessenger = () => { + const messenger = new MockControllerMessenger< + DeviceControllerActions | DeviceControllerAllowedActions, + DeviceControllerEvents | DeviceControllerAllowedEvents + >(); + + jest.spyOn(messenger, 'call'); + + return messenger; +}; + +export const getRestrictedDeviceControllerMessenger = ( + messenger: ReturnType< + typeof getRootDeviceControllerMessenger + > = getRootDeviceControllerMessenger(), + mocked = true, +) => { + const controllerMessenger = messenger.getRestricted< + 'DeviceController', + DeviceControllerActions['type'] | DeviceControllerAllowedActions['type'], + DeviceControllerEvents['type'] | DeviceControllerAllowedEvents['type'] + >({ + name: 'DeviceController', + allowedEvents: [], + allowedActions: [ + 'PermissionController:getPermissions', + 'PermissionController:grantPermissionsIncremental', + ], + }); + + if (mocked) { + messenger.registerActionHandler( + 'PermissionController:grantPermissionsIncremental', + () => { + return {}; + }, + ); + + messenger.registerActionHandler( + 'PermissionController:getPermissions', + () => { + return { + [SnapEndowments.Devices]: MOCK_DEVICE_PERMISSION, + }; + }, + ); + } + + return controllerMessenger; +}; diff --git a/packages/snaps-controllers/src/test-utils/devices.ts b/packages/snaps-controllers/src/test-utils/devices.ts new file mode 100644 index 0000000000..6af846b297 --- /dev/null +++ b/packages/snaps-controllers/src/test-utils/devices.ts @@ -0,0 +1,19 @@ +import type { PermissionConstraint } from '@metamask/permission-controller'; +import { SnapEndowments } from '@metamask/snaps-rpc-methods'; +import { SnapCaveatType } from '@metamask/snaps-utils'; +import { MOCK_SNAP_ID } from '@metamask/snaps-utils/test-utils'; + +export const MOCK_DEVICE_ID = 'hid:11415:4117'; + +export const MOCK_DEVICE_PERMISSION: PermissionConstraint = { + caveats: [ + { + type: SnapCaveatType.DeviceIds, + value: { devices: [{ deviceId: MOCK_DEVICE_ID }] }, + }, + ], + date: 1664187844588, + id: 'izn0WGUO8cvq_jqvLQuQP', + invoker: MOCK_SNAP_ID, + parentCapability: SnapEndowments.Devices, +}; diff --git a/packages/snaps-controllers/src/test-utils/index.ts b/packages/snaps-controllers/src/test-utils/index.ts index 91b9d1585a..58a8364857 100644 --- a/packages/snaps-controllers/src/test-utils/index.ts +++ b/packages/snaps-controllers/src/test-utils/index.ts @@ -1,5 +1,6 @@ export * from './confirmations'; export * from './controller'; +export * from './devices'; export * from './execution-environment'; export * from './service'; export * from './sleep'; diff --git a/packages/snaps-controllers/src/types/event-emitter.ts b/packages/snaps-controllers/src/types/event-emitter.ts new file mode 100644 index 0000000000..d60ab9a0c9 --- /dev/null +++ b/packages/snaps-controllers/src/types/event-emitter.ts @@ -0,0 +1,60 @@ +import { EventEmitter } from 'events'; + +/** + * A string or symbol that represents an event name. + */ +type EventKey = string | symbol; + +/** + * A map of event names to listener functions. + */ +export type EventMap = Record void>; + +/** + * An {@link EventEmitter} that is typed to a specific set of events. + * + * @param event + * @param listener + * @template Events - The event map type, i.e., a record of event names to + * listener functions, which is used for typing the events that can be emitted + * and listened to. + * @example + * type MyEvents = { + * foo: (a: number, b: string) => void; + * bar: (c: boolean) => void; + * }; + * + * const emitter: TypedEventEmitter = new EventEmitter(); + * emitter.on('foo', (a, b) => console.log(a, b)); // Has correct types. + */ +export abstract class TypedEventEmitter< + Events extends EventMap, +> extends EventEmitter { + emit( + event: Event extends EventKey ? Event : never, + ...args: Parameters + ): boolean { + return super.emit(event, ...args); + } + + off( + event: Event extends EventKey ? Event : never, + listener: Events[Event], + ): this { + return super.off(event, listener); + } + + on( + event: Event extends EventKey ? Event : never, + listener: Events[Event], + ): this { + return super.on(event, listener); + } + + once( + event: Event extends EventKey ? Event : never, + listener: Events[Event], + ): this { + return super.once(event, listener); + } +} diff --git a/packages/snaps-controllers/src/types/index.ts b/packages/snaps-controllers/src/types/index.ts index a84e64a5c4..aa38c14a3b 100644 --- a/packages/snaps-controllers/src/types/index.ts +++ b/packages/snaps-controllers/src/types/index.ts @@ -1,2 +1,3 @@ export * from './controllers'; export * from './encryptor'; +export * from './event-emitter'; diff --git a/packages/snaps-rpc-methods/src/endowments/devices.test.ts b/packages/snaps-rpc-methods/src/endowments/devices.test.ts new file mode 100644 index 0000000000..96de27d75f --- /dev/null +++ b/packages/snaps-rpc-methods/src/endowments/devices.test.ts @@ -0,0 +1,227 @@ +import type { PermissionConstraint } from '@metamask/permission-controller'; +import { PermissionType, SubjectType } from '@metamask/permission-controller'; +import { SnapCaveatType } from '@metamask/snaps-utils'; + +import { + getPermittedDeviceIds, + devicesEndowmentBuilder, + validateDeviceIdsCaveat, + deviceIdsCaveatSpecifications, +} from './devices'; +import { SnapEndowments } from './enum'; + +describe('endowment:devices', () => { + const specification = devicesEndowmentBuilder.specificationBuilder({}); + + it('builds the expected permission specification', () => { + expect(specification).toStrictEqual({ + permissionType: PermissionType.Endowment, + targetName: SnapEndowments.Devices, + endowmentGetter: expect.any(Function), + allowedCaveats: [SnapCaveatType.DeviceIds], + subjectTypes: [SubjectType.Snap], + validator: expect.any(Function), + }); + + expect(specification.endowmentGetter()).toBeNull(); + }); + + describe('validator', () => { + it('allows no caveats', () => { + expect(() => + // @ts-expect-error Missing required permission types. + specification.validator({}), + ).not.toThrow(); + }); + + it('throws if the caveats are not one or both of "chainIds" and "lookupMatchers".', () => { + expect(() => + // @ts-expect-error Missing other required permission types. + specification.validator({ + caveats: [{ type: 'foo', value: 'bar' }], + }), + ).toThrow('Expected the following caveats: "deviceIds", received "foo".'); + + expect(() => + // @ts-expect-error Missing other required permission types. + specification.validator({ + caveats: [ + { type: 'chainIds', value: ['foo'] }, + { type: 'chainIds', value: ['bar'] }, + ], + }), + ).toThrow('Duplicate caveats are not allowed.'); + }); + }); +}); + +describe('getPermittedDeviceIds', () => { + it('returns the value from the `endowment:devices` permission', () => { + const permission: PermissionConstraint = { + date: 0, + parentCapability: 'foo', + invoker: 'bar', + id: 'baz', + caveats: [ + { + type: SnapCaveatType.DeviceIds, + value: { + devices: [ + { + deviceId: 'hid:123:456', + }, + ], + }, + }, + ], + }; + + expect(getPermittedDeviceIds(permission)).toStrictEqual([ + { + deviceId: 'hid:123:456', + }, + ]); + }); + + it('returns `null` if the input is `undefined`', () => { + expect(getPermittedDeviceIds(undefined)).toBeNull(); + }); + + it('returns `null` if the permission does not have caveats', () => { + const permission: PermissionConstraint = { + date: 0, + parentCapability: 'foo', + invoker: 'bar', + id: 'baz', + caveats: null, + }; + + expect(getPermittedDeviceIds(permission)).toBeNull(); + }); + + it(`returns \`null\` if the caveat doesn't have devices`, () => { + const permission: PermissionConstraint = { + date: 0, + parentCapability: 'foo', + invoker: 'bar', + id: 'baz', + caveats: [ + { + type: SnapCaveatType.DeviceIds, + value: {}, + }, + ], + }; + + expect(getPermittedDeviceIds(permission)).toBeNull(); + }); + + it('throws if the caveat is not a `deviceIds` caveat', () => { + const permission: PermissionConstraint = { + date: 0, + parentCapability: 'foo', + invoker: 'bar', + id: 'baz', + caveats: [ + { + type: SnapCaveatType.ChainIds, + value: 'foo', + }, + ], + }; + + expect(() => getPermittedDeviceIds(permission)).toThrow( + 'Assertion failed.', + ); + }); +}); + +describe('validateDeviceIdsCaveat', () => { + it('throws if the value is not a plain object', () => { + expect(() => + // @ts-expect-error Missing required permission types. + validateDeviceIdsCaveat({}), + ).toThrow('Expected a plain object.'); + }); + + it('throws if the value does not have a `devices` property', () => { + expect(() => + // @ts-expect-error Missing required permission types. + validateDeviceIdsCaveat({ value: {} }), + ).toThrow('Expected a valid device specification array.'); + }); + + it('throws if the `devices` property is not a valid device specification array', () => { + expect(() => + // @ts-expect-error Missing required permission types. + validateDeviceIdsCaveat({ value: { devices: 'foo' } }), + ).toThrow('Expected a valid device specification array.'); + }); +}); + +describe('deviceIdsCaveatSpecifications', () => { + describe('validator', () => { + it('validates the device IDs caveat', () => { + const caveat = { + type: SnapCaveatType.DeviceIds, + value: { + devices: [ + { + deviceId: 'hid:123:456', + }, + ], + }, + }; + + expect(() => + deviceIdsCaveatSpecifications[SnapCaveatType.DeviceIds]?.validator?.( + caveat, + ), + ).not.toThrow(); + }); + }); + + describe('merger', () => { + it('merges the device IDs from two caveats', () => { + const leftValue = { + devices: [ + { + deviceId: 'hid:123:456', + }, + ], + }; + const rightValue = { + devices: [ + { + deviceId: 'hid:789:012', + }, + ], + }; + + expect( + deviceIdsCaveatSpecifications[SnapCaveatType.DeviceIds]?.merger?.( + leftValue, + rightValue, + ), + ).toStrictEqual([ + { + devices: [ + { + deviceId: 'hid:123:456', + }, + { + deviceId: 'hid:789:012', + }, + ], + }, + { + devices: [ + { + deviceId: 'hid:789:012', + }, + ], + }, + ]); + }); + }); +}); 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..6fdd1f0b26 --- /dev/null +++ b/packages/snaps-rpc-methods/src/endowments/devices.ts @@ -0,0 +1,147 @@ +import type { + Caveat, + CaveatSpecificationConstraint, + EndowmentGetterParams, + PermissionConstraint, + PermissionSpecificationBuilder, + PermissionValidatorConstraint, + 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 { createGenericPermissionValidator } from './caveats'; +import { SnapEndowments } from './enum'; + +const permissionName = SnapEndowments.Devices; + +type DevicesEndowmentSpecification = ValidPermissionSpecification<{ + permissionType: PermissionType.Endowment; + targetName: typeof permissionName; + endowmentGetter: (_options?: any) => null; + allowedCaveats: [SnapCaveatType.DeviceIds]; + validator: PermissionValidatorConstraint; +}>; + +/** + * 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. + */ +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], + validator: createGenericPermissionValidator([ + { type: SnapCaveatType.DeviceIds, optional: true }, + ]), + }; +}; + +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 device IDs 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 device IDs 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') || + !isDeviceSpecificationArray(value.devices) + ) { + 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), + merger: (leftValue, rightValue) => { + const leftDevices = leftValue.devices.map( + (device: DeviceSpecification) => device.deviceId, + ); + const rightDevices = rightValue.devices.map( + (device: DeviceSpecification) => device.deviceId, + ); + const newDevices = Array.from( + new Set([...leftDevices, ...rightDevices]), + ).map((deviceId) => ({ deviceId })); + const newValue = { devices: newDevices }; + const diff = { + devices: newDevices.filter( + (value) => !leftDevices.includes(value.deviceId), + ), + }; + return [newValue, diff]; + }, + }), +}; 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..704bd385bc 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, @@ -117,3 +123,4 @@ export { getChainIdsCaveat, getLookupMatchersCaveat } from './name-lookup'; export { getKeyringCaveatOrigins } from './keyring'; export { getMaxRequestTimeCaveat } from './caveats'; export { getCronjobCaveatJobs } from './cronjob'; +export { getPermittedDeviceIds } from './devices'; diff --git a/packages/snaps-rpc-methods/src/permissions.test.ts b/packages/snaps-rpc-methods/src/permissions.test.ts index 50f911b100..397e12f2b2 100644 --- a/packages/snaps-rpc-methods/src/permissions.test.ts +++ b/packages/snaps-rpc-methods/src/permissions.test.ts @@ -19,6 +19,18 @@ describe('buildSnapEndowmentSpecifications', () => { ], "targetName": "endowment:cronjob", }, + "endowment:devices": { + "allowedCaveats": [ + "deviceIds", + ], + "endowmentGetter": [Function], + "permissionType": "Endowment", + "subjectTypes": [ + "snap", + ], + "targetName": "endowment:devices", + "validator": [Function], + }, "endowment:ethereum-provider": { "allowedCaveats": null, "endowmentGetter": [Function], diff --git a/packages/snaps-rpc-methods/src/permitted/getSupportedDevices.ts b/packages/snaps-rpc-methods/src/permitted/getSupportedDevices.ts new file mode 100644 index 0000000000..9217fc6f29 --- /dev/null +++ b/packages/snaps-rpc-methods/src/permitted/getSupportedDevices.ts @@ -0,0 +1,48 @@ +import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; +import type { PermittedHandlerExport } from '@metamask/permission-controller'; +import type { GetSupportedDevicesResult } from '@metamask/snaps-sdk'; +import { DeviceType } from '@metamask/snaps-sdk'; +import type { + JsonRpcParams, + JsonRpcRequest, + PendingJsonRpcResponse, +} from '@metamask/utils'; + +/** + * The `snap_getSupportedDevices` method implementation. + */ +export const getSupportedDevicesHandler: PermittedHandlerExport< + Record, + JsonRpcParams, + GetSupportedDevicesResult +> = { + methodNames: ['snap_getSupportedDevices'], + implementation: getSupportedDevicesImplementation, + hookNames: {}, +}; + +/** + * The `snap_getSupportedDevices` method implementation. + * + * @param _ - The JSON-RPC request object. + * @param response - The JSON-RPC response object. + * @param _next - The `json-rpc-engine` "next" callback. Not used by this + * function. + * @param end - The `json-rpc-engine` "end" callback. + * @returns Nothing. + */ +async function getSupportedDevicesImplementation( + _: JsonRpcRequest, + response: PendingJsonRpcResponse, + _next: unknown, + end: JsonRpcEngineEndCallback, +): Promise { + const deviceTypes: DeviceType[] = []; + + if (navigator?.hid) { + deviceTypes.push(DeviceType.HID); + } + + response.result = deviceTypes; + return end(); +} diff --git a/packages/snaps-rpc-methods/src/permitted/handlers.ts b/packages/snaps-rpc-methods/src/permitted/handlers.ts index 5bfacdacd4..d3d8041c46 100644 --- a/packages/snaps-rpc-methods/src/permitted/handlers.ts +++ b/packages/snaps-rpc-methods/src/permitted/handlers.ts @@ -6,11 +6,15 @@ import { getCurrencyRateHandler } from './getCurrencyRate'; import { getFileHandler } from './getFile'; import { getInterfaceStateHandler } from './getInterfaceState'; import { getSnapsHandler } from './getSnaps'; +import { getSupportedDevicesHandler } from './getSupportedDevices'; 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 = { @@ -27,6 +31,10 @@ export const methodHandlers = { snap_resolveInterface: resolveInterfaceHandler, snap_getCurrencyRate: getCurrencyRateHandler, snap_experimentalProviderRequest: providerRequestHandler, + snap_readDevice: readDeviceHandler, + snap_requestDevice: requestDeviceHandler, + snap_writeDevice: writeDeviceHandler, + snap_getSupportedDevices: getSupportedDevicesHandler, }; /* 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/listDevices.test.ts b/packages/snaps-rpc-methods/src/permitted/listDevices.test.ts new file mode 100644 index 0000000000..b45f9b1930 --- /dev/null +++ b/packages/snaps-rpc-methods/src/permitted/listDevices.test.ts @@ -0,0 +1,104 @@ +import { JsonRpcEngine } from '@metamask/json-rpc-engine'; +import type { ListDevicesParams, ListDevicesResult } from '@metamask/snaps-sdk'; +import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; + +import { listDevicesHandler } from './listDevices'; + +describe('listDevices', () => { + describe('listDevicesHandler', () => { + it('has the expected shape', () => { + expect(listDevicesHandler).toMatchObject({ + methodNames: ['snap_listDevices'], + implementation: expect.any(Function), + hookNames: { + listDevices: true, + }, + }); + }); + }); + + describe('implementation', () => { + it('returns the result of the `listDevices` hook', async () => { + const { implementation } = listDevicesHandler; + + const listDevices = jest.fn().mockImplementation(async () => []); + + const hooks = { + listDevices, + }; + + const engine = new JsonRpcEngine(); + + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_listDevices', + params: { + type: 'hid', + }, + }); + + expect(response).toStrictEqual({ + jsonrpc: '2.0', + id: 1, + result: [], + }); + }); + + it('throws on invalid params', async () => { + const { implementation } = listDevicesHandler; + + const listDevices = jest.fn(); + + const hooks = { + listDevices, + }; + + const engine = new JsonRpcEngine(); + + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_listDevices', + params: { + type: 'bluetooth', + }, + }); + + expect(response).toStrictEqual({ + error: { + code: -32602, + message: + 'Invalid params: At path: type -- Expected the literal `"hid"`, but received: "bluetooth".', + stack: expect.any(String), + }, + id: 1, + jsonrpc: '2.0', + }); + }); + }); +}); 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..09dfe9dae2 --- /dev/null +++ b/packages/snaps-rpc-methods/src/permitted/listDevices.ts @@ -0,0 +1,115 @@ +import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; +import type { PermittedHandlerExport } from '@metamask/permission-controller'; +import { rpcErrors } from '@metamask/rpc-errors'; +import type { + JsonRpcRequest, + ListDevicesParams, + ListDevicesResult, +} from '@metamask/snaps-sdk'; +import { selectiveUnion } from '@metamask/snaps-sdk'; +import type { InferMatching } from '@metamask/snaps-utils'; +import { + array, + create, + literal, + object, + optional, + StructError, +} from '@metamask/superstruct'; +import { 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( + selectiveUnion((value) => { + if (Array.isArray(value)) { + return array(literal('hid')); + } + + return 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; + const validatedParams = getValidatedParams(params); + + try { + response.result = await listDevices(validatedParams); + } catch (error) { + return end(error); + } + + return end(); +} + +/** + * Validate the method `params` and returns them cast to the correct type. + * Throws if validation fails. + * + * @param params - The unvalidated params object from the method request. + * @returns The validated method parameter object. + */ +function getValidatedParams(params: unknown): ListDevicesParams { + try { + return create(params, ListDevicesParametersStruct); + } catch (error) { + if (error instanceof StructError) { + throw rpcErrors.invalidParams({ + message: `Invalid params: ${error.message}.`, + }); + } + + /* istanbul ignore next */ + throw rpcErrors.internal(); + } +} diff --git a/packages/snaps-rpc-methods/src/permitted/readDevice.test.ts b/packages/snaps-rpc-methods/src/permitted/readDevice.test.ts new file mode 100644 index 0000000000..e80df949e9 --- /dev/null +++ b/packages/snaps-rpc-methods/src/permitted/readDevice.test.ts @@ -0,0 +1,105 @@ +import { JsonRpcEngine } from '@metamask/json-rpc-engine'; +import type { ReadDeviceParams, ReadDeviceResult } from '@metamask/snaps-sdk'; +import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; + +import { readDeviceHandler } from './readDevice'; + +describe('readDevice', () => { + describe('readDeviceHandler', () => { + it('has the expected shape', () => { + expect(readDeviceHandler).toMatchObject({ + methodNames: ['snap_readDevice'], + implementation: expect.any(Function), + hookNames: { + readDevice: true, + }, + }); + }); + }); + + describe('implementation', () => { + it('returns the result of the `readDevice` hook', async () => { + const { implementation } = readDeviceHandler; + + const readDevice = jest.fn().mockImplementation(async () => '0x1234'); + + const hooks = { + readDevice, + }; + + const engine = new JsonRpcEngine(); + + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_readDevice', + params: { + type: 'hid', + id: 'hid:123:456', + }, + }); + + expect(response).toStrictEqual({ + jsonrpc: '2.0', + id: 1, + result: '0x1234', + }); + }); + + it('throws on invalid params', async () => { + const { implementation } = readDeviceHandler; + + const readDevice = jest.fn(); + + const hooks = { + readDevice, + }; + + const engine = new JsonRpcEngine(); + + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_readDevice', + params: { + type: 'bluetooth', + }, + }); + + expect(response).toStrictEqual({ + error: { + code: -32602, + message: + 'Invalid params: At path: type -- Expected the literal `"hid"`, but received: "bluetooth".', + stack: expect.any(String), + }, + id: 1, + jsonrpc: '2.0', + }); + }); + }); +}); 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..3e65c5d49a --- /dev/null +++ b/packages/snaps-rpc-methods/src/permitted/readDevice.ts @@ -0,0 +1,111 @@ +import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; +import type { PermittedHandlerExport } from '@metamask/permission-controller'; +import { rpcErrors } from '@metamask/rpc-errors'; +import type { + JsonRpcRequest, + ReadDeviceParams, + ReadDeviceResult, +} from '@metamask/snaps-sdk'; +import { deviceId, DeviceType } from '@metamask/snaps-sdk'; +import type { InferMatching } from '@metamask/snaps-utils'; +import { + create, + literal, + number, + object, + optional, + StructError, + union, +} from '@metamask/superstruct'; +import { type PendingJsonRpcResponse } from '@metamask/utils'; + +import type { MethodHooksObject } from '../utils'; + +const hookNames: MethodHooksObject = { + readDevice: true, +}; + +export type ReadDeviceHooks = { + /** + * A hook to read data from a device. + * + * @param params - The parameters for reading data from the device. + * @returns The data read from the device. + */ + 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(DeviceType.HID), + reportType: optional(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 read data from a device. + * @returns Nothing. + */ +async function readDeviceImplementation( + request: JsonRpcRequest, + response: PendingJsonRpcResponse, + _next: unknown, + end: JsonRpcEngineEndCallback, + { readDevice }: ReadDeviceHooks, +): Promise { + const { params } = request; + const validatedParams = getValidatedParams(params); + + try { + response.result = await readDevice(validatedParams); + } catch (error) { + return end(error); + } + + return end(); +} + +/** + * Validate the method `params` and returns them cast to the correct type. + * Throws if validation fails. + * + * @param params - The unvalidated params object from the method request. + * @returns The validated method parameter object. + */ +function getValidatedParams(params: unknown): ReadDeviceParams { + try { + return create(params, ReadDeviceParametersStruct); + } catch (error) { + if (error instanceof StructError) { + throw rpcErrors.invalidParams({ + message: `Invalid params: ${error.message}.`, + }); + } + + /* istanbul ignore next */ + throw rpcErrors.internal(); + } +} diff --git a/packages/snaps-rpc-methods/src/permitted/requestDevice.test.ts b/packages/snaps-rpc-methods/src/permitted/requestDevice.test.ts new file mode 100644 index 0000000000..032d0224ba --- /dev/null +++ b/packages/snaps-rpc-methods/src/permitted/requestDevice.test.ts @@ -0,0 +1,107 @@ +import { JsonRpcEngine } from '@metamask/json-rpc-engine'; +import type { + RequestDeviceParams, + RequestDeviceResult, +} from '@metamask/snaps-sdk'; +import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; + +import { requestDeviceHandler } from './requestDevice'; + +describe('requestDevice', () => { + describe('requestDeviceHandler', () => { + it('has the expected shape', () => { + expect(requestDeviceHandler).toMatchObject({ + methodNames: ['snap_requestDevice'], + implementation: expect.any(Function), + hookNames: { + requestDevice: true, + }, + }); + }); + }); + + describe('implementation', () => { + it('returns the result of the `requestDevice` hook', async () => { + const { implementation } = requestDeviceHandler; + + const requestDevice = jest.fn().mockImplementation(async () => []); + + const hooks = { + requestDevice, + }; + + const engine = new JsonRpcEngine(); + + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_requestDevice', + params: { + type: 'hid', + }, + }); + + expect(response).toStrictEqual({ + jsonrpc: '2.0', + id: 1, + result: [], + }); + }); + + it('throws on invalid params', async () => { + const { implementation } = requestDeviceHandler; + + const requestDevice = jest.fn(); + + const hooks = { + requestDevice, + }; + + const engine = new JsonRpcEngine(); + + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_requestDevice', + params: { + type: 'bluetooth', + }, + }); + + expect(response).toStrictEqual({ + error: { + code: -32602, + message: + 'Invalid params: At path: type -- Expected the literal `"hid"`, but received: "bluetooth".', + stack: expect.any(String), + }, + id: 1, + jsonrpc: '2.0', + }); + }); + }); +}); diff --git a/packages/snaps-rpc-methods/src/permitted/requestDevice.ts b/packages/snaps-rpc-methods/src/permitted/requestDevice.ts new file mode 100644 index 0000000000..3b2e309fed --- /dev/null +++ b/packages/snaps-rpc-methods/src/permitted/requestDevice.ts @@ -0,0 +1,107 @@ +import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; +import type { PermittedHandlerExport } from '@metamask/permission-controller'; +import { rpcErrors } from '@metamask/rpc-errors'; +import type { + JsonRpcRequest, + RequestDeviceParams, + RequestDeviceResult, +} from '@metamask/snaps-sdk'; +import { DeviceFilterStruct, DeviceTypeStruct } from '@metamask/snaps-sdk'; +import type { InferMatching } from '@metamask/snaps-utils'; +import { + object, + optional, + array, + create, + StructError, +} from '@metamask/superstruct'; +import { type PendingJsonRpcResponse } from '@metamask/utils'; + +import type { MethodHooksObject } from '../utils'; + +const hookNames: MethodHooksObject = { + requestDevice: true, +}; + +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; +}; + +export const requestDeviceHandler: PermittedHandlerExport< + RequestDeviceHooks, + RequestDeviceParams, + RequestDeviceResult +> = { + methodNames: ['snap_requestDevice'], + implementation: requestDeviceImplementation, + hookNames, +}; + +const RequestDeviceParametersStruct = object({ + type: DeviceTypeStruct, + filters: optional(array(DeviceFilterStruct)), +}); + +export type RequestDeviceParameters = InferMatching< + typeof RequestDeviceParametersStruct, + RequestDeviceParams +>; + +/** + * 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, + { requestDevice }: RequestDeviceHooks, +): Promise { + const { params } = request; + const validatedParams = getValidatedParams(params); + + try { + response.result = await requestDevice(validatedParams); + } catch (error) { + return end(error); + } + + return end(); +} + +/** + * Validate the method `params` and returns them cast to the correct type. + * Throws if validation fails. + * + * @param params - The unvalidated params object from the method request. + * @returns The validated method parameter object. + */ +function getValidatedParams(params: unknown): RequestDeviceParams { + try { + return create(params, RequestDeviceParametersStruct); + } catch (error) { + if (error instanceof StructError) { + throw rpcErrors.invalidParams({ + message: `Invalid params: ${error.message}.`, + }); + } + + /* istanbul ignore next */ + throw rpcErrors.internal(); + } +} diff --git a/packages/snaps-rpc-methods/src/permitted/writeDevice.test.ts b/packages/snaps-rpc-methods/src/permitted/writeDevice.test.ts new file mode 100644 index 0000000000..0c087272cc --- /dev/null +++ b/packages/snaps-rpc-methods/src/permitted/writeDevice.test.ts @@ -0,0 +1,102 @@ +import { JsonRpcEngine } from '@metamask/json-rpc-engine'; +import type { WriteDeviceParams, WriteDeviceResult } from '@metamask/snaps-sdk'; +import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; + +import { writeDeviceHandler } from './writeDevice'; + +describe('writeDevice', () => { + describe('writeDeviceHandler', () => { + it('has the expected shape', () => { + expect(writeDeviceHandler).toMatchObject({ + methodNames: ['snap_writeDevice'], + implementation: expect.any(Function), + hookNames: { + writeDevice: true, + }, + }); + }); + }); + + describe('implementation', () => { + it('returns the result of the `writeDevice` hook', async () => { + const { implementation } = writeDeviceHandler; + + const writeDevice = jest.fn(); + + const hooks = { + writeDevice, + }; + + const engine = new JsonRpcEngine(); + + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_writeDevice', + params: { + type: 'hid', + id: 'hid:123:456', + data: '0x1234', + }, + }); + + expect(response).toStrictEqual({ jsonrpc: '2.0', id: 1, result: null }); + }); + + it('throws on invalid params', async () => { + const { implementation } = writeDeviceHandler; + + const writeDevice = jest.fn(); + + const hooks = { + writeDevice, + }; + + const engine = new JsonRpcEngine(); + + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_writeDevice', + params: { + type: 'bluetooth', + }, + }); + + expect(response).toStrictEqual({ + error: { + code: -32602, + message: + 'Invalid params: At path: type -- Expected the literal `"hid"`, but received: "bluetooth".', + stack: expect.any(String), + }, + id: 1, + jsonrpc: '2.0', + }); + }); + }); +}); 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..8e291417d0 --- /dev/null +++ b/packages/snaps-rpc-methods/src/permitted/writeDevice.ts @@ -0,0 +1,111 @@ +import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; +import type { PermittedHandlerExport } from '@metamask/permission-controller'; +import { rpcErrors } from '@metamask/rpc-errors'; +import type { + JsonRpcRequest, + WriteDeviceParams, + WriteDeviceResult, +} from '@metamask/snaps-sdk'; +import { deviceId, DeviceType } from '@metamask/snaps-sdk'; +import type { InferMatching } from '@metamask/snaps-utils'; +import { + create, + literal, + number, + object, + optional, + StructError, +} from '@metamask/superstruct'; +import { type PendingJsonRpcResponse, StrictHexStruct } from '@metamask/utils'; + +import type { MethodHooksObject } from '../utils'; + +const hookNames: MethodHooksObject = { + writeDevice: true, +}; + +export type WriteDeviceHooks = { + /** + * A hook to write data to a device. + * + * @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; +}; + +export const writeDeviceHandler: PermittedHandlerExport< + WriteDeviceHooks, + WriteDeviceParams, + WriteDeviceResult +> = { + methodNames: ['snap_writeDevice'], + implementation: writeDeviceImplementation, + hookNames, +}; + +const WriteDeviceParametersStruct = object({ + type: literal('hid'), + id: deviceId(DeviceType.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 write data to a device. + * @returns Nothing. + */ +async function writeDeviceImplementation( + request: JsonRpcRequest, + response: PendingJsonRpcResponse, + _next: unknown, + end: JsonRpcEngineEndCallback, + { writeDevice }: WriteDeviceHooks, +): Promise { + const { params } = request; + const validatedParams = getValidatedParams(params); + + try { + response.result = (await writeDevice(validatedParams)) ?? null; + } catch (error) { + return end(error); + } + + return end(); +} + +/** + * Validate the method `params` and returns them cast to the correct type. + * Throws if validation fails. + * + * @param params - The unvalidated params object from the method request. + * @returns The validated method parameter object. + */ +function getValidatedParams(params: unknown): WriteDeviceParams { + try { + return create(params, WriteDeviceParametersStruct); + } catch (error) { + if (error instanceof StructError) { + throw rpcErrors.invalidParams({ + message: `Invalid params: ${error.message}.`, + }); + } + + /* istanbul ignore next */ + throw rpcErrors.internal(); + } +} 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.test.ts b/packages/snaps-sdk/src/types/device.test.ts new file mode 100644 index 0000000000..da26b28708 --- /dev/null +++ b/packages/snaps-sdk/src/types/device.test.ts @@ -0,0 +1,51 @@ +import { is } from '@metamask/superstruct'; +import { expectTypeOf } from 'expect-type'; + +import type { DeviceId, ScopedDeviceId } from './device'; +import { deviceId, DeviceTypeStruct } from './device'; + +describe('DeviceTypeStruct', () => { + it('only accepts `hid`', () => { + expect(is('hid', DeviceTypeStruct)).toBe(true); + }); + + it('does not accept unknown device types', () => { + expect(is('bluetooth', DeviceTypeStruct)).toBe(false); + }); +}); + +describe('DeviceId', () => { + it('has a colon separated device type and identifier', () => { + expectTypeOf<'hid:1:2'>().toMatchTypeOf(); + }); + + it('does not accept unknown device types', () => { + expectTypeOf<'bluetooth:1:2'>().not.toMatchTypeOf(); + }); +}); + +describe('ScopedDeviceId', () => { + it('has a colon separated device type and identifier', () => { + expectTypeOf<'hid:1:2'>().toMatchTypeOf>(); + }); + + it('does not accept unknown device types', () => { + expectTypeOf<'bluetooth:1:2'>().not.toMatchTypeOf>(); + }); +}); + +describe('deviceId', () => { + it('creates a scoped device ID struct', () => { + const struct = deviceId('hid'); + + expect(is('hid:1:2', struct)).toBe(true); + expect(is('bluetooth:1:2', struct)).toBe(false); + }); + + it('creates a device ID struct', () => { + const struct = deviceId(); + + expect(is('hid:1:2', struct)).toBe(true); + expect(is('bluetooth:1:2', struct)).toBe(true); + }); +}); diff --git a/packages/snaps-sdk/src/types/device.ts b/packages/snaps-sdk/src/types/device.ts new file mode 100644 index 0000000000..5a08f071b4 --- /dev/null +++ b/packages/snaps-sdk/src/types/device.ts @@ -0,0 +1,96 @@ +import type { Struct } from '@metamask/superstruct'; +import { refine, string } from '@metamask/superstruct'; + +import { enumValue } from '../internals'; + +/** + * The type of the device. Currently, only `hid` is supported. + */ +export enum DeviceType { + HID = 'hid', +} + +/** + * A struct that represents the `DeviceType` type. + */ +export const DeviceTypeStruct = enumValue(DeviceType.HID); + +/** + * 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 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. + */ +export type DeviceMetadata = { + /** + * 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: number; + + /** + * The product ID of the device. + */ + productId: number; + + /** + * Whether the device is available. + */ + available: boolean; +}; + +type ScopedDeviceMetadata = DeviceMetadata & { + type: Type; + id: ScopedDeviceId; +}; + +export type HidDeviceMetadata = ScopedDeviceMetadata; 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/get-supported-devices.ts b/packages/snaps-sdk/src/types/methods/get-supported-devices.ts new file mode 100644 index 0000000000..0bf3dcd97e --- /dev/null +++ b/packages/snaps-sdk/src/types/methods/get-supported-devices.ts @@ -0,0 +1,11 @@ +import type { DeviceType } from '../device'; + +/** + * 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..36fa923670 100644 --- a/packages/snaps-sdk/src/types/methods/index.ts +++ b/packages/snaps-sdk/src/types/methods/index.ts @@ -12,12 +12,17 @@ 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-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 new file mode 100644 index 0000000000..a178b9adfe --- /dev/null +++ b/packages/snaps-sdk/src/types/methods/list-devices.ts @@ -0,0 +1,18 @@ +import type { EnumToUnion } from '../../internals'; +import type { DeviceMetadata, DeviceType } from '../device'; + +/** + * The request parameters for the `snap_listDevices` method. + */ +export type ListDevicesParams = { + /** + * The type(s) of the device to list. If not provided, all devices are listed. + */ + type?: EnumToUnion | EnumToUnion[]; +}; + +/** + * The result returned by the `snap_readDevice` method. This is a list of + * devices that are available to the Snap. + */ +export type ListDevicesResult = DeviceMetadata[]; diff --git a/packages/snaps-sdk/src/types/methods/methods.ts b/packages/snaps-sdk/src/types/methods/methods.ts index 4d9e1f33c6..20ccdd951a 100644 --- a/packages/snaps-sdk/src/types/methods/methods.ts +++ b/packages/snaps-sdk/src/types/methods/methods.ts @@ -32,17 +32,27 @@ 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, } 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 +62,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 @@ -67,10 +78,18 @@ 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]; 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..ec5e876417 --- /dev/null +++ b/packages/snaps-sdk/src/types/methods/read-device.ts @@ -0,0 +1,42 @@ +import type { Hex } from '@metamask/utils'; + +import type { EnumToUnion } from '../../internals'; +import type { DeviceType, ScopedDeviceId } from '../device'; + +/** + * The request parameters for the `snap_readDevice` method reading from an HID + * device. + */ +type HidReadParams = { + /** + * The type of the device. + */ + type: EnumToUnion; + + /** + * The ID of the device to read from. + */ + id: ScopedDeviceId; + + /** + * 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..6d120a53e2 --- /dev/null +++ b/packages/snaps-sdk/src/types/methods/request-device.ts @@ -0,0 +1,45 @@ +import { number, object, optional } from '@metamask/superstruct'; + +import type { Describe, EnumToUnion } from '../../internals'; +import type { DeviceMetadata, DeviceType } from '../device'; + +export type DeviceFilter = { + /** + * The vendor ID of the device. + */ + vendorId?: number; + + /** + * The product ID of the device. + */ + 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. + */ +export type RequestDeviceParams = { + /** + * The type of the device to request. + */ + type: EnumToUnion; + + /** + * The filters to apply to the devices. + */ + filters?: 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 = DeviceMetadata | 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..f2536b4805 --- /dev/null +++ b/packages/snaps-sdk/src/types/methods/write-device.ts @@ -0,0 +1,47 @@ +import type { Hex } from '@metamask/utils'; + +import type { EnumToUnion } from '../../internals'; +import type { DeviceType, 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: EnumToUnion; + + /** + * The ID of the device to write to. + */ + id: ScopedDeviceId; + + /** + * 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 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 = never; 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..611e964158 --- /dev/null +++ b/packages/snaps-utils/src/devices.ts @@ -0,0 +1,48 @@ +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 = 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'; diff --git a/packages/test-snaps/package.json b/packages/test-snaps/package.json index 45885eacf8..e7788e3b15 100644 --- a/packages/test-snaps/package.json +++ b/packages/test-snaps/package.json @@ -58,6 +58,7 @@ "@metamask/interactive-ui-example-snap": "workspace:^", "@metamask/json-rpc-example-snap": "workspace:^", "@metamask/jsx-example-snap": "workspace:^", + "@metamask/ledger-example-snap": "workspace:^", "@metamask/lifecycle-hooks-example-snap": "workspace:^", "@metamask/localization-example-snap": "workspace:^", "@metamask/manage-state-example-snap": "workspace:^", diff --git a/packages/test-snaps/src/features/snaps/index.ts b/packages/test-snaps/src/features/snaps/index.ts index c80978a40d..10c00d61f1 100644 --- a/packages/test-snaps/src/features/snaps/index.ts +++ b/packages/test-snaps/src/features/snaps/index.ts @@ -15,6 +15,7 @@ export * from './home-page'; export * from './images'; export * from './json-rpc'; export * from './jsx'; +export * from './ledger'; export * from './lifecycle-hooks'; export * from './manage-state'; export * from './multi-install'; diff --git a/packages/test-snaps/src/features/snaps/ledger/Ledger.tsx b/packages/test-snaps/src/features/snaps/ledger/Ledger.tsx new file mode 100644 index 0000000000..66aede96ed --- /dev/null +++ b/packages/test-snaps/src/features/snaps/ledger/Ledger.tsx @@ -0,0 +1,45 @@ +import { logError } from '@metamask/snaps-utils'; +import type { FunctionComponent } from 'react'; +import { Button } from 'react-bootstrap'; + +import { useInvokeMutation } from '../../../api'; +import { Result, Snap } from '../../../components'; +import { getSnapId } from '../../../utils'; +import { LEDGER_SNAP_ID, LEDGER_SNAP_PORT, LEDGER_VERSION } from './constants'; + +export const Ledger: FunctionComponent = () => { + const [invokeSnap, { isLoading, data, error }] = useInvokeMutation(); + + const handleSubmit = () => { + invokeSnap({ + snapId: getSnapId(LEDGER_SNAP_ID, LEDGER_SNAP_PORT), + method: 'request', + }).catch(logError); + }; + + return ( + + + + + {JSON.stringify(data, null, 2)} + {JSON.stringify(error, null, 2)} + + + + ); +}; diff --git a/packages/test-snaps/src/features/snaps/ledger/constants.ts b/packages/test-snaps/src/features/snaps/ledger/constants.ts new file mode 100644 index 0000000000..f8394f2629 --- /dev/null +++ b/packages/test-snaps/src/features/snaps/ledger/constants.ts @@ -0,0 +1,5 @@ +import packageJson from '@metamask/ledger-example-snap/package.json'; + +export const LEDGER_SNAP_ID = 'npm:@metamask/ledger-example-snap'; +export const LEDGER_SNAP_PORT = 8032; +export const LEDGER_VERSION = packageJson.version; diff --git a/packages/test-snaps/src/features/snaps/ledger/index.ts b/packages/test-snaps/src/features/snaps/ledger/index.ts new file mode 100644 index 0000000000..8fa7c12537 --- /dev/null +++ b/packages/test-snaps/src/features/snaps/ledger/index.ts @@ -0,0 +1 @@ +export * from './Ledger'; diff --git a/yarn.lock b/yarn.lock index e9770eb180..015bc21e63 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3231,6 +3231,73 @@ __metadata: languageName: node linkType: hard +"@ethersproject/abi@npm:^5.7.0": + version: 5.7.0 + resolution: "@ethersproject/abi@npm:5.7.0" + dependencies: + "@ethersproject/address": "npm:^5.7.0" + "@ethersproject/bignumber": "npm:^5.7.0" + "@ethersproject/bytes": "npm:^5.7.0" + "@ethersproject/constants": "npm:^5.7.0" + "@ethersproject/hash": "npm:^5.7.0" + "@ethersproject/keccak256": "npm:^5.7.0" + "@ethersproject/logger": "npm:^5.7.0" + "@ethersproject/properties": "npm:^5.7.0" + "@ethersproject/strings": "npm:^5.7.0" + checksum: 10/6ed002cbc61a7e21bc0182702345659c1984f6f8e6bad166e43aee76ea8f74766dd0f6236574a868e1b4600af27972bf25b973fae7877ae8da3afa90d3965cac + languageName: node + linkType: hard + +"@ethersproject/abstract-provider@npm:^5.7.0": + version: 5.7.0 + resolution: "@ethersproject/abstract-provider@npm:5.7.0" + dependencies: + "@ethersproject/bignumber": "npm:^5.7.0" + "@ethersproject/bytes": "npm:^5.7.0" + "@ethersproject/logger": "npm:^5.7.0" + "@ethersproject/networks": "npm:^5.7.0" + "@ethersproject/properties": "npm:^5.7.0" + "@ethersproject/transactions": "npm:^5.7.0" + "@ethersproject/web": "npm:^5.7.0" + checksum: 10/c03e413a812486002525f4036bf2cb90e77a19b98fa3d16279e28e0a05520a1085690fac2ee9f94b7931b9a803249ff8a8bbb26ff8dee52196a6ef7a3fc5edc5 + languageName: node + linkType: hard + +"@ethersproject/abstract-signer@npm:^5.7.0": + version: 5.7.0 + resolution: "@ethersproject/abstract-signer@npm:5.7.0" + dependencies: + "@ethersproject/abstract-provider": "npm:^5.7.0" + "@ethersproject/bignumber": "npm:^5.7.0" + "@ethersproject/bytes": "npm:^5.7.0" + "@ethersproject/logger": "npm:^5.7.0" + "@ethersproject/properties": "npm:^5.7.0" + checksum: 10/0a6ffade0a947c9ba617048334e1346838f394d1d0a5307ac435a0c63ed1033b247e25ffb0cd6880d7dcf5459581f52f67e3804ebba42ff462050f1e4321ba0c + languageName: node + linkType: hard + +"@ethersproject/address@npm:^5.7.0": + version: 5.7.0 + resolution: "@ethersproject/address@npm:5.7.0" + dependencies: + "@ethersproject/bignumber": "npm:^5.7.0" + "@ethersproject/bytes": "npm:^5.7.0" + "@ethersproject/keccak256": "npm:^5.7.0" + "@ethersproject/logger": "npm:^5.7.0" + "@ethersproject/rlp": "npm:^5.7.0" + checksum: 10/1ac4f3693622ed9fbbd7e966a941ec1eba0d9445e6e8154b1daf8e93b8f62ad91853d1de5facf4c27b41e6f1e47b94a317a2492ba595bee1841fd3030c3e9a27 + languageName: node + linkType: hard + +"@ethersproject/base64@npm:^5.7.0": + version: 5.7.0 + resolution: "@ethersproject/base64@npm:5.7.0" + dependencies: + "@ethersproject/bytes": "npm:^5.7.0" + checksum: 10/7105105f401e1c681e61db1e9da1b5960d8c5fbd262bbcacc99d61dbb9674a9db1181bb31903d98609f10e8a0eb64c850475f3b040d67dea953e2b0ac6380e96 + languageName: node + linkType: hard + "@ethersproject/bignumber@npm:^5.7.0": version: 5.7.0 resolution: "@ethersproject/bignumber@npm:5.7.0" @@ -3260,6 +3327,33 @@ __metadata: languageName: node linkType: hard +"@ethersproject/hash@npm:^5.7.0": + version: 5.7.0 + resolution: "@ethersproject/hash@npm:5.7.0" + dependencies: + "@ethersproject/abstract-signer": "npm:^5.7.0" + "@ethersproject/address": "npm:^5.7.0" + "@ethersproject/base64": "npm:^5.7.0" + "@ethersproject/bignumber": "npm:^5.7.0" + "@ethersproject/bytes": "npm:^5.7.0" + "@ethersproject/keccak256": "npm:^5.7.0" + "@ethersproject/logger": "npm:^5.7.0" + "@ethersproject/properties": "npm:^5.7.0" + "@ethersproject/strings": "npm:^5.7.0" + checksum: 10/d83de3f3a1b99b404a2e7bb503f5cdd90c66a97a32cce1d36b09bb8e3fb7205b96e30ad28e2b9f30083beea6269b157d0c6e3425052bb17c0a35fddfdd1c72a3 + languageName: node + linkType: hard + +"@ethersproject/keccak256@npm:^5.7.0": + version: 5.7.0 + resolution: "@ethersproject/keccak256@npm:5.7.0" + dependencies: + "@ethersproject/bytes": "npm:^5.7.0" + js-sha3: "npm:0.8.0" + checksum: 10/ff70950d82203aab29ccda2553422cbac2e7a0c15c986bd20a69b13606ed8bb6e4fdd7b67b8d3b27d4f841e8222cbaccd33ed34be29f866fec7308f96ed244c6 + languageName: node + linkType: hard + "@ethersproject/logger@npm:^5.7.0": version: 5.7.0 resolution: "@ethersproject/logger@npm:5.7.0" @@ -3267,6 +3361,76 @@ __metadata: languageName: node linkType: hard +"@ethersproject/networks@npm:^5.7.0": + version: 5.7.1 + resolution: "@ethersproject/networks@npm:5.7.1" + dependencies: + "@ethersproject/logger": "npm:^5.7.0" + checksum: 10/5265d0b4b72ef91af57be804b44507f4943038d609699764d8a69157ed381e30fe22ebf63630ed8e530ceb220f15d69dae8cda2e5023ccd793285c9d5882e599 + languageName: node + linkType: hard + +"@ethersproject/properties@npm:^5.7.0": + version: 5.7.0 + resolution: "@ethersproject/properties@npm:5.7.0" + dependencies: + "@ethersproject/logger": "npm:^5.7.0" + checksum: 10/f8401a161940aa1c32695115a20c65357877002a6f7dc13ab1600064bf54d7b825b4db49de8dc8da69efcbb0c9f34f8813e1540427e63e262ab841c1bf6c1c1e + languageName: node + linkType: hard + +"@ethersproject/rlp@npm:^5.7.0": + version: 5.7.0 + resolution: "@ethersproject/rlp@npm:5.7.0" + dependencies: + "@ethersproject/bytes": "npm:^5.7.0" + "@ethersproject/logger": "npm:^5.7.0" + checksum: 10/3b8c5279f7654794d5874569f5598ae6a880e19e6616013a31e26c35c5f586851593a6e85c05ed7b391fbc74a1ea8612dd4d867daefe701bf4e8fcf2ab2f29b9 + languageName: node + linkType: hard + +"@ethersproject/signing-key@npm:^5.7.0": + version: 5.7.0 + resolution: "@ethersproject/signing-key@npm:5.7.0" + dependencies: + "@ethersproject/bytes": "npm:^5.7.0" + "@ethersproject/logger": "npm:^5.7.0" + "@ethersproject/properties": "npm:^5.7.0" + bn.js: "npm:^5.2.1" + elliptic: "npm:6.5.4" + hash.js: "npm:1.1.7" + checksum: 10/ff2f79ded86232b139e7538e4aaa294c6022a7aaa8c95a6379dd7b7c10a6d363685c6967c816f98f609581cf01f0a5943c667af89a154a00bcfe093a8c7f3ce7 + languageName: node + linkType: hard + +"@ethersproject/strings@npm:^5.7.0": + version: 5.7.0 + resolution: "@ethersproject/strings@npm:5.7.0" + dependencies: + "@ethersproject/bytes": "npm:^5.7.0" + "@ethersproject/constants": "npm:^5.7.0" + "@ethersproject/logger": "npm:^5.7.0" + checksum: 10/24191bf30e98d434a9fba2f522784f65162d6712bc3e1ccc98ed85c5da5884cfdb5a1376b7695374655a7b95ec1f5fdbeef5afc7d0ea77ffeb78047e9b791fa5 + languageName: node + linkType: hard + +"@ethersproject/transactions@npm:^5.7.0": + version: 5.7.0 + resolution: "@ethersproject/transactions@npm:5.7.0" + dependencies: + "@ethersproject/address": "npm:^5.7.0" + "@ethersproject/bignumber": "npm:^5.7.0" + "@ethersproject/bytes": "npm:^5.7.0" + "@ethersproject/constants": "npm:^5.7.0" + "@ethersproject/keccak256": "npm:^5.7.0" + "@ethersproject/logger": "npm:^5.7.0" + "@ethersproject/properties": "npm:^5.7.0" + "@ethersproject/rlp": "npm:^5.7.0" + "@ethersproject/signing-key": "npm:^5.7.0" + checksum: 10/d809e9d40020004b7de9e34bf39c50377dce8ed417cdf001bfabc81ecb1b7d1e0c808fdca0a339ea05e1b380648eaf336fe70f137904df2d3c3135a38190a5af + languageName: node + linkType: hard + "@ethersproject/units@npm:^5.7.0": version: 5.7.0 resolution: "@ethersproject/units@npm:5.7.0" @@ -3278,6 +3442,19 @@ __metadata: languageName: node linkType: hard +"@ethersproject/web@npm:^5.7.0": + version: 5.7.1 + resolution: "@ethersproject/web@npm:5.7.1" + dependencies: + "@ethersproject/base64": "npm:^5.7.0" + "@ethersproject/bytes": "npm:^5.7.0" + "@ethersproject/logger": "npm:^5.7.0" + "@ethersproject/properties": "npm:^5.7.0" + "@ethersproject/strings": "npm:^5.7.0" + checksum: 10/c83b6b3ac40573ddb67b1750bb4cf21ded7d8555be5e53a97c0f34964622fd88de9220a90a118434bae164a2bff3acbdc5ecb990517b5f6dc32bdad7adf604c2 + languageName: node + linkType: hard + "@gar/promisify@npm:^1.1.3": version: 1.1.3 resolution: "@gar/promisify@npm:1.1.3" @@ -3807,6 +3984,136 @@ __metadata: languageName: node linkType: hard +"@ledgerhq/cryptoassets-evm-signatures@npm:^13.5.2": + version: 13.5.2 + resolution: "@ledgerhq/cryptoassets-evm-signatures@npm:13.5.2" + dependencies: + "@ledgerhq/live-env": "npm:^2.4.1" + axios: "npm:1.7.7" + checksum: 10/2cf692c111523fa634a6eeadfbe1e9eca29fd9e8c03a04dac00f0c191becdc0300bf90d1a5a0190b6ac8cf1436bb14009b50039e3611c55dd6843a6ec45230ec + 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/domain-service@npm:^1.2.11": + version: 1.2.11 + resolution: "@ledgerhq/domain-service@npm:1.2.11" + dependencies: + "@ledgerhq/errors": "npm:^6.19.1" + "@ledgerhq/logs": "npm:^6.12.0" + "@ledgerhq/types-live": "npm:^6.53.0" + axios: "npm:1.7.7" + eip55: "npm:^2.1.1" + react: "npm:^18.2.0" + react-dom: "npm:^18.2.0" + checksum: 10/47487edda6aec4c8f5f71fbff0883d4e778b367261995426f420cb6db3b310cf9b5e4b0a59b0f63a8c6687963aa6b38cf205bee16f10a383bd5fe61f076fbfc6 + 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/evm-tools@npm:^1.3.0": + version: 1.3.0 + resolution: "@ledgerhq/evm-tools@npm:1.3.0" + dependencies: + "@ethersproject/constants": "npm:^5.7.0" + "@ethersproject/hash": "npm:^5.7.0" + "@ledgerhq/cryptoassets-evm-signatures": "npm:^13.5.2" + "@ledgerhq/live-env": "npm:^2.4.1" + axios: "npm:1.7.7" + crypto-js: "npm:4.2.0" + checksum: 10/e4394a3065391d44efe958a043df6fade68e789f18386d2a22f51574eb6b723151c8e631af2bce21b951b1908930145181cf8db41fe546e4a06c34a81cb3ff6b + languageName: node + linkType: hard + +"@ledgerhq/hw-app-eth@npm:^6.41.0": + version: 6.41.0 + resolution: "@ledgerhq/hw-app-eth@npm:6.41.0" + dependencies: + "@ethersproject/abi": "npm:^5.7.0" + "@ethersproject/rlp": "npm:^5.7.0" + "@ethersproject/transactions": "npm:^5.7.0" + "@ledgerhq/cryptoassets-evm-signatures": "npm:^13.5.2" + "@ledgerhq/domain-service": "npm:^1.2.11" + "@ledgerhq/errors": "npm:^6.19.1" + "@ledgerhq/evm-tools": "npm:^1.3.0" + "@ledgerhq/hw-transport": "npm:^6.31.4" + "@ledgerhq/hw-transport-mocker": "npm:^6.29.4" + "@ledgerhq/logs": "npm:^6.12.0" + "@ledgerhq/types-live": "npm:^6.53.0" + axios: "npm:1.7.7" + bignumber.js: "npm:^9.1.2" + semver: "npm:^7.3.5" + checksum: 10/d6c1b2d06ac6bd6e501ec0ed3d9043cd8e18356971922f1cd84554e2429ada4317e301165cb320addcdb951faeed685b988948d8aee66656044323f1c5870774 + languageName: node + linkType: hard + +"@ledgerhq/hw-transport-mocker@npm:^6.29.4": + version: 6.29.4 + resolution: "@ledgerhq/hw-transport-mocker@npm:6.29.4" + dependencies: + "@ledgerhq/hw-transport": "npm:^6.31.4" + "@ledgerhq/logs": "npm:^6.12.0" + rxjs: "npm:^7.8.1" + checksum: 10/6f1568b1723ee6964872b09b712714bacf33c87e83413a33420b7ba11e3c30fa6786f02d2cf7b8bc9b3560f4b5c3b166017d5e0a960267a7824a153687fe32ed + 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/live-env@npm:^2.4.1": + version: 2.4.1 + resolution: "@ledgerhq/live-env@npm:2.4.1" + dependencies: + rxjs: "npm:^7.8.1" + utility-types: "npm:^3.10.0" + checksum: 10/e8f5f13d77619f0e2b83907fa2a4e80f9e1ed18aeba0cfb2dcafe4d505ed4dd811a1b508ca752a4fd3782f8e5cf651a9daea0cb17d57e9159f915042b94b867d + 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 + +"@ledgerhq/types-live@npm:^6.53.0": + version: 6.53.0 + resolution: "@ledgerhq/types-live@npm:6.53.0" + dependencies: + bignumber.js: "npm:^9.1.2" + rxjs: "npm:^7.8.1" + checksum: 10/aeb4881f994b86ff5e284c8b24b81ad2fae21b87769639fd55b266f3979f6d66f0690ef1b76c51aeebb6238c3c780062d0b8e2e6d91a1860fc4827a3c4194b55 + 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 +5350,48 @@ __metadata: languageName: node linkType: hard +"@metamask/ledger-example-snap@workspace:^, @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-app-eth": "npm:^6.41.0" + "@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" @@ -5724,6 +6073,7 @@ __metadata: "@types/node": "npm:18.14.2" "@types/readable-stream": "npm:^4.0.15" "@types/tar-stream": "npm:^3.1.1" + "@types/w3c-web-hid": "npm:^1.0.6" "@typescript-eslint/eslint-plugin": "npm:^5.42.1" "@typescript-eslint/parser": "npm:^6.21.0" "@wdio/browser-runner": "npm:^8.19.0" @@ -5733,6 +6083,7 @@ __metadata: "@wdio/spec-reporter": "npm:^8.19.0" "@wdio/static-server-service": "npm:^8.19.0" "@xstate/fsm": "npm:^2.0.0" + async-mutex: "npm:^0.4.0" browserify-zlib: "npm:^0.2.0" concat-stream: "npm:^2.0.0" deepmerge: "npm:^4.2.2" @@ -6380,6 +6731,7 @@ __metadata: "@metamask/interactive-ui-example-snap": "workspace:^" "@metamask/json-rpc-example-snap": "workspace:^" "@metamask/jsx-example-snap": "workspace:^" + "@metamask/ledger-example-snap": "workspace:^" "@metamask/lifecycle-hooks-example-snap": "workspace:^" "@metamask/localization-example-snap": "workspace:^" "@metamask/manage-state-example-snap": "workspace:^" @@ -6442,6 +6794,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" @@ -8160,6 +8529,13 @@ __metadata: languageName: node linkType: hard +"@types/w3c-web-hid@npm:^1.0.6": + version: 1.0.6 + resolution: "@types/w3c-web-hid@npm:1.0.6" + checksum: 10/14773befa9c458b3459cdb530a8269937e623e6b72c6bd2d7f88b42f8d47c02d8a64ddc98f79c81c930b6eadf1dc1c94917b553ead72acc13c8406f65310c85d + languageName: node + linkType: hard + "@types/warning@npm:^3.0.0": version: 3.0.0 resolution: "@types/warning@npm:3.0.0" @@ -9617,7 +9993,7 @@ __metadata: languageName: node linkType: hard -"axios@npm:^1.7.4": +"axios@npm:1.7.7, axios@npm:^1.7.4": version: 1.7.7 resolution: "axios@npm:1.7.7" dependencies: @@ -9841,6 +10217,13 @@ __metadata: languageName: node linkType: hard +"bignumber.js@npm:^9.1.2": + version: 9.1.2 + resolution: "bignumber.js@npm:9.1.2" + checksum: 10/d89b8800a987225d2c00dcbf8a69dc08e92aa0880157c851c287b307d31ceb2fc2acb0c62c3e3a3d42b6c5fcae9b004035f13eb4386e56d529d7edac18d5c9d8 + languageName: node + linkType: hard + "bin-links@npm:4.0.3": version: 4.0.3 resolution: "bin-links@npm:4.0.3" @@ -11290,6 +11673,13 @@ __metadata: languageName: node linkType: hard +"crypto-js@npm:4.2.0": + version: 4.2.0 + resolution: "crypto-js@npm:4.2.0" + checksum: 10/c7bcc56a6e01c3c397e95aa4a74e4241321f04677f9a618a8f48a63b5781617248afb9adb0629824792e7ec20ca0d4241a49b6b2938ae6f973ec4efc5c53c924 + languageName: node + linkType: hard + "css-box-model@npm:1.2.1": version: 1.2.1 resolution: "css-box-model@npm:1.2.1" @@ -12135,6 +12525,15 @@ __metadata: languageName: node linkType: hard +"eip55@npm:^2.1.1": + version: 2.1.1 + resolution: "eip55@npm:2.1.1" + dependencies: + keccak: "npm:^3.0.3" + checksum: 10/512d319e4f91ab0c33b514f371206956521dcdcdd23e8eb4d6f9c21e3be9f72287c0b82feb854d3a1eec91805804d13c31e7a1a7dafd37f69eb9994a9c6c8f32 + languageName: node + linkType: hard + "ejs@npm:^3.1.9": version: 3.1.10 resolution: "ejs@npm:3.1.10" @@ -12153,6 +12552,21 @@ __metadata: languageName: node linkType: hard +"elliptic@npm:6.5.4": + version: 6.5.4 + resolution: "elliptic@npm:6.5.4" + dependencies: + bn.js: "npm:^4.11.9" + brorand: "npm:^1.1.0" + hash.js: "npm:^1.0.0" + hmac-drbg: "npm:^1.0.1" + inherits: "npm:^2.0.4" + minimalistic-assert: "npm:^1.0.1" + minimalistic-crypto-utils: "npm:^1.0.1" + checksum: 10/2cd7ff4b69720dbb2ca1ca650b2cf889d1df60c96d4a99d331931e4fe21e45a7f3b8074e86618ca7e56366c4b6258007f234f9d61d9b0c87bbbc8ea990b99e94 + languageName: node + linkType: hard + "elliptic@npm:^6.5.3, elliptic@npm:^6.5.4": version: 6.5.7 resolution: "elliptic@npm:6.5.7" @@ -14578,7 +14992,7 @@ __metadata: languageName: node linkType: hard -"hash.js@npm:^1.0.0, hash.js@npm:^1.0.3": +"hash.js@npm:1.1.7, hash.js@npm:^1.0.0, hash.js@npm:^1.0.3": version: 1.1.7 resolution: "hash.js@npm:1.1.7" dependencies: @@ -16327,6 +16741,13 @@ __metadata: languageName: node linkType: hard +"js-sha3@npm:0.8.0": + version: 0.8.0 + resolution: "js-sha3@npm:0.8.0" + checksum: 10/a49ac6d3a6bfd7091472a28ab82a94c7fb8544cc584ee1906486536ba1cb4073a166f8c7bb2b0565eade23c5b3a7b8f7816231e0309ab5c549b737632377a20c + languageName: node + linkType: hard + "js-sha3@npm:^0.5.7": version: 0.5.7 resolution: "js-sha3@npm:0.5.7" @@ -16572,6 +16993,18 @@ __metadata: languageName: node linkType: hard +"keccak@npm:^3.0.3": + version: 3.0.4 + resolution: "keccak@npm:3.0.4" + dependencies: + node-addon-api: "npm:^2.0.0" + node-gyp: "npm:latest" + node-gyp-build: "npm:^4.2.0" + readable-stream: "npm:^3.6.0" + checksum: 10/45478bb0a57e44d0108646499b8360914b0fbc8b0e088f1076659cb34faaa9eb829c40f6dd9dadb3460bb86cc33153c41fed37fe5ce09465a60e71e78c23fa55 + languageName: node + linkType: hard + "keyv@npm:^4.5.3": version: 4.5.3 resolution: "keyv@npm:4.5.3" @@ -17938,6 +18371,15 @@ __metadata: languageName: node linkType: hard +"node-addon-api@npm:^2.0.0": + version: 2.0.2 + resolution: "node-addon-api@npm:2.0.2" + dependencies: + node-gyp: "npm:latest" + checksum: 10/e4ce4daac5b2fefa6b94491b86979a9c12d9cceba571d2c6df1eb5859f9da68e5dc198f128798e1785a88aafee6e11f4992dcccd4bf86bec90973927d158bd60 + languageName: node + linkType: hard + "node-addon-api@npm:^6.1.0": version: 6.1.0 resolution: "node-addon-api@npm:6.1.0" @@ -18004,6 +18446,17 @@ __metadata: languageName: node linkType: hard +"node-gyp-build@npm:^4.2.0": + version: 4.8.3 + resolution: "node-gyp-build@npm:4.8.3" + bin: + node-gyp-build: bin.js + node-gyp-build-optional: optional.js + node-gyp-build-test: build-test.js + checksum: 10/4cdc07c940bc1ae484d4d62b0627c80bfb5018e597f2c68c0a7a80b17e9b9cef9d566ec52150ff6f867dd42788eff97a3bcf5cb5b4679ef74954b2df2ac57c02 + languageName: node + linkType: hard + "node-gyp@npm:^10.0.0": version: 10.0.1 resolution: "node-gyp@npm:10.0.1" @@ -22555,6 +23008,13 @@ __metadata: languageName: node linkType: hard +"utility-types@npm:^3.10.0": + version: 3.11.0 + resolution: "utility-types@npm:3.11.0" + checksum: 10/a3c51463fc807ed04ccc8b5d0fa6e31f3dcd7a4cbd30ab4bc6d760ce5319dd493d95bf04244693daf316f97e9ab2a37741edfed8748ad38572a595398ad0fdaf + languageName: node + linkType: hard + "utils-merge@npm:1.0.1": version: 1.0.1 resolution: "utils-merge@npm:1.0.1"