From 4d4cd30ebeb387a301bd7ae5e7f6abad0fe57cc3 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 9 Oct 2024 16:32:25 +0200 Subject: [PATCH 01/15] wip: Add DeviceController --- .../src/devices/DeviceController.ts | 90 +++++++++++++++++++ .../snaps-controllers/src/devices/index.ts | 1 + packages/snaps-controllers/src/index.ts | 1 + .../src/permitted/handlers.ts | 3 + .../src/permitted/requestDevice.ts | 65 ++++++++++++++ 5 files changed, 160 insertions(+) create mode 100644 packages/snaps-controllers/src/devices/DeviceController.ts create mode 100644 packages/snaps-controllers/src/devices/index.ts create mode 100644 packages/snaps-rpc-methods/src/permitted/requestDevice.ts diff --git a/packages/snaps-controllers/src/devices/DeviceController.ts b/packages/snaps-controllers/src/devices/DeviceController.ts new file mode 100644 index 0000000000..5009a20efe --- /dev/null +++ b/packages/snaps-controllers/src/devices/DeviceController.ts @@ -0,0 +1,90 @@ +import type { + RestrictedControllerMessenger, + ControllerGetStateAction, + ControllerStateChangeEvent, +} from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import type { GetPermissions } from '@metamask/permission-controller'; + +import type { DeleteInterface } from '../interface'; +import type { GetAllSnaps, HandleSnapRequest } from '../snaps'; +import type { + TransactionControllerUnapprovedTransactionAddedEvent, + SignatureStateChange, + TransactionControllerTransactionStatusUpdatedEvent, +} from '../types'; + +const controllerName = 'DeviceController'; + +export type DeviceControllerAllowedActions = + | HandleSnapRequest + | GetAllSnaps + | GetPermissions + | DeleteInterface; + +export type DeviceControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + DeviceControllerState +>; + +export type DeviceControllerActions = DeviceControllerGetStateAction; + +export type DeviceControllerStateChangeEvent = ControllerStateChangeEvent< + typeof controllerName, + DeviceControllerState +>; + +export type DeviceControllerEvents = DeviceControllerStateChangeEvent; + +export type DeviceControllerAllowedEvents = + | TransactionControllerUnapprovedTransactionAddedEvent + | TransactionControllerTransactionStatusUpdatedEvent + | SignatureStateChange; + +export type DeviceControllerMessenger = RestrictedControllerMessenger< + typeof controllerName, + DeviceControllerActions | DeviceControllerAllowedActions, + DeviceControllerEvents | DeviceControllerAllowedEvents, + DeviceControllerAllowedActions['type'], + DeviceControllerAllowedEvents['type'] +>; + +export type Device = {}; + +export type DeviceControllerState = { + devices: Record; +}; + +export type DeviceControllerArgs = { + messenger: DeviceControllerMessenger; + state?: DeviceControllerState; +}; +/** + * Controller for managing access to devices for Snaps. + */ +export class DeviceController extends BaseController< + typeof controllerName, + DeviceControllerState, + DeviceControllerMessenger +> { + constructor({ messenger, state }: DeviceControllerArgs) { + super({ + messenger, + metadata: { + devices: { persist: true, anonymous: false }, + }, + name: controllerName, + state: { ...state, devices: {} }, + }); + } + + async requestDevices() { + const devices = await (navigator as any).hid.requestDevice({ filters: [] }); + return devices; + } + + async getDevices() { + const devices = await (navigator as any).hid.getDevices(); + return devices; + } +} 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-rpc-methods/src/permitted/handlers.ts b/packages/snaps-rpc-methods/src/permitted/handlers.ts index 5bfacdacd4..37b0771190 100644 --- a/packages/snaps-rpc-methods/src/permitted/handlers.ts +++ b/packages/snaps-rpc-methods/src/permitted/handlers.ts @@ -1,5 +1,6 @@ import { createInterfaceHandler } from './createInterface'; import { providerRequestHandler } from './experimentalProviderRequest'; +import { providerRequestHandler as requestDeviceHandler } from './requestDevice'; import { getAllSnapsHandler } from './getAllSnaps'; import { getClientStatusHandler } from './getClientStatus'; import { getCurrencyRateHandler } from './getCurrencyRate'; @@ -12,6 +13,7 @@ import { requestSnapsHandler } from './requestSnaps'; import { resolveInterfaceHandler } from './resolveInterface'; import { updateInterfaceHandler } from './updateInterface'; + /* eslint-disable @typescript-eslint/naming-convention */ export const methodHandlers = { wallet_getAllSnaps: getAllSnapsHandler, @@ -27,6 +29,7 @@ export const methodHandlers = { snap_resolveInterface: resolveInterfaceHandler, snap_getCurrencyRate: getCurrencyRateHandler, snap_experimentalProviderRequest: providerRequestHandler, + snap_requestDevice: requestDeviceHandler, }; /* eslint-enable @typescript-eslint/naming-convention */ 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..e49c542bb2 --- /dev/null +++ b/packages/snaps-rpc-methods/src/permitted/requestDevice.ts @@ -0,0 +1,65 @@ +import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; +import type { PermittedHandlerExport } from '@metamask/permission-controller'; +import type { + JsonRpcRequest, + ProviderRequestParams, + ProviderRequestResult, +} from '@metamask/snaps-sdk'; +import { type InferMatching } from '@metamask/snaps-utils'; +import { object, optional, string, type } from '@metamask/superstruct'; +import { + type PendingJsonRpcResponse, + CaipChainIdStruct, + JsonRpcParamsStruct, +} from '@metamask/utils'; + +import type { MethodHooksObject } from '../utils'; + +const hookNames: MethodHooksObject = { + requestDevices: true, +}; + +export type ProviderRequestMethodHooks = { + requestDevices: () => any; +}; + +export const providerRequestHandler: PermittedHandlerExport< + ProviderRequestMethodHooks, + ProviderRequestParameters, + ProviderRequestResult +> = { + methodNames: ['snap_requestDevice'], + implementation: providerRequestImplementation, + hookNames, +}; + +const ProviderRequestParametersStruct = object({ + chainId: CaipChainIdStruct, + request: type({ + method: string(), + params: optional(JsonRpcParamsStruct), + }), +}); + +export type ProviderRequestParameters = InferMatching< + typeof ProviderRequestParametersStruct, + ProviderRequestParams +>; + +async function providerRequestImplementation( + req: JsonRpcRequest, + res: PendingJsonRpcResponse, + _next: unknown, + end: JsonRpcEngineEndCallback, + { requestDevices }: ProviderRequestMethodHooks, +): Promise { + const { params } = req; + + try { + res.result = await requestDevices(); + } catch (error) { + return end(error); + } + + return end(); +} From ee1f1598630dbac6a99df009996b47abb2b9bb04 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Fri, 11 Oct 2024 15:38:46 +0200 Subject: [PATCH 02/15] Add basic pairing flow --- .../src/devices/DeviceController.ts | 90 +++++++++++++++---- 1 file changed, 75 insertions(+), 15 deletions(-) diff --git a/packages/snaps-controllers/src/devices/DeviceController.ts b/packages/snaps-controllers/src/devices/DeviceController.ts index 5009a20efe..ba821cf405 100644 --- a/packages/snaps-controllers/src/devices/DeviceController.ts +++ b/packages/snaps-controllers/src/devices/DeviceController.ts @@ -4,23 +4,17 @@ import type { ControllerStateChangeEvent, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; -import type { GetPermissions } from '@metamask/permission-controller'; -import type { DeleteInterface } from '../interface'; -import type { GetAllSnaps, HandleSnapRequest } from '../snaps'; import type { TransactionControllerUnapprovedTransactionAddedEvent, SignatureStateChange, TransactionControllerTransactionStatusUpdatedEvent, } from '../types'; +import { createDeferredPromise } from '@metamask/utils'; const controllerName = 'DeviceController'; -export type DeviceControllerAllowedActions = - | HandleSnapRequest - | GetAllSnaps - | GetPermissions - | DeleteInterface; +export type DeviceControllerAllowedActions = never; export type DeviceControllerGetStateAction = ControllerGetStateAction< typeof controllerName, @@ -49,10 +43,18 @@ export type DeviceControllerMessenger = RestrictedControllerMessenger< DeviceControllerAllowedEvents['type'] >; -export type Device = {}; +export enum DeviceType { + HID, + Bluetooth, +} + +export type Device = { + type: DeviceType; +}; export type DeviceControllerState = { devices: Record; + pairing?: { snapId: string }; }; export type DeviceControllerArgs = { @@ -67,24 +69,82 @@ export class DeviceController extends BaseController< DeviceControllerState, DeviceControllerMessenger > { + #pairing?: { + promise: Promise; + resolve: (result: unknown) => void; + reject: (error: unknown) => void; + }; + constructor({ messenger, state }: DeviceControllerArgs) { super({ messenger, metadata: { devices: { persist: true, anonymous: false }, + pairing: { persist: false, anonymous: false }, }, name: controllerName, state: { ...state, devices: {} }, }); } - async requestDevices() { - const devices = await (navigator as any).hid.requestDevice({ filters: [] }); - return devices; + async requestDevices(snapId: string) { + const device = await this.#requestPairing({ snapId }); + + console.log('Paired device', device); + // TODO: Persist device + // TODO: Grant permission to use device + } + + async #hasPermission(snapId: string, device: Device) { + // TODO: Verify Snap has permission to use device. + return true; + } + + // Get actually connected devices + async #getDevices() { + // TODO: Merge multiple device implementations + return (navigator as any).hid.getDevices(); } - async getDevices() { - const devices = await (navigator as any).hid.getDevices(); - return devices; + #isPairing() { + return this.#pairing !== undefined; + } + + async #requestPairing({ snapId }: { snapId: string }) { + 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 }; + }); + + return promise; + } + + resolvePairing(device: unknown) { + if (!this.#isPairing()) { + return; + } + + this.#pairing?.resolve(device); + this.update((draftState) => { + delete draftState.pairing; + }); + } + + rejectPairing() { + if (!this.#isPairing()) { + return; + } + + this.#pairing?.reject(new Error('Pairing rejected')); + this.update((draftState) => { + delete draftState.pairing; + }); } } From e9b95d33e99dabfc66a66a093a11fe83e5d3090e Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Fri, 11 Oct 2024 15:53:40 +0200 Subject: [PATCH 03/15] Add UI actions for pairing --- .../src/devices/DeviceController.ts | 36 +++++++++++++------ 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/packages/snaps-controllers/src/devices/DeviceController.ts b/packages/snaps-controllers/src/devices/DeviceController.ts index ba821cf405..e387352bfa 100644 --- a/packages/snaps-controllers/src/devices/DeviceController.ts +++ b/packages/snaps-controllers/src/devices/DeviceController.ts @@ -4,12 +4,6 @@ import type { ControllerStateChangeEvent, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; - -import type { - TransactionControllerUnapprovedTransactionAddedEvent, - SignatureStateChange, - TransactionControllerTransactionStatusUpdatedEvent, -} from '../types'; import { createDeferredPromise } from '@metamask/utils'; const controllerName = 'DeviceController'; @@ -21,7 +15,20 @@ export type DeviceControllerGetStateAction = ControllerGetStateAction< DeviceControllerState >; -export type DeviceControllerActions = DeviceControllerGetStateAction; +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; export type DeviceControllerStateChangeEvent = ControllerStateChangeEvent< typeof controllerName, @@ -30,10 +37,7 @@ export type DeviceControllerStateChangeEvent = ControllerStateChangeEvent< export type DeviceControllerEvents = DeviceControllerStateChangeEvent; -export type DeviceControllerAllowedEvents = - | TransactionControllerUnapprovedTransactionAddedEvent - | TransactionControllerTransactionStatusUpdatedEvent - | SignatureStateChange; +export type DeviceControllerAllowedEvents = never; export type DeviceControllerMessenger = RestrictedControllerMessenger< typeof controllerName, @@ -85,6 +89,16 @@ export class DeviceController extends BaseController< name: controllerName, state: { ...state, devices: {} }, }); + + this.messagingSystem.registerActionHandler( + `${controllerName}:resolvePairing`, + async (...args) => this.resolvePairing(...args), + ); + + this.messagingSystem.registerActionHandler( + `${controllerName}:rejectPairing`, + async (...args) => this.rejectPairing(...args), + ); } async requestDevices(snapId: string) { From 69fc8a79d5fe61aec0a20b13768674f73280d6b2 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 23 Oct 2024 12:51:35 +0200 Subject: [PATCH 04/15] More progress on pairing flow --- .../src/devices/DeviceController.ts | 81 +++++++++++++++---- 1 file changed, 64 insertions(+), 17 deletions(-) diff --git a/packages/snaps-controllers/src/devices/DeviceController.ts b/packages/snaps-controllers/src/devices/DeviceController.ts index e387352bfa..bd683b66a0 100644 --- a/packages/snaps-controllers/src/devices/DeviceController.ts +++ b/packages/snaps-controllers/src/devices/DeviceController.ts @@ -4,7 +4,7 @@ import type { ControllerStateChangeEvent, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; -import { createDeferredPromise } from '@metamask/utils'; +import { createDeferredPromise, hasProperty } from '@metamask/utils'; const controllerName = 'DeviceController'; @@ -48,17 +48,22 @@ export type DeviceControllerMessenger = RestrictedControllerMessenger< >; export enum DeviceType { - HID, - Bluetooth, + HID = 'HID', } -export type Device = { +export type DeviceMetadata = { type: DeviceType; + id: string; + name: string; +}; + +export type Device = DeviceMetadata & { + connected: boolean; }; export type DeviceControllerState = { devices: Record; - pairing?: { snapId: string }; + pairing: { snapId: string } | null; }; export type DeviceControllerArgs = { @@ -75,7 +80,7 @@ export class DeviceController extends BaseController< > { #pairing?: { promise: Promise; - resolve: (result: unknown) => void; + resolve: (result: string) => void; reject: (error: unknown) => void; }; @@ -87,7 +92,7 @@ export class DeviceController extends BaseController< pairing: { persist: false, anonymous: false }, }, name: controllerName, - state: { ...state, devices: {} }, + state: { ...state, devices: {}, pairing: null }, }); this.messagingSystem.registerActionHandler( @@ -102,11 +107,15 @@ export class DeviceController extends BaseController< } async requestDevices(snapId: string) { - const device = await this.#requestPairing({ snapId }); + const deviceId = await this.#requestPairing({ snapId }); + + await this.#syncDevices(); + + console.log('Granting access to', deviceId); - console.log('Paired device', device); - // TODO: Persist device // TODO: Grant permission to use device + + return null; } async #hasPermission(snapId: string, device: Device) { @@ -114,10 +123,42 @@ export class DeviceController extends BaseController< return true; } + async #syncDevices() { + const connectedDevices = await this.#getDevices(); + + this.update((draftState) => { + for (const device of Object.values(draftState.devices)) { + draftState.devices[device.id].connected = hasProperty( + connectedDevices, + device.id, + ); + } + for (const device of Object.values(connectedDevices)) { + if (!hasProperty(draftState.devices, device.id)) { + // @ts-expect-error Not sure why this is failing, continuing. + draftState.devices[device.id] = { ...device, connected: true }; + } + } + }); + } + // Get actually connected devices - async #getDevices() { + async #getDevices(): Promise> { + const type = DeviceType.HID; // TODO: Merge multiple device implementations - return (navigator as any).hid.getDevices(); + const devices: any[] = await (navigator as any).hid.getDevices(); + return devices.reduce>( + (accumulator, device) => { + const { vendorId, productId, productName } = device; + + const id = `${type}-${vendorId}-${productId}`; + + accumulator[id] = { type, id, name: productName }; + + return accumulator; + }, + {}, + ); } #isPairing() { @@ -130,9 +171,13 @@ export class DeviceController extends BaseController< throw new Error('A pairing is already underway.'); } - const { promise, resolve, reject } = createDeferredPromise(); + const { promise, resolve, reject } = createDeferredPromise(); this.#pairing = { promise, resolve, reject }; + + // TODO: Consider polling this call while pairing is ongoing? + await this.#syncDevices(); + this.update((draftState) => { draftState.pairing = { snapId }; }); @@ -140,14 +185,15 @@ export class DeviceController extends BaseController< return promise; } - resolvePairing(device: unknown) { + resolvePairing(deviceId: string) { if (!this.#isPairing()) { return; } - this.#pairing?.resolve(device); + this.#pairing?.resolve(deviceId); + this.#pairing = undefined; this.update((draftState) => { - delete draftState.pairing; + draftState.pairing = null; }); } @@ -157,8 +203,9 @@ export class DeviceController extends BaseController< } this.#pairing?.reject(new Error('Pairing rejected')); + this.#pairing = undefined; this.update((draftState) => { - delete draftState.pairing; + draftState.pairing = null; }); } } From 896b91cd5e8305eacd4a141b5b1e33173afbb15d Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Wed, 6 Nov 2024 12:18:18 +0100 Subject: [PATCH 05/15] Add device API (#2864) --- .../examples/packages/ledger/.depcheckrc.json | 18 ++ .../examples/packages/ledger/.eslintrc.js | 7 + .../examples/packages/ledger/CHANGELOG.md | 9 + .../examples/packages/ledger/LICENSE.APACHE2 | 201 +++++++++++++++ .../examples/packages/ledger/LICENSE.MIT0 | 16 ++ packages/examples/packages/ledger/README.md | 4 + .../examples/packages/ledger/jest.config.js | 36 +++ .../examples/packages/ledger/package.json | 90 +++++++ .../examples/packages/ledger/snap.config.ts | 13 + .../packages/ledger/snap.manifest.json | 25 ++ .../examples/packages/ledger/src/index.ts | 17 ++ .../examples/packages/ledger/src/transport.ts | 240 ++++++++++++++++++ .../examples/packages/ledger/tsconfig.json | 21 ++ .../src/endowments/devices.ts | 126 +++++++++ .../snaps-rpc-methods/src/endowments/enum.ts | 1 + .../snaps-rpc-methods/src/endowments/index.ts | 6 + .../src/permitted/handlers.ts | 7 +- .../snaps-rpc-methods/src/permitted/index.ts | 8 +- .../src/permitted/listDevices.ts | 76 ++++++ .../src/permitted/readDevice.ts | 86 +++++++ .../src/permitted/requestDevice.ts | 79 +++--- .../src/permitted/writeDevice.ts | 85 +++++++ packages/snaps-sdk/src/index.ts | 2 +- packages/snaps-sdk/src/types/device.ts | 94 +++++++ packages/snaps-sdk/src/types/index.ts | 1 + .../types/methods/get-supported-devices.ts | 11 + packages/snaps-sdk/src/types/methods/index.ts | 5 + .../src/types/methods/list-devices.ts | 17 ++ .../snaps-sdk/src/types/methods/methods.ts | 19 ++ .../src/types/methods/read-device.ts | 41 +++ .../src/types/methods/request-device.ts | 45 ++++ .../src/types/methods/write-device.ts | 40 +++ packages/snaps-utils/src/caveats.ts | 5 + packages/snaps-utils/src/devices.ts | 50 ++++ packages/snaps-utils/src/index.ts | 1 + yarn.lock | 96 +++++++ 36 files changed, 1561 insertions(+), 37 deletions(-) create mode 100644 packages/examples/packages/ledger/.depcheckrc.json create mode 100644 packages/examples/packages/ledger/.eslintrc.js create mode 100644 packages/examples/packages/ledger/CHANGELOG.md create mode 100644 packages/examples/packages/ledger/LICENSE.APACHE2 create mode 100644 packages/examples/packages/ledger/LICENSE.MIT0 create mode 100644 packages/examples/packages/ledger/README.md create mode 100644 packages/examples/packages/ledger/jest.config.js create mode 100644 packages/examples/packages/ledger/package.json create mode 100644 packages/examples/packages/ledger/snap.config.ts create mode 100644 packages/examples/packages/ledger/snap.manifest.json create mode 100644 packages/examples/packages/ledger/src/index.ts create mode 100644 packages/examples/packages/ledger/src/transport.ts create mode 100644 packages/examples/packages/ledger/tsconfig.json create mode 100644 packages/snaps-rpc-methods/src/endowments/devices.ts create mode 100644 packages/snaps-rpc-methods/src/permitted/listDevices.ts create mode 100644 packages/snaps-rpc-methods/src/permitted/readDevice.ts create mode 100644 packages/snaps-rpc-methods/src/permitted/writeDevice.ts create mode 100644 packages/snaps-sdk/src/types/device.ts create mode 100644 packages/snaps-sdk/src/types/methods/get-supported-devices.ts create mode 100644 packages/snaps-sdk/src/types/methods/list-devices.ts create mode 100644 packages/snaps-sdk/src/types/methods/read-device.ts create mode 100644 packages/snaps-sdk/src/types/methods/request-device.ts create mode 100644 packages/snaps-sdk/src/types/methods/write-device.ts create mode 100644 packages/snaps-utils/src/devices.ts diff --git a/packages/examples/packages/ledger/.depcheckrc.json b/packages/examples/packages/ledger/.depcheckrc.json new file mode 100644 index 0000000000..c437c59cd2 --- /dev/null +++ b/packages/examples/packages/ledger/.depcheckrc.json @@ -0,0 +1,18 @@ +{ + "ignore-patterns": ["dist", "coverage"], + "ignores": [ + "@lavamoat/allow-scripts", + "@lavamoat/preinstall-always-fail", + "@metamask/auto-changelog", + "@metamask/eslint-*", + "@types/*", + "@typescript-eslint/*", + "eslint-config-*", + "eslint-plugin-*", + "jest-silent-reporter", + "prettier-plugin-packagejson", + "ts-node", + "typedoc", + "typescript" + ] +} diff --git a/packages/examples/packages/ledger/.eslintrc.js b/packages/examples/packages/ledger/.eslintrc.js new file mode 100644 index 0000000000..a47fd0b65d --- /dev/null +++ b/packages/examples/packages/ledger/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['../../.eslintrc.js'], + + parserOptions: { + tsconfigRootDir: __dirname, + }, +}; diff --git a/packages/examples/packages/ledger/CHANGELOG.md b/packages/examples/packages/ledger/CHANGELOG.md new file mode 100644 index 0000000000..aa399df1be --- /dev/null +++ b/packages/examples/packages/ledger/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +[Unreleased]: https://github.com/MetaMask/snaps/ diff --git a/packages/examples/packages/ledger/LICENSE.APACHE2 b/packages/examples/packages/ledger/LICENSE.APACHE2 new file mode 100644 index 0000000000..5fb887469b --- /dev/null +++ b/packages/examples/packages/ledger/LICENSE.APACHE2 @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2024 ConsenSys Software Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/examples/packages/ledger/LICENSE.MIT0 b/packages/examples/packages/ledger/LICENSE.MIT0 new file mode 100644 index 0000000000..1a8536859a --- /dev/null +++ b/packages/examples/packages/ledger/LICENSE.MIT0 @@ -0,0 +1,16 @@ +MIT No Attribution + +Copyright 2024 ConsenSys Software Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this +software and associated documentation files (the "Software"), to deal in the Software +without restriction, including without limitation the rights to use, copy, modify, +merge, publish, distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/examples/packages/ledger/README.md b/packages/examples/packages/ledger/README.md new file mode 100644 index 0000000000..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..7e49707302 --- /dev/null +++ b/packages/examples/packages/ledger/package.json @@ -0,0 +1,90 @@ +{ + "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-transport": "^6.31.4", + "@metamask/snaps-sdk": "workspace:^", + "@metamask/utils": "^10.0.0" + }, + "devDependencies": { + "@jest/globals": "^29.5.0", + "@lavamoat/allow-scripts": "^3.0.4", + "@metamask/auto-changelog": "^3.4.4", + "@metamask/eslint-config": "^12.1.0", + "@metamask/eslint-config-jest": "^12.1.0", + "@metamask/eslint-config-nodejs": "^12.1.0", + "@metamask/eslint-config-typescript": "^12.1.0", + "@metamask/snaps-cli": "workspace:^", + "@metamask/snaps-jest": "workspace:^", + "@swc/core": "1.3.78", + "@swc/jest": "^0.2.26", + "@typescript-eslint/eslint-plugin": "^5.42.1", + "@typescript-eslint/parser": "^6.21.0", + "deepmerge": "^4.2.2", + "depcheck": "^1.4.7", + "eslint": "^8.27.0", + "eslint-config-prettier": "^8.5.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-jest": "^27.1.5", + "eslint-plugin-jsdoc": "^41.1.2", + "eslint-plugin-n": "^15.7.0", + "eslint-plugin-prettier": "^4.2.1", + "eslint-plugin-promise": "^6.1.1", + "jest": "^29.0.2", + "jest-silent-reporter": "^0.6.0", + "prettier": "^2.8.8", + "prettier-plugin-packagejson": "^2.5.2", + "ts-node": "^10.9.1", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.16 || >=20" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/examples/packages/ledger/snap.config.ts b/packages/examples/packages/ledger/snap.config.ts new file mode 100644 index 0000000000..1be4ccfa2b --- /dev/null +++ b/packages/examples/packages/ledger/snap.config.ts @@ -0,0 +1,13 @@ +import type { SnapConfig } from '@metamask/snaps-cli'; + +const config: SnapConfig = { + input: './src/index.ts', + server: { + port: 8032, + }, + stats: { + buffer: false, + }, +}; + +export default config; diff --git a/packages/examples/packages/ledger/snap.manifest.json b/packages/examples/packages/ledger/snap.manifest.json new file mode 100644 index 0000000000..cbf1a9233e --- /dev/null +++ b/packages/examples/packages/ledger/snap.manifest.json @@ -0,0 +1,25 @@ +{ + "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": "TSu0FIqVXvJG6WzqtKPx5kN2fjveQ8EypKCk/jAShmM=", + "location": { + "npm": { + "filePath": "dist/bundle.js", + "packageName": "@metamask/ledger-example-snap", + "registry": "https://registry.npmjs.org" + } + } + }, + "initialPermissions": { + "endowment:rpc": { + "dapps": true + } + }, + "manifestVersion": "0.1" +} diff --git a/packages/examples/packages/ledger/src/index.ts b/packages/examples/packages/ledger/src/index.ts new file mode 100644 index 0000000000..3a6254b88e --- /dev/null +++ b/packages/examples/packages/ledger/src/index.ts @@ -0,0 +1,17 @@ +import type { OnRpcRequestHandler } from '@metamask/snaps-sdk'; +import { MethodNotFoundError } from '@metamask/snaps-sdk'; + +import TransportSnapsHID from './transport'; + +export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { + switch (request.method) { + case 'request': + await TransportSnapsHID.request(); + return null; + + default: + throw new MethodNotFoundError({ + method: request.method, + }); + } +}; diff --git a/packages/examples/packages/ledger/src/transport.ts b/packages/examples/packages/ledger/src/transport.ts new file mode 100644 index 0000000000..9c378e03e8 --- /dev/null +++ b/packages/examples/packages/ledger/src/transport.ts @@ -0,0 +1,240 @@ +import type { DeviceModel } from '@ledgerhq/devices'; +import { identifyUSBProductId, ledgerUSBVendorId } from '@ledgerhq/devices'; +import hidFraming from '@ledgerhq/devices/hid-framing'; +import { TransportOpenUserCancelled } from '@ledgerhq/errors'; +import type { + DescriptorEvent, + Observer, + Subscription, +} from '@ledgerhq/hw-transport'; +import Transport from '@ledgerhq/hw-transport'; +import type { HidDevice } from '@metamask/snaps-sdk'; +import { bytesToHex } from '@metamask/utils'; + +/** + * Request a Ledger device using Snaps. + * + * @returns A promise that resolves to a device, or `null` if no device was + * provided. + */ +async function requestDevice() { + return (await snap.request({ + method: 'snap_requestDevice', + params: { type: 'hid', filters: [{ vendorId: ledgerUSBVendorId }] }, + })) as HidDevice; +} + +export default class TransportSnapsHID extends Transport { + readonly device: HidDevice; + + readonly deviceModel: DeviceModel | null | undefined; + + #channel = Math.floor(Math.random() * 0xffff); + + #packetSize = 64; + + constructor(device: HidDevice) { + super(); + + this.device = device; + this.deviceModel = identifyUSBProductId(device.productId); + } + + /** + * Check if the transport is supported by the current environment. + * + * @returns A promise that resolves to `true` if the transport is supported, + * or `false` otherwise. + */ + static async isSupported() { + const types = await snap.request({ + method: 'snap_getSupportedDevices', + }); + + return types.includes('hid'); + } + + /** + * List the HID devices that were previously authorised by the user. + * + * @returns A promise that resolves to an array of devices. + */ + static async list() { + const devices = (await snap.request({ + method: 'snap_listDevices', + params: { type: 'hid' }, + })) as HidDevice[]; + + return devices.filter( + (device) => device.vendorId === ledgerUSBVendorId && device.available, + ); + } + + /** + * Get the first Ledger device that was previously authorised by the user, or + * request a new device if none are available. + * + * @param observer - The observer to notify when a device is found. + * @returns A subscription that can be used to unsubscribe from the observer. + */ + static listen(observer: Observer>): Subscription { + let unsubscribed = false; + + /** + * Unsubscribe from the subscription. + */ + function unsubscribe() { + unsubscribed = true; + } + + /** + * Emit a device to the observer. + * + * @param device - The device to emit. + */ + function emit(device: HidDevice) { + observer.next({ + type: 'add', + descriptor: device, + deviceModel: identifyUSBProductId(device.productId), + }); + + observer.complete(); + } + + this.list() + .then((devices) => { + if (unsubscribed) { + return; + } + + if (devices.length > 0) { + emit(devices[0]); + return; + } + + requestDevice() + .then((device) => { + if (unsubscribed) { + return; + } + + if (!device) { + observer.error( + new TransportOpenUserCancelled( + 'No device was provided to connect to.', + ), + ); + + return; + } + + emit(device); + }) + .catch((error) => { + observer.error(new TransportOpenUserCancelled(error.message)); + }); + }) + .catch((error) => { + observer.error(new TransportOpenUserCancelled(error.message)); + }); + + return { unsubscribe }; + } + + /** + * Request to connect to a Ledger device. This will always prompt the user to + * connect a device. + * + * @returns A promise that resolves to a transport. + */ + static async request() { + const device = await requestDevice(); + if (!device) { + throw new TransportOpenUserCancelled( + 'No device was provided to connect to.', + ); + } + + return this.open(device); + } + + /** + * Create a transport with a previously connected device. Returns `null` if no + * device was found. + * + * @returns A promise that resolves to a transport, or `null` if no device was + * found. + */ + static async openConnected() { + const devices = await this.list(); + if (devices.length > 0) { + return this.open(devices[0]); + } + + return null; + } + + /** + * Create a transport with a specific device. + * + * @param device - The device to connect to. + * @returns A transport. + */ + static async open(device: HidDevice) { + return new TransportSnapsHID(device); + } + + /** + * Close the connection to the transport device. + */ + async close() { + // Snaps devices cannot be closed. + } + + /** + * Exchange with the device using APDU protocol. + * + * @param apdu - The APDU command to send to the device. + * @returns The response from the device. + */ + exchange = async (apdu: Buffer): Promise => { + return await this.exchangeAtomicImpl(async () => { + const framing = hidFraming(this.#channel, this.#packetSize); + const blocks = framing.makeBlocks(apdu); + + for (const block of blocks) { + await snap.request({ + method: 'snap_writeDevice', + params: { + type: 'hid', + id: this.device.id, + data: bytesToHex(block), + }, + }); + } + + let result; + let accumulator = null; + + while (!(result = framing.getReducedResult(accumulator))) { + const bytes = await snap.request({ + method: 'snap_readDevice', + params: { + type: 'hid', + id: this.device.id, + }, + }); + + const buffer = Buffer.from(bytes, 'hex'); + accumulator = framing.reduceResponse(accumulator, buffer); + } + + return result; + }); + }; + + setScrambleKey() { + // This transport does not support setting a scramble key. + } +} diff --git a/packages/examples/packages/ledger/tsconfig.json b/packages/examples/packages/ledger/tsconfig.json new file mode 100644 index 0000000000..1cb4c3315f --- /dev/null +++ b/packages/examples/packages/ledger/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": "./", + "paths": { + "@metamask/*": ["../../../*/src"] + } + }, + "include": ["src", "snap.config.ts"], + "references": [ + { + "path": "../../../snaps-sdk" + }, + { + "path": "../../../snaps-jest" + }, + { + "path": "../../../snaps-cli" + } + ] +} diff --git a/packages/snaps-rpc-methods/src/endowments/devices.ts b/packages/snaps-rpc-methods/src/endowments/devices.ts new file mode 100644 index 0000000000..970cc7aeab --- /dev/null +++ b/packages/snaps-rpc-methods/src/endowments/devices.ts @@ -0,0 +1,126 @@ +import type { + Caveat, + CaveatSpecificationConstraint, + EndowmentGetterParams, + PermissionConstraint, + PermissionSpecificationBuilder, + ValidPermissionSpecification, +} from '@metamask/permission-controller'; +import { PermissionType, SubjectType } from '@metamask/permission-controller'; +import { rpcErrors } from '@metamask/rpc-errors'; +import type { DeviceSpecification } from '@metamask/snaps-utils'; +import { + SnapCaveatType, + isDeviceSpecificationArray, +} from '@metamask/snaps-utils'; +import { hasProperty, isPlainObject, assert } from '@metamask/utils'; + +import { SnapEndowments } from './enum'; + +const permissionName = SnapEndowments.Devices; + +type DevicesEndowmentSpecification = ValidPermissionSpecification<{ + permissionType: PermissionType.Endowment; + targetName: typeof permissionName; + endowmentGetter: (_options?: any) => null; + allowedCaveats: [SnapCaveatType.DeviceIds]; +}>; + +/** + * The `endowment:devices` permission is 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], + }; +}; + +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') || !isPlainObject(value)) { + throw rpcErrors.invalidParams({ + message: 'Expected a plain object.', + }); + } + + if (!isDeviceSpecificationArray(value.jobs)) { + throw rpcErrors.invalidParams({ + message: 'Expected a valid device specification array.', + }); + } +} + +/** + * Caveat specification for the device IDs caveat. + */ +export const deviceIdsCaveatSpecifications: Record< + SnapCaveatType.DeviceIds, + CaveatSpecificationConstraint +> = { + [SnapCaveatType.DeviceIds]: Object.freeze({ + type: SnapCaveatType.DeviceIds, + validator: (caveat) => validateDeviceIdsCaveat(caveat), + }), +}; diff --git a/packages/snaps-rpc-methods/src/endowments/enum.ts b/packages/snaps-rpc-methods/src/endowments/enum.ts index f0d1577c6f..40d8bb651f 100644 --- a/packages/snaps-rpc-methods/src/endowments/enum.ts +++ b/packages/snaps-rpc-methods/src/endowments/enum.ts @@ -10,4 +10,5 @@ export enum SnapEndowments { LifecycleHooks = 'endowment:lifecycle-hooks', Keyring = 'endowment:keyring', HomePage = 'endowment:page-home', + Devices = 'endowment:devices', } diff --git a/packages/snaps-rpc-methods/src/endowments/index.ts b/packages/snaps-rpc-methods/src/endowments/index.ts index faa0c8fe06..b96e3bd7d6 100644 --- a/packages/snaps-rpc-methods/src/endowments/index.ts +++ b/packages/snaps-rpc-methods/src/endowments/index.ts @@ -12,6 +12,10 @@ import { cronjobEndowmentBuilder, getCronjobCaveatMapper, } from './cronjob'; +import { + deviceIdsCaveatSpecifications, + devicesEndowmentBuilder, +} from './devices'; import { ethereumProviderEndowmentBuilder } from './ethereum-provider'; import { homePageEndowmentBuilder } from './home-page'; import { @@ -48,6 +52,7 @@ export const endowmentPermissionBuilders = { [transactionInsightEndowmentBuilder.targetName]: transactionInsightEndowmentBuilder, [cronjobEndowmentBuilder.targetName]: cronjobEndowmentBuilder, + [devicesEndowmentBuilder.targetName]: devicesEndowmentBuilder, [ethereumProviderEndowmentBuilder.targetName]: ethereumProviderEndowmentBuilder, [rpcEndowmentBuilder.targetName]: rpcEndowmentBuilder, @@ -62,6 +67,7 @@ export const endowmentPermissionBuilders = { export const endowmentCaveatSpecifications = { ...cronjobCaveatSpecifications, + ...deviceIdsCaveatSpecifications, ...transactionInsightCaveatSpecifications, ...rpcCaveatSpecifications, ...nameLookupCaveatSpecifications, diff --git a/packages/snaps-rpc-methods/src/permitted/handlers.ts b/packages/snaps-rpc-methods/src/permitted/handlers.ts index 37b0771190..05f72b9285 100644 --- a/packages/snaps-rpc-methods/src/permitted/handlers.ts +++ b/packages/snaps-rpc-methods/src/permitted/handlers.ts @@ -1,6 +1,5 @@ import { createInterfaceHandler } from './createInterface'; import { providerRequestHandler } from './experimentalProviderRequest'; -import { providerRequestHandler as requestDeviceHandler } from './requestDevice'; import { getAllSnapsHandler } from './getAllSnaps'; import { getClientStatusHandler } from './getClientStatus'; import { getCurrencyRateHandler } from './getCurrencyRate'; @@ -9,10 +8,12 @@ import { getInterfaceStateHandler } from './getInterfaceState'; import { getSnapsHandler } from './getSnaps'; import { invokeKeyringHandler } from './invokeKeyring'; import { invokeSnapSugarHandler } from './invokeSnapSugar'; +import { readDeviceHandler } from './readDevice'; +import { requestDeviceHandler } from './requestDevice'; import { requestSnapsHandler } from './requestSnaps'; import { resolveInterfaceHandler } from './resolveInterface'; import { updateInterfaceHandler } from './updateInterface'; - +import { writeDeviceHandler } from './writeDevice'; /* eslint-disable @typescript-eslint/naming-convention */ export const methodHandlers = { @@ -29,7 +30,9 @@ export const methodHandlers = { snap_resolveInterface: resolveInterfaceHandler, snap_getCurrencyRate: getCurrencyRateHandler, snap_experimentalProviderRequest: providerRequestHandler, + snap_readDevice: readDeviceHandler, snap_requestDevice: requestDeviceHandler, + snap_writeDevice: writeDeviceHandler, }; /* eslint-enable @typescript-eslint/naming-convention */ diff --git a/packages/snaps-rpc-methods/src/permitted/index.ts b/packages/snaps-rpc-methods/src/permitted/index.ts index 5aa676fce3..155b211935 100644 --- a/packages/snaps-rpc-methods/src/permitted/index.ts +++ b/packages/snaps-rpc-methods/src/permitted/index.ts @@ -5,9 +5,12 @@ import type { GetClientStatusHooks } from './getClientStatus'; import type { GetCurrencyRateMethodHooks } from './getCurrencyRate'; import type { GetInterfaceStateMethodHooks } from './getInterfaceState'; import type { GetSnapsHooks } from './getSnaps'; +import type { ReadDeviceHooks } from './readDevice'; +import type { RequestDeviceHooks } from './requestDevice'; import type { RequestSnapsHooks } from './requestSnaps'; import type { ResolveInterfaceMethodHooks } from './resolveInterface'; import type { UpdateInterfaceMethodHooks } from './updateInterface'; +import type { WriteDeviceHooks } from './writeDevice'; export type PermittedRpcMethodHooks = GetAllSnapsHooks & GetClientStatusHooks & @@ -18,7 +21,10 @@ export type PermittedRpcMethodHooks = GetAllSnapsHooks & GetInterfaceStateMethodHooks & ResolveInterfaceMethodHooks & GetCurrencyRateMethodHooks & - ProviderRequestMethodHooks; + ProviderRequestMethodHooks & + ReadDeviceHooks & + RequestDeviceHooks & + WriteDeviceHooks; export * from './handlers'; export * from './middleware'; diff --git a/packages/snaps-rpc-methods/src/permitted/listDevices.ts b/packages/snaps-rpc-methods/src/permitted/listDevices.ts new file mode 100644 index 0000000000..eb14e87a6f --- /dev/null +++ b/packages/snaps-rpc-methods/src/permitted/listDevices.ts @@ -0,0 +1,76 @@ +import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; +import type { PermittedHandlerExport } from '@metamask/permission-controller'; +import type { + JsonRpcRequest, + ListDevicesParams, + ListDevicesResult, +} from '@metamask/snaps-sdk'; +import type { InferMatching } from '@metamask/snaps-utils'; +import { array, literal, object, optional, union } from '@metamask/superstruct'; +import { assertStruct, type PendingJsonRpcResponse } from '@metamask/utils'; + +import type { MethodHooksObject } from '../utils'; + +const hookNames: MethodHooksObject = { + listDevices: true, +}; + +export type ListDevicesHooks = { + /** + * A hook to list the available devices. + * + * @param params - The parameters for reading data from the device. + * @returns The data read from the device. + */ + listDevices: (params: ListDevicesParams) => Promise; +}; + +export const listDevicesHandler: PermittedHandlerExport< + ListDevicesHooks, + ListDevicesParams, + ListDevicesResult +> = { + methodNames: ['snap_listDevices'], + implementation: listDevicesImplementation, + hookNames, +}; + +const ListDevicesParametersStruct = object({ + type: optional(union([literal('hid'), array(literal('hid'))])), +}); + +export type ListDevicesParameters = InferMatching< + typeof ListDevicesParametersStruct, + ListDevicesParams +>; + +/** + * Handles the `snap_listDevices` method. + * + * @param request - The JSON-RPC request object. + * @param response - The JSON-RPC response object. + * @param _next - The `json-rpc-engine` "next" callback. Not used by this + * method. + * @param end - The `json-rpc-engine` "end" callback. + * @param hooks - The RPC method hooks. + * @param hooks.listDevices - The function to read data from a device. + * @returns Nothing. + */ +async function listDevicesImplementation( + request: JsonRpcRequest, + response: PendingJsonRpcResponse, + _next: unknown, + end: JsonRpcEngineEndCallback, + { listDevices }: ListDevicesHooks, +): Promise { + const { params } = request; + assertStruct(params, ListDevicesParametersStruct); + + try { + response.result = await listDevices(params); + } catch (error) { + return end(error); + } + + return end(); +} diff --git a/packages/snaps-rpc-methods/src/permitted/readDevice.ts b/packages/snaps-rpc-methods/src/permitted/readDevice.ts new file mode 100644 index 0000000000..9d864fc376 --- /dev/null +++ b/packages/snaps-rpc-methods/src/permitted/readDevice.ts @@ -0,0 +1,86 @@ +import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; +import type { PermittedHandlerExport } from '@metamask/permission-controller'; +import type { + JsonRpcRequest, + ReadDeviceParams, + ReadDeviceResult, +} from '@metamask/snaps-sdk'; +import { deviceId } from '@metamask/snaps-sdk'; +import type { InferMatching } from '@metamask/snaps-utils'; +import { + literal, + number, + object, + optional, + union, +} from '@metamask/superstruct'; +import { assertStruct, type PendingJsonRpcResponse } from '@metamask/utils'; + +import type { MethodHooksObject } from '../utils'; + +const hookNames: MethodHooksObject = { + readDevice: true, +}; + +export type ReadDeviceHooks = { + /** + * A hook to 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('hid'), + reportType: union([literal('output'), literal('feature')]), + reportId: optional(number()), +}); + +export type ReadDeviceParameters = InferMatching< + typeof ReadDeviceParametersStruct, + ReadDeviceParams +>; + +/** + * Handles the `snap_readDevice` method. + * + * @param request - The JSON-RPC request object. + * @param response - The JSON-RPC response object. + * @param _next - The `json-rpc-engine` "next" callback. Not used by this + * method. + * @param end - The `json-rpc-engine` "end" callback. + * @param hooks - The RPC method hooks. + * @param hooks.readDevice - The function to read data from a device. + * @returns Nothing. + */ +async function readDeviceImplementation( + request: JsonRpcRequest, + response: PendingJsonRpcResponse, + _next: unknown, + end: JsonRpcEngineEndCallback, + { readDevice }: ReadDeviceHooks, +): Promise { + const { params } = request; + assertStruct(params, ReadDeviceParametersStruct); + + try { + response.result = await readDevice(params); + } catch (error) { + return end(error); + } + + return end(); +} diff --git a/packages/snaps-rpc-methods/src/permitted/requestDevice.ts b/packages/snaps-rpc-methods/src/permitted/requestDevice.ts index e49c542bb2..40ee9b6218 100644 --- a/packages/snaps-rpc-methods/src/permitted/requestDevice.ts +++ b/packages/snaps-rpc-methods/src/permitted/requestDevice.ts @@ -2,61 +2,74 @@ import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; import type { PermittedHandlerExport } from '@metamask/permission-controller'; import type { JsonRpcRequest, - ProviderRequestParams, - ProviderRequestResult, + RequestDeviceParams, + RequestDeviceResult, } from '@metamask/snaps-sdk'; -import { type InferMatching } from '@metamask/snaps-utils'; -import { object, optional, string, type } from '@metamask/superstruct'; -import { - type PendingJsonRpcResponse, - CaipChainIdStruct, - JsonRpcParamsStruct, -} from '@metamask/utils'; +import { DeviceFilterStruct, DeviceTypeStruct } from '@metamask/snaps-sdk'; +import type { InferMatching } from '@metamask/snaps-utils'; +import { object, optional, array } from '@metamask/superstruct'; +import { assertStruct, type PendingJsonRpcResponse } from '@metamask/utils'; import type { MethodHooksObject } from '../utils'; -const hookNames: MethodHooksObject = { - requestDevices: true, +const hookNames: MethodHooksObject = { + requestDevice: true, }; -export type ProviderRequestMethodHooks = { - requestDevices: () => any; +export type RequestDeviceHooks = { + /** + * A hook to request a device. + * + * @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 providerRequestHandler: PermittedHandlerExport< - ProviderRequestMethodHooks, - ProviderRequestParameters, - ProviderRequestResult +export const requestDeviceHandler: PermittedHandlerExport< + RequestDeviceHooks, + RequestDeviceParams, + RequestDeviceResult > = { methodNames: ['snap_requestDevice'], - implementation: providerRequestImplementation, + implementation: requestDeviceImplementation, hookNames, }; -const ProviderRequestParametersStruct = object({ - chainId: CaipChainIdStruct, - request: type({ - method: string(), - params: optional(JsonRpcParamsStruct), - }), +const RequestDeviceParametersStruct = object({ + type: DeviceTypeStruct, + filters: optional(array(DeviceFilterStruct)), }); -export type ProviderRequestParameters = InferMatching< - typeof ProviderRequestParametersStruct, - ProviderRequestParams +export type RequestDeviceParameters = InferMatching< + typeof RequestDeviceParametersStruct, + RequestDeviceParams >; -async function providerRequestImplementation( - req: JsonRpcRequest, - res: PendingJsonRpcResponse, +/** + * Handles the `snap_requestDevice` method. + * + * @param request - The JSON-RPC request object. + * @param response - The JSON-RPC response object. + * @param _next - The `json-rpc-engine` "next" callback. Not used by this + * method. + * @param end - The `json-rpc-engine` "end" callback. + * @param hooks - The RPC method hooks. + * @param hooks.requestDevice - The function to request a device. + * @returns Nothing. + */ +async function requestDeviceImplementation( + request: JsonRpcRequest, + response: PendingJsonRpcResponse, _next: unknown, end: JsonRpcEngineEndCallback, - { requestDevices }: ProviderRequestMethodHooks, + { requestDevice }: RequestDeviceHooks, ): Promise { - const { params } = req; + const { params } = request; + assertStruct(params, RequestDeviceParametersStruct); try { - res.result = await requestDevices(); + response.result = await requestDevice(params); } catch (error) { return end(error); } diff --git a/packages/snaps-rpc-methods/src/permitted/writeDevice.ts b/packages/snaps-rpc-methods/src/permitted/writeDevice.ts new file mode 100644 index 0000000000..3058f0a3a1 --- /dev/null +++ b/packages/snaps-rpc-methods/src/permitted/writeDevice.ts @@ -0,0 +1,85 @@ +import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; +import type { PermittedHandlerExport } from '@metamask/permission-controller'; +import type { + JsonRpcRequest, + WriteDeviceParams, + WriteDeviceResult, +} from '@metamask/snaps-sdk'; +import { deviceId } from '@metamask/snaps-sdk'; +import type { InferMatching } from '@metamask/snaps-utils'; +import { literal, number, object, optional } from '@metamask/superstruct'; +import { + assertStruct, + type PendingJsonRpcResponse, + StrictHexStruct, +} from '@metamask/utils'; + +import type { MethodHooksObject } from '../utils'; + +const hookNames: MethodHooksObject = { + writeDevice: true, +}; + +export type WriteDeviceHooks = { + /** + * A hook to 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('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; + assertStruct(params, WriteDeviceParametersStruct); + + try { + response.result = await writeDevice(params); + } catch (error) { + return end(error); + } + + return end(); +} diff --git a/packages/snaps-sdk/src/index.ts b/packages/snaps-sdk/src/index.ts index c90eb4c6fa..41cddbad65 100644 --- a/packages/snaps-sdk/src/index.ts +++ b/packages/snaps-sdk/src/index.ts @@ -1,5 +1,5 @@ // Only internals that are used by other Snaps packages should be exported here. -export type { EnumToUnion } from './internals'; +export type { Describe, EnumToUnion } from './internals'; export { getErrorData, getErrorMessage, diff --git a/packages/snaps-sdk/src/types/device.ts b/packages/snaps-sdk/src/types/device.ts new file mode 100644 index 0000000000..5942a9739b --- /dev/null +++ b/packages/snaps-sdk/src/types/device.ts @@ -0,0 +1,94 @@ +import type { Struct } from '@metamask/superstruct'; +import { literal, refine, string } from '@metamask/superstruct'; + +import type { Describe } from '../internals'; + +/** + * The type of the device. Currently, only `hid` is supported. + */ +export type DeviceType = 'hid'; + +/** + * A struct that represents the `DeviceType` type. + */ +export const DeviceTypeStruct: Describe = literal('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 Device = { + /** + * The ID of the device. + */ + id: DeviceId; + + /** + * The type of the device. + */ + type: DeviceType; + + /** + * The name of the device. + */ + name: string; + + /** + * The vendor ID of the device. + */ + vendorId: number; + + /** + * The product ID of the device. + */ + productId: number; + + /** + * Whether the device is available. + */ + available: boolean; +}; + +type ScopedDevice = Device & { + type: Type; + id: ScopedDeviceId; +}; + +export type HidDevice = ScopedDevice<'hid'>; diff --git a/packages/snaps-sdk/src/types/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..6f33ec6648 --- /dev/null +++ b/packages/snaps-sdk/src/types/methods/get-supported-devices.ts @@ -0,0 +1,11 @@ +import type { DeviceType } from '@metamask/snaps-sdk'; + +/** + * The request parameters for the `snap_getSupportedDevices` method. + */ +export type GetSupportedDevicesParams = never; + +/** + * The result returned by the `snap_getSupportedDevices` method. + */ +export type GetSupportedDevicesResult = DeviceType[]; diff --git a/packages/snaps-sdk/src/types/methods/index.ts b/packages/snaps-sdk/src/types/methods/index.ts index 766910160c..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..6ccdf8bb57 --- /dev/null +++ b/packages/snaps-sdk/src/types/methods/list-devices.ts @@ -0,0 +1,17 @@ +import type { Device, 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?: DeviceType | DeviceType[]; +}; + +/** + * The result returned by the `snap_readDevice` method. This is a list of + * devices that are available to the Snap. + */ +export type ListDevicesResult = Device[]; diff --git a/packages/snaps-sdk/src/types/methods/methods.ts b/packages/snaps-sdk/src/types/methods/methods.ts index 4d9e1f33c6..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..34ab447813 --- /dev/null +++ b/packages/snaps-sdk/src/types/methods/read-device.ts @@ -0,0 +1,41 @@ +import type { Hex } from '@metamask/utils'; + +import type { ScopedDeviceId } from '../device'; + +/** + * The request parameters for the `snap_readDevice` method reading from an HID + * device. + */ +type HidReadParams = { + /** + * The type of the device. + */ + type: 'hid'; + + /** + * The ID of the device to read from. + */ + id: ScopedDeviceId<'hid'>; + + /** + * The type of the data to read. This is either an output report or a feature + * report. It defaults to `output` if not provided. + */ + reportType?: 'output' | 'feature'; + + /** + * The report ID to read from. This is only required for devices that use + * report IDs, and defaults to `0` if not provided. + */ + reportId?: number; +}; + +/** + * The request parameters for the `snap_readDevice` method. + */ +export type ReadDeviceParams = HidReadParams; + +/** + * The result returned by the `snap_readDevice` method. + */ +export type ReadDeviceResult = Hex; diff --git a/packages/snaps-sdk/src/types/methods/request-device.ts b/packages/snaps-sdk/src/types/methods/request-device.ts new file mode 100644 index 0000000000..f2df8cc6db --- /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 } from '../../internals'; +import type { Device, 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: DeviceType; + + /** + * 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 = Device | null; diff --git a/packages/snaps-sdk/src/types/methods/write-device.ts b/packages/snaps-sdk/src/types/methods/write-device.ts new file mode 100644 index 0000000000..07e9b58505 --- /dev/null +++ b/packages/snaps-sdk/src/types/methods/write-device.ts @@ -0,0 +1,40 @@ +import type { Hex } from '@metamask/utils'; + +import type { ScopedDeviceId } from '../device'; + +/** + * The request parameters for the `snap_writeDevice` method when writing to a + * HID device. + */ +type HidWriteParams = { + /** + * The type of the device. + */ + type: 'hid'; + + /** + * The ID of the device to write to. + */ + id: ScopedDeviceId<'hid'>; + + /** + * The data to write to the device. + */ + data: Hex; + + /** + * The report ID to write to. This is only required for devices that use + * report IDs, and defaults to `0` if not provided. + */ + reportId?: number; +}; + +/** + * The request parameters for the `snap_writeDevice` method. + */ +export type WriteDeviceParams = HidWriteParams; + +/** + * The result returned by the `snap_writeDevice` method. + */ +export type WriteDeviceResult = 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..f1152c3d31 --- /dev/null +++ b/packages/snaps-utils/src/devices.ts @@ -0,0 +1,50 @@ +import { deviceId } from '@metamask/snaps-sdk'; +import type { Infer } from '@metamask/superstruct'; +import { array, is, object } from '@metamask/superstruct'; + +export const DeviceSpecificationStruct = object({ + /** + * The device ID that the Snap has permission to access. + */ + deviceId: deviceId(), +}); + +/** + * A device specification, which is used as caveat value. + */ +export type DeviceSpecification = Infer; + +/** + * Check if the given value is a {@link DeviceSpecification} object. + * + * @param value - The value to check. + * @returns Whether the value is a {@link DeviceSpecification} object. + */ +export function isDeviceSpecification( + value: unknown, +): value is DeviceSpecification { + return is(value, DeviceSpecificationStruct); +} + +export const DeviceSpecificationArrayStruct = object({ + devices: array(DeviceSpecificationStruct), +}); + +/** + * A device specification array, which is used as caveat value. + */ +export type DeviceSpecificationArray = Infer< + typeof DeviceSpecificationArrayStruct +>; + +/** + * Check if the given value is a {@link DeviceSpecificationArray} object. + * + * @param value - The value to check. + * @returns Whether the value is a {@link DeviceSpecificationArray} object. + */ +export function isDeviceSpecificationArray( + value: unknown, +): value is DeviceSpecificationArray { + return is(value, DeviceSpecificationArrayStruct); +} diff --git a/packages/snaps-utils/src/index.ts b/packages/snaps-utils/src/index.ts index c3488c1d33..8f6d9d8658 100644 --- a/packages/snaps-utils/src/index.ts +++ b/packages/snaps-utils/src/index.ts @@ -10,6 +10,7 @@ export * from './currency'; export * from './deep-clone'; export * from './default-endowments'; export * from './derivation-paths'; +export * from './devices'; export * from './entropy'; export * from './errors'; export * from './handlers'; diff --git a/yarn.lock b/yarn.lock index e9770eb180..c8797cf43c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3807,6 +3807,44 @@ __metadata: languageName: node linkType: hard +"@ledgerhq/devices@npm:^8.4.4": + version: 8.4.4 + resolution: "@ledgerhq/devices@npm:8.4.4" + dependencies: + "@ledgerhq/errors": "npm:^6.19.1" + "@ledgerhq/logs": "npm:^6.12.0" + rxjs: "npm:^7.8.1" + semver: "npm:^7.3.5" + checksum: 10/57136fc45ae2fa42b3cf93eb7cc3542fd84010390b3d0a536d342c7e92f90e475d608b1774f17a547419edddd7df0d0b1b1dbd6d2c778009ebab0fc3ec313f67 + languageName: node + linkType: hard + +"@ledgerhq/errors@npm:^6.19.1": + version: 6.19.1 + resolution: "@ledgerhq/errors@npm:6.19.1" + checksum: 10/8265c6d73c314a4aabbe060ec29e2feebb4e904fe811bf7a9c53cde08e713dcbceded9d927ebb2f0ffc47a7b16524379d4a7e9aa3d61945b8a832be7cd5cf69b + languageName: node + linkType: hard + +"@ledgerhq/hw-transport@npm:^6.31.4": + version: 6.31.4 + resolution: "@ledgerhq/hw-transport@npm:6.31.4" + dependencies: + "@ledgerhq/devices": "npm:^8.4.4" + "@ledgerhq/errors": "npm:^6.19.1" + "@ledgerhq/logs": "npm:^6.12.0" + events: "npm:^3.3.0" + checksum: 10/cf101e5b818e95e59031241d556dbec24658f54104910e414be493bc4b90b0aea50f5d4b3339a237dd0b12845bb2683c845f3a82f2ea9da4e077b68d1e1f7e48 + languageName: node + linkType: hard + +"@ledgerhq/logs@npm:^6.12.0": + version: 6.12.0 + resolution: "@ledgerhq/logs@npm:6.12.0" + checksum: 10/a0a01f5d6edb0c14e7a42d24ab67ce362219517f6a50d0572c917f4f7988a1e2e9363e3d0fb170fe267f054e1e30a111564de44276e01c538b258d902c546421 + languageName: node + linkType: hard + "@leichtgewicht/ip-codec@npm:^2.0.1": version: 2.0.4 resolution: "@leichtgewicht/ip-codec@npm:2.0.4" @@ -5043,6 +5081,47 @@ __metadata: languageName: node linkType: hard +"@metamask/ledger-example-snap@workspace:packages/examples/packages/ledger": + version: 0.0.0-use.local + resolution: "@metamask/ledger-example-snap@workspace:packages/examples/packages/ledger" + dependencies: + "@jest/globals": "npm:^29.5.0" + "@lavamoat/allow-scripts": "npm:^3.0.4" + "@ledgerhq/devices": "npm:^8.4.4" + "@ledgerhq/errors": "npm:^6.19.1" + "@ledgerhq/hw-transport": "npm:^6.31.4" + "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/eslint-config": "npm:^12.1.0" + "@metamask/eslint-config-jest": "npm:^12.1.0" + "@metamask/eslint-config-nodejs": "npm:^12.1.0" + "@metamask/eslint-config-typescript": "npm:^12.1.0" + "@metamask/snaps-cli": "workspace:^" + "@metamask/snaps-jest": "workspace:^" + "@metamask/snaps-sdk": "workspace:^" + "@metamask/utils": "npm:^10.0.0" + "@swc/core": "npm:1.3.78" + "@swc/jest": "npm:^0.2.26" + "@typescript-eslint/eslint-plugin": "npm:^5.42.1" + "@typescript-eslint/parser": "npm:^6.21.0" + deepmerge: "npm:^4.2.2" + depcheck: "npm:^1.4.7" + eslint: "npm:^8.27.0" + eslint-config-prettier: "npm:^8.5.0" + eslint-plugin-import: "npm:^2.26.0" + eslint-plugin-jest: "npm:^27.1.5" + eslint-plugin-jsdoc: "npm:^41.1.2" + eslint-plugin-n: "npm:^15.7.0" + eslint-plugin-prettier: "npm:^4.2.1" + eslint-plugin-promise: "npm:^6.1.1" + jest: "npm:^29.0.2" + jest-silent-reporter: "npm:^0.6.0" + prettier: "npm:^2.8.8" + prettier-plugin-packagejson: "npm:^2.5.2" + ts-node: "npm:^10.9.1" + typescript: "npm:~5.3.3" + languageName: unknown + linkType: soft + "@metamask/lifecycle-hooks-example-snap@workspace:^, @metamask/lifecycle-hooks-example-snap@workspace:packages/examples/packages/lifecycle-hooks": version: 0.0.0-use.local resolution: "@metamask/lifecycle-hooks-example-snap@workspace:packages/examples/packages/lifecycle-hooks" @@ -6442,6 +6521,23 @@ __metadata: languageName: unknown linkType: soft +"@metamask/utils@npm:^10.0.0": + version: 10.0.0 + resolution: "@metamask/utils@npm:10.0.0" + dependencies: + "@ethereumjs/tx": "npm:^4.2.0" + "@metamask/superstruct": "npm:^3.1.0" + "@noble/hashes": "npm:^1.3.1" + "@scure/base": "npm:^1.1.3" + "@types/debug": "npm:^4.1.7" + debug: "npm:^4.3.4" + pony-cause: "npm:^2.1.10" + semver: "npm:^7.5.4" + uuid: "npm:^9.0.1" + checksum: 10/9c2e6421f685d8a45145b6026a6f9fd0701eb5a2e8490fc6d18e64c103d5a62097f301cbc797790da52ceb5853bd9f65845c934b00299e69e5e6736c52b32f0f + languageName: node + linkType: hard + "@metamask/utils@npm:^9.0.0, @metamask/utils@npm:^9.1.0, @metamask/utils@npm:^9.2.1": version: 9.2.1 resolution: "@metamask/utils@npm:9.2.1" From d91975ef3389a84ec4e7c0d940b0b223a888dcd9 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 6 Nov 2024 15:27:37 +0100 Subject: [PATCH 06/15] Add permissioning logic --- .../src/devices/DeviceController.ts | 66 +++++++++++++++---- .../src/endowments/devices.ts | 20 +++++- .../snaps-rpc-methods/src/endowments/index.ts | 1 + packages/snaps-utils/src/devices.ts | 4 +- 4 files changed, 76 insertions(+), 15 deletions(-) diff --git a/packages/snaps-controllers/src/devices/DeviceController.ts b/packages/snaps-controllers/src/devices/DeviceController.ts index bd683b66a0..b8b5aedcc7 100644 --- a/packages/snaps-controllers/src/devices/DeviceController.ts +++ b/packages/snaps-controllers/src/devices/DeviceController.ts @@ -4,11 +4,23 @@ import type { ControllerStateChangeEvent, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; +import type { + GetPermissions, + GrantPermissionsIncremental, +} from '@metamask/permission-controller'; +import { + SnapCaveatType, + SnapEndowments, + getPermittedDeviceIds, +} from '@metamask/snaps-rpc-methods'; +import type { DeviceId, SnapId } from '@metamask/snaps-sdk'; import { createDeferredPromise, hasProperty } from '@metamask/utils'; const controllerName = 'DeviceController'; -export type DeviceControllerAllowedActions = never; +export type DeviceControllerAllowedActions = + | GetPermissions + | GrantPermissionsIncremental; export type DeviceControllerGetStateAction = ControllerGetStateAction< typeof controllerName, @@ -48,12 +60,12 @@ export type DeviceControllerMessenger = RestrictedControllerMessenger< >; export enum DeviceType { - HID = 'HID', + HID = 'hid', } export type DeviceMetadata = { type: DeviceType; - id: string; + id: DeviceId; name: string; }; @@ -106,21 +118,53 @@ export class DeviceController extends BaseController< ); } - async requestDevices(snapId: string) { + async requestDevice(snapId: string) { const deviceId = await this.#requestPairing({ snapId }); await this.#syncDevices(); - console.log('Granting access to', deviceId); - - // TODO: Grant permission to use device + // 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 }] }, + }, + ], + }, + }, + }, + ); + // TODO: Return value return null; } - async #hasPermission(snapId: string, device: Device) { - // TODO: Verify Snap has permission to use device. - return true; + #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; + } + + async #hasPermission(snapId: SnapId, device: Device) { + const devices = this.#getPermittedDevices(snapId); + return devices.some( + (permittedDevice) => permittedDevice.deviceId === device.id, + ); } async #syncDevices() { @@ -151,7 +195,7 @@ export class DeviceController extends BaseController< (accumulator, device) => { const { vendorId, productId, productName } = device; - const id = `${type}-${vendorId}-${productId}`; + const id = `${type}:${vendorId}:${productId}` as DeviceId; accumulator[id] = { type, id, name: productName }; diff --git a/packages/snaps-rpc-methods/src/endowments/devices.ts b/packages/snaps-rpc-methods/src/endowments/devices.ts index 970cc7aeab..0ee9d985ea 100644 --- a/packages/snaps-rpc-methods/src/endowments/devices.ts +++ b/packages/snaps-rpc-methods/src/endowments/devices.ts @@ -105,7 +105,7 @@ export function validateDeviceIdsCaveat(caveat: Caveat) { }); } - if (!isDeviceSpecificationArray(value.jobs)) { + if (!isDeviceSpecificationArray(value.devices)) { throw rpcErrors.invalidParams({ message: 'Expected a valid device specification array.', }); @@ -122,5 +122,23 @@ export const deviceIdsCaveatSpecifications: Record< [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/index.ts b/packages/snaps-rpc-methods/src/endowments/index.ts index b96e3bd7d6..704bd385bc 100644 --- a/packages/snaps-rpc-methods/src/endowments/index.ts +++ b/packages/snaps-rpc-methods/src/endowments/index.ts @@ -123,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-utils/src/devices.ts b/packages/snaps-utils/src/devices.ts index f1152c3d31..611e964158 100644 --- a/packages/snaps-utils/src/devices.ts +++ b/packages/snaps-utils/src/devices.ts @@ -26,9 +26,7 @@ export function isDeviceSpecification( return is(value, DeviceSpecificationStruct); } -export const DeviceSpecificationArrayStruct = object({ - devices: array(DeviceSpecificationStruct), -}); +export const DeviceSpecificationArrayStruct = array(DeviceSpecificationStruct); /** * A device specification array, which is used as caveat value. From b55b0c666d61b77efc611479d8c749e149c3aacd Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 7 Nov 2024 15:00:51 +0100 Subject: [PATCH 07/15] Implement writeDevice and listDevices --- .../examples/packages/ledger/snap.config.ts | 5 +- .../packages/ledger/snap.manifest.json | 5 +- .../examples/packages/ledger/src/index.ts | 17 - .../examples/packages/ledger/src/index.tsx | 46 ++ .../src/devices/DeviceController.ts | 127 +++- .../src/types/methods/write-device.ts | 6 + yarn.lock | 618 +++++++++++++++++- 7 files changed, 779 insertions(+), 45 deletions(-) delete mode 100644 packages/examples/packages/ledger/src/index.ts create mode 100644 packages/examples/packages/ledger/src/index.tsx diff --git a/packages/examples/packages/ledger/snap.config.ts b/packages/examples/packages/ledger/snap.config.ts index 1be4ccfa2b..56bfb1ce00 100644 --- a/packages/examples/packages/ledger/snap.config.ts +++ b/packages/examples/packages/ledger/snap.config.ts @@ -1,10 +1,13 @@ import type { SnapConfig } from '@metamask/snaps-cli'; const config: SnapConfig = { - input: './src/index.ts', + input: './src/index.tsx', server: { port: 8032, }, + polyfills: { + buffer: true, + }, stats: { buffer: false, }, diff --git a/packages/examples/packages/ledger/snap.manifest.json b/packages/examples/packages/ledger/snap.manifest.json index cbf1a9233e..68aa9d2e7a 100644 --- a/packages/examples/packages/ledger/snap.manifest.json +++ b/packages/examples/packages/ledger/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "TSu0FIqVXvJG6WzqtKPx5kN2fjveQ8EypKCk/jAShmM=", + "shasum": "mm1QJa11tXxOH00wZe9kxpajdDBxklAJg96Bv2c2o7w=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -19,7 +19,8 @@ "initialPermissions": { "endowment:rpc": { "dapps": true - } + }, + "snap_dialog": {} }, "manifestVersion": "0.1" } diff --git a/packages/examples/packages/ledger/src/index.ts b/packages/examples/packages/ledger/src/index.ts deleted file mode 100644 index 3a6254b88e..0000000000 --- a/packages/examples/packages/ledger/src/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { OnRpcRequestHandler } from '@metamask/snaps-sdk'; -import { MethodNotFoundError } from '@metamask/snaps-sdk'; - -import TransportSnapsHID from './transport'; - -export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { - switch (request.method) { - case 'request': - await TransportSnapsHID.request(); - return null; - - default: - throw new MethodNotFoundError({ - method: request.method, - }); - } -}; diff --git a/packages/examples/packages/ledger/src/index.tsx b/packages/examples/packages/ledger/src/index.tsx new file mode 100644 index 0000000000..2491b053b2 --- /dev/null +++ b/packages/examples/packages/ledger/src/index.tsx @@ -0,0 +1,46 @@ +import type { + OnRpcRequestHandler, + OnUserInputHandler, +} from '@metamask/snaps-sdk'; +import { Box, Button } from '@metamask/snaps-sdk/jsx'; +import { MethodNotFoundError } from '@metamask/snaps-sdk'; +import Eth from '@ledgerhq/hw-app-eth'; + +import TransportSnapsHID from './transport'; + +export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { + switch (request.method) { + case 'request': { + return snap.request({ + method: 'snap_dialog', + params: { + content: ( + + + + ), + }, + }); + } + + default: + throw new MethodNotFoundError({ + method: request.method, + }); + } +}; + +export const onUserInput: OnUserInputHandler = async () => { + try { + const transport = await TransportSnapsHID.request(); + const eth = new Eth(transport); + console.log( + await eth.signPersonalMessage( + "44'/60'/0'/0/0", + Buffer.from('test').toString('hex'), + ), + ); + } catch (error) { + console.error(error); + } +}; diff --git a/packages/snaps-controllers/src/devices/DeviceController.ts b/packages/snaps-controllers/src/devices/DeviceController.ts index b8b5aedcc7..146f1e4056 100644 --- a/packages/snaps-controllers/src/devices/DeviceController.ts +++ b/packages/snaps-controllers/src/devices/DeviceController.ts @@ -8,13 +8,24 @@ import type { GetPermissions, GrantPermissionsIncremental, } from '@metamask/permission-controller'; +import { rpcErrors } from '@metamask/rpc-errors'; import { SnapCaveatType, SnapEndowments, getPermittedDeviceIds, } from '@metamask/snaps-rpc-methods'; -import type { DeviceId, SnapId } from '@metamask/snaps-sdk'; -import { createDeferredPromise, hasProperty } from '@metamask/utils'; +import type { + DeviceId, + ListDevicesParams, + ReadDeviceParams, + SnapId, + WriteDeviceParams, +} from '@metamask/snaps-sdk'; +import { + createDeferredPromise, + hasProperty, + hexToBytes, +} from '@metamask/utils'; const controllerName = 'DeviceController'; @@ -73,6 +84,11 @@ export type Device = DeviceMetadata & { connected: boolean; }; +export type ConnectedDevice = { + reference: any; // TODO: Type this + metadata: DeviceMetadata; +}; + export type DeviceControllerState = { devices: Record; pairing: { snapId: string } | null; @@ -91,8 +107,8 @@ export class DeviceController extends BaseController< DeviceControllerMessenger > { #pairing?: { - promise: Promise; - resolve: (result: string) => void; + promise: Promise; + resolve: (result: DeviceId) => void; reject: (error: unknown) => void; }; @@ -121,7 +137,7 @@ export class DeviceController extends BaseController< async requestDevice(snapId: string) { const deviceId = await this.#requestPairing({ snapId }); - await this.#syncDevices(); + // await this.#syncDevices(); // TODO: Figure out how to revoke these permissions again? this.messagingSystem.call( @@ -142,8 +158,71 @@ export class DeviceController extends BaseController< }, ); - // TODO: Return value - return null; + // TODO: Can a paired device by not connected? + const device = await this.#getConnectedDeviceById(deviceId); + return device.metadata; + } + + async writeDevice( + snapId: SnapId, + { id, reportId = 0, reportType, data }: WriteDeviceParams, + ) { + if (!this.#hasPermission(snapId, id)) { + // TODO: Decide on error message + throw rpcErrors.invalidParams(); + } + + const device = await this.#getConnectedDeviceById(id); + if (!device) { + // Handle + } + + const actualDevice = device.reference; + + if (!actualDevice.opened) { + await actualDevice.open(); + } + + if (reportType === 'feature') { + await actualDevice.sendFeatureReport(reportId, hexToBytes(data)); + } else { + await actualDevice.sendReport(reportId, hexToBytes(data)); + } + } + + async readDevice(snapId: SnapId, { id }: ReadDeviceParams) { + if (!this.#hasPermission(snapId, id)) { + // TODO: Decide on error message + throw rpcErrors.invalidParams(); + } + + const device = await this.#getConnectedDeviceById(id); + if (!device) { + // Handle + } + + const actualDevice = device.reference; + + if (!actualDevice.opened) { + await actualDevice.open(); + } + + // TODO: Actual read + } + + async listDevices(snapId: SnapId, { type }: ListDevicesParams) { + await this.#syncDevices(); + + 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) { @@ -157,18 +236,18 @@ export class DeviceController extends BaseController< const permission = permissions[SnapEndowments.Devices]; const devices = getPermittedDeviceIds(permission); - return devices; + return devices ?? []; } - async #hasPermission(snapId: SnapId, device: Device) { + #hasPermission(snapId: SnapId, deviceId: DeviceId) { const devices = this.#getPermittedDevices(snapId); return devices.some( - (permittedDevice) => permittedDevice.deviceId === device.id, + (permittedDevice) => permittedDevice.deviceId === deviceId, ); } async #syncDevices() { - const connectedDevices = await this.#getDevices(); + const connectedDevices = await this.#getConnectedDevices(); this.update((draftState) => { for (const device of Object.values(draftState.devices)) { @@ -178,26 +257,33 @@ export class DeviceController extends BaseController< ); } for (const device of Object.values(connectedDevices)) { - if (!hasProperty(draftState.devices, device.id)) { + if (!hasProperty(draftState.devices, device.metadata.id)) { // @ts-expect-error Not sure why this is failing, continuing. - draftState.devices[device.id] = { ...device, connected: true }; + draftState.devices[device.metadata.id] = { + ...device.metadata, + connected: true, + }; } } }); } // Get actually connected devices - async #getDevices(): Promise> { + async #getConnectedDevices(): Promise> { const type = DeviceType.HID; // TODO: Merge multiple device implementations const devices: any[] = await (navigator as any).hid.getDevices(); - return devices.reduce>( + return devices.reduce>( (accumulator, device) => { const { vendorId, productId, productName } = device; const id = `${type}:${vendorId}:${productId}` as DeviceId; - accumulator[id] = { type, id, name: productName }; + // TODO: Figure out what to do about duplicates. + accumulator[id] = { + reference: device, + metadata: { type, id, name: productName }, + }; return accumulator; }, @@ -205,6 +291,11 @@ export class DeviceController extends BaseController< ); } + async #getConnectedDeviceById(id: DeviceId) { + const devices = await this.#getConnectedDevices(); + return devices[id]; + } + #isPairing() { return this.#pairing !== undefined; } @@ -215,7 +306,7 @@ export class DeviceController extends BaseController< throw new Error('A pairing is already underway.'); } - const { promise, resolve, reject } = createDeferredPromise(); + const { promise, resolve, reject } = createDeferredPromise(); this.#pairing = { promise, resolve, reject }; @@ -229,7 +320,7 @@ export class DeviceController extends BaseController< return promise; } - resolvePairing(deviceId: string) { + resolvePairing(deviceId: DeviceId) { if (!this.#isPairing()) { return; } diff --git a/packages/snaps-sdk/src/types/methods/write-device.ts b/packages/snaps-sdk/src/types/methods/write-device.ts index 07e9b58505..05152d2c2e 100644 --- a/packages/snaps-sdk/src/types/methods/write-device.ts +++ b/packages/snaps-sdk/src/types/methods/write-device.ts @@ -17,6 +17,12 @@ type HidWriteParams = { */ id: ScopedDeviceId<'hid'>; + /** + * The type of the data to read. This is either an output report or a feature + * report. It defaults to `output` if not provided. + */ + reportType?: 'output' | 'feature'; + /** * The data to write to the device. */ diff --git a/yarn.lock b/yarn.lock index c8797cf43c..77fffefebf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3231,7 +3231,84 @@ __metadata: languageName: node linkType: hard -"@ethersproject/bignumber@npm:^5.7.0": +"@ethersproject/abi@npm:5.7.0, @ethersproject/abi@npm:^5.5.0, @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, @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, @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, @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, @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/basex@npm:5.7.0, @ethersproject/basex@npm:^5.7.0": + version: 5.7.0 + resolution: "@ethersproject/basex@npm:5.7.0" + dependencies: + "@ethersproject/bytes": "npm:^5.7.0" + "@ethersproject/properties": "npm:^5.7.0" + checksum: 10/840e333e109bff2fcf8d91dcfd45fa951835844ef0e1ba710037e87291c7b5f3c189ba86f6cee2ca7de2ede5b7d59fbb930346607695855bee20d2f9f63371ef + languageName: node + linkType: hard + +"@ethersproject/bignumber@npm:5.7.0, @ethersproject/bignumber@npm:^5.7.0": version: 5.7.0 resolution: "@ethersproject/bignumber@npm:5.7.0" dependencies: @@ -3242,7 +3319,7 @@ __metadata: languageName: node linkType: hard -"@ethersproject/bytes@npm:^5.7.0": +"@ethersproject/bytes@npm:5.7.0, @ethersproject/bytes@npm:^5.7.0": version: 5.7.0 resolution: "@ethersproject/bytes@npm:5.7.0" dependencies: @@ -3251,7 +3328,7 @@ __metadata: languageName: node linkType: hard -"@ethersproject/constants@npm:^5.7.0": +"@ethersproject/constants@npm:5.7.0, @ethersproject/constants@npm:^5.7.0": version: 5.7.0 resolution: "@ethersproject/constants@npm:5.7.0" dependencies: @@ -3260,14 +3337,243 @@ __metadata: languageName: node linkType: hard -"@ethersproject/logger@npm:^5.7.0": +"@ethersproject/contracts@npm:5.7.0": + version: 5.7.0 + resolution: "@ethersproject/contracts@npm:5.7.0" + dependencies: + "@ethersproject/abi": "npm:^5.7.0" + "@ethersproject/abstract-provider": "npm:^5.7.0" + "@ethersproject/abstract-signer": "npm:^5.7.0" + "@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/logger": "npm:^5.7.0" + "@ethersproject/properties": "npm:^5.7.0" + "@ethersproject/transactions": "npm:^5.7.0" + checksum: 10/5df66179af242faabea287a83fd2f8f303a4244dc87a6ff802e1e3b643f091451295c8e3d088c7739970b7915a16a581c192d4e007d848f1fdf3cc9e49010053 + languageName: node + linkType: hard + +"@ethersproject/hash@npm:5.7.0, @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/hdnode@npm:5.7.0, @ethersproject/hdnode@npm:^5.7.0": + version: 5.7.0 + resolution: "@ethersproject/hdnode@npm:5.7.0" + dependencies: + "@ethersproject/abstract-signer": "npm:^5.7.0" + "@ethersproject/basex": "npm:^5.7.0" + "@ethersproject/bignumber": "npm:^5.7.0" + "@ethersproject/bytes": "npm:^5.7.0" + "@ethersproject/logger": "npm:^5.7.0" + "@ethersproject/pbkdf2": "npm:^5.7.0" + "@ethersproject/properties": "npm:^5.7.0" + "@ethersproject/sha2": "npm:^5.7.0" + "@ethersproject/signing-key": "npm:^5.7.0" + "@ethersproject/strings": "npm:^5.7.0" + "@ethersproject/transactions": "npm:^5.7.0" + "@ethersproject/wordlists": "npm:^5.7.0" + checksum: 10/2fbe6278c324235afaa88baa5dea24d8674c72b14ad037fe2096134d41025977f410b04fd146e333a1b6cac9482e9de62d6375d1705fd42667543f2d0eb66655 + languageName: node + linkType: hard + +"@ethersproject/json-wallets@npm:5.7.0, @ethersproject/json-wallets@npm:^5.7.0": + version: 5.7.0 + resolution: "@ethersproject/json-wallets@npm:5.7.0" + dependencies: + "@ethersproject/abstract-signer": "npm:^5.7.0" + "@ethersproject/address": "npm:^5.7.0" + "@ethersproject/bytes": "npm:^5.7.0" + "@ethersproject/hdnode": "npm:^5.7.0" + "@ethersproject/keccak256": "npm:^5.7.0" + "@ethersproject/logger": "npm:^5.7.0" + "@ethersproject/pbkdf2": "npm:^5.7.0" + "@ethersproject/properties": "npm:^5.7.0" + "@ethersproject/random": "npm:^5.7.0" + "@ethersproject/strings": "npm:^5.7.0" + "@ethersproject/transactions": "npm:^5.7.0" + aes-js: "npm:3.0.0" + scrypt-js: "npm:3.0.1" + checksum: 10/4a1ef0912ffc8d18c392ae4e292948d86bffd715fe3dd3e66d1cd21f6c9267aeadad4da84261db853327f97cdfd765a377f9a87e39d4c6749223a69226faf0a1 + languageName: node + linkType: hard + +"@ethersproject/keccak256@npm:5.7.0, @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, @ethersproject/logger@npm:^5.7.0": version: 5.7.0 resolution: "@ethersproject/logger@npm:5.7.0" checksum: 10/683a939f467ae7510deedc23d7611d0932c3046137f5ffb92ba1e3c8cd9cf2fbbaa676b660c248441a0fa9143783137c46d6e6d17d676188dd5a6ef0b72dd091 languageName: node linkType: hard -"@ethersproject/units@npm:^5.7.0": +"@ethersproject/networks@npm:5.7.1, @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/pbkdf2@npm:5.7.0, @ethersproject/pbkdf2@npm:^5.7.0": + version: 5.7.0 + resolution: "@ethersproject/pbkdf2@npm:5.7.0" + dependencies: + "@ethersproject/bytes": "npm:^5.7.0" + "@ethersproject/sha2": "npm:^5.7.0" + checksum: 10/dea7ba747805e24b81dfb99e695eb329509bf5cad1a42e48475ade28e060e567458a3d5bf930f302691bded733fd3fa364f0c7adce920f9f05a5ef8c13267aaa + languageName: node + linkType: hard + +"@ethersproject/properties@npm:5.7.0, @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/providers@npm:5.7.2": + version: 5.7.2 + resolution: "@ethersproject/providers@npm:5.7.2" + dependencies: + "@ethersproject/abstract-provider": "npm:^5.7.0" + "@ethersproject/abstract-signer": "npm:^5.7.0" + "@ethersproject/address": "npm:^5.7.0" + "@ethersproject/base64": "npm:^5.7.0" + "@ethersproject/basex": "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/logger": "npm:^5.7.0" + "@ethersproject/networks": "npm:^5.7.0" + "@ethersproject/properties": "npm:^5.7.0" + "@ethersproject/random": "npm:^5.7.0" + "@ethersproject/rlp": "npm:^5.7.0" + "@ethersproject/sha2": "npm:^5.7.0" + "@ethersproject/strings": "npm:^5.7.0" + "@ethersproject/transactions": "npm:^5.7.0" + "@ethersproject/web": "npm:^5.7.0" + bech32: "npm:1.1.4" + ws: "npm:7.4.6" + checksum: 10/8534a1896e61b9f0b66427a639df64a5fe76d0c08ec59b9f0cc64fdd1d0cc28d9fc3312838ae8d7817c8f5e2e76b7f228b689bc33d1cbb8e1b9517d4c4f678d8 + languageName: node + linkType: hard + +"@ethersproject/random@npm:5.7.0, @ethersproject/random@npm:^5.7.0": + version: 5.7.0 + resolution: "@ethersproject/random@npm:5.7.0" + dependencies: + "@ethersproject/bytes": "npm:^5.7.0" + "@ethersproject/logger": "npm:^5.7.0" + checksum: 10/c23ec447998ce1147651bd58816db4d12dbeb404f66a03d14a13e1edb439879bab18528e1fc46b931502903ac7b1c08ea61d6a86e621a6e060fa63d41aeed3ac + languageName: node + linkType: hard + +"@ethersproject/rlp@npm:5.7.0, @ethersproject/rlp@npm:^5.5.0, @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/sha2@npm:5.7.0, @ethersproject/sha2@npm:^5.7.0": + version: 5.7.0 + resolution: "@ethersproject/sha2@npm:5.7.0" + dependencies: + "@ethersproject/bytes": "npm:^5.7.0" + "@ethersproject/logger": "npm:^5.7.0" + hash.js: "npm:1.1.7" + checksum: 10/09321057c022effbff4cc2d9b9558228690b5dd916329d75c4b1ffe32ba3d24b480a367a7cc92d0f0c0b1c896814d03351ae4630e2f1f7160be2bcfbde435dbc + languageName: node + linkType: hard + +"@ethersproject/signing-key@npm:5.7.0, @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/solidity@npm:5.7.0": + version: 5.7.0 + resolution: "@ethersproject/solidity@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/sha2": "npm:^5.7.0" + "@ethersproject/strings": "npm:^5.7.0" + checksum: 10/9a02f37f801c96068c3e7721f83719d060175bc4e80439fe060e92bd7acfcb6ac1330c7e71c49f4c2535ca1308f2acdcb01e00133129aac00581724c2d6293f3 + languageName: node + linkType: hard + +"@ethersproject/strings@npm:5.7.0, @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, @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, @ethersproject/units@npm:^5.7.0": version: 5.7.0 resolution: "@ethersproject/units@npm:5.7.0" dependencies: @@ -3278,6 +3584,55 @@ __metadata: languageName: node linkType: hard +"@ethersproject/wallet@npm:5.7.0": + version: 5.7.0 + resolution: "@ethersproject/wallet@npm:5.7.0" + dependencies: + "@ethersproject/abstract-provider": "npm:^5.7.0" + "@ethersproject/abstract-signer": "npm:^5.7.0" + "@ethersproject/address": "npm:^5.7.0" + "@ethersproject/bignumber": "npm:^5.7.0" + "@ethersproject/bytes": "npm:^5.7.0" + "@ethersproject/hash": "npm:^5.7.0" + "@ethersproject/hdnode": "npm:^5.7.0" + "@ethersproject/json-wallets": "npm:^5.7.0" + "@ethersproject/keccak256": "npm:^5.7.0" + "@ethersproject/logger": "npm:^5.7.0" + "@ethersproject/properties": "npm:^5.7.0" + "@ethersproject/random": "npm:^5.7.0" + "@ethersproject/signing-key": "npm:^5.7.0" + "@ethersproject/transactions": "npm:^5.7.0" + "@ethersproject/wordlists": "npm:^5.7.0" + checksum: 10/340f8e5c77c6c47c4d1596c200d97c53c1d4b4eb54d9166d0f2a114cb81685e7689255b0627e917fbcdc29cb54c4bd1f1a9909f3096ef9dff9acc0b24972f1c1 + languageName: node + linkType: hard + +"@ethersproject/web@npm:5.7.1, @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 + +"@ethersproject/wordlists@npm:5.7.0, @ethersproject/wordlists@npm:^5.7.0": + version: 5.7.0 + resolution: "@ethersproject/wordlists@npm:5.7.0" + dependencies: + "@ethersproject/bytes": "npm:^5.7.0" + "@ethersproject/hash": "npm:^5.7.0" + "@ethersproject/logger": "npm:^5.7.0" + "@ethersproject/properties": "npm:^5.7.0" + "@ethersproject/strings": "npm:^5.7.0" + checksum: 10/737fca67ad743a32020f50f5b9e147e5683cfba2692367c1124a5a5538be78515865257b426ec9141daac91a70295e5e21bef7a193b79fe745f1be378562ccaa + languageName: node + linkType: hard + "@gar/promisify@npm:^1.1.3": version: 1.1.3 resolution: "@gar/promisify@npm:1.1.3" @@ -3807,6 +4162,16 @@ __metadata: languageName: node linkType: hard +"@ledgerhq/cryptoassets-evm-signatures@npm:^13.5.1": + version: 13.5.1 + resolution: "@ledgerhq/cryptoassets-evm-signatures@npm:13.5.1" + dependencies: + "@ledgerhq/live-env": "npm:^2.4.0" + axios: "npm:1.7.7" + checksum: 10/8e9889a0a4c53afcbac0d42eeff5617ac37d9a69528861080a6dbada0a4fced10b5e7157c27224fb854776f5f248d2a77baa33c39525064b440285434422b83b + languageName: node + linkType: hard + "@ledgerhq/devices@npm:^8.4.4": version: 8.4.4 resolution: "@ledgerhq/devices@npm:8.4.4" @@ -3819,6 +4184,21 @@ __metadata: languageName: node linkType: hard +"@ledgerhq/domain-service@npm:^1.2.10": + version: 1.2.10 + resolution: "@ledgerhq/domain-service@npm:1.2.10" + dependencies: + "@ledgerhq/errors": "npm:^6.19.1" + "@ledgerhq/logs": "npm:^6.12.0" + "@ledgerhq/types-live": "npm:^6.52.4" + axios: "npm:1.7.7" + eip55: "npm:^2.1.1" + react: "npm:^18.2.0" + react-dom: "npm:^18.2.0" + checksum: 10/a46d546bd68ee3f7247e63e89b3425ab7df9d62f75501b6db7a13b1a319fb40cb2272c638ddc6c1bee973f66bd70fa4156d3287fa31e20a5bb2d0164d59aaf61 + languageName: node + linkType: hard + "@ledgerhq/errors@npm:^6.19.1": version: 6.19.1 resolution: "@ledgerhq/errors@npm:6.19.1" @@ -3826,6 +4206,51 @@ __metadata: languageName: node linkType: hard +"@ledgerhq/evm-tools@npm:^1.2.4": + version: 1.2.4 + resolution: "@ledgerhq/evm-tools@npm:1.2.4" + dependencies: + "@ledgerhq/cryptoassets-evm-signatures": "npm:^13.5.1" + "@ledgerhq/live-env": "npm:^2.4.0" + axios: "npm:1.7.7" + crypto-js: "npm:4.2.0" + ethers: "npm:5.7.2" + checksum: 10/5e1e213f39b337a91858ba94418ed816d7fd7591c2798da1754cca6ac96f395c80fbb5e8c41ed568383b669c7eed59e998e5a5f81bee1c92f17f1a4895f97772 + languageName: node + linkType: hard + +"@ledgerhq/hw-app-eth@npm:^6.40.3": + version: 6.40.3 + resolution: "@ledgerhq/hw-app-eth@npm:6.40.3" + dependencies: + "@ethersproject/abi": "npm:^5.5.0" + "@ethersproject/rlp": "npm:^5.5.0" + "@ledgerhq/cryptoassets-evm-signatures": "npm:^13.5.1" + "@ledgerhq/domain-service": "npm:^1.2.10" + "@ledgerhq/errors": "npm:^6.19.1" + "@ledgerhq/evm-tools": "npm:^1.2.4" + "@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.52.4" + axios: "npm:1.7.7" + bignumber.js: "npm:^9.1.2" + semver: "npm:^7.3.5" + checksum: 10/86f2d0a53acb74fe88a64909b846d44e13285210641596fd8c53f033d71c9ea3f058b8e6c599384a54f16a4448793208c33d80f9bdc0a72e0532d0068292a38e + 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" @@ -3838,6 +4263,16 @@ __metadata: languageName: node linkType: hard +"@ledgerhq/live-env@npm:^2.4.0": + version: 2.4.0 + resolution: "@ledgerhq/live-env@npm:2.4.0" + dependencies: + rxjs: "npm:^7.8.1" + utility-types: "npm:^3.10.0" + checksum: 10/825337025181bb97ac9c55f413a0cf0b2fff2be62f53b5230d328f592fd0b8b9ee4e2d979bf55f576361b880dd5f1424a9331b2da597414c111c213ab7a15dba + languageName: node + linkType: hard + "@ledgerhq/logs@npm:^6.12.0": version: 6.12.0 resolution: "@ledgerhq/logs@npm:6.12.0" @@ -3845,6 +4280,16 @@ __metadata: languageName: node linkType: hard +"@ledgerhq/types-live@npm:^6.52.4": + version: 6.52.4 + resolution: "@ledgerhq/types-live@npm:6.52.4" + dependencies: + bignumber.js: "npm:^9.1.2" + rxjs: "npm:^7.8.1" + checksum: 10/54288b5b334f0e9e57e5dbea9e8f9a86391e2e2daea2db755ee812dd9e687d45e426aab8737bac1c1fe308281ec170f75aca8845f2a3dba0201dc32a3dcdec1a + languageName: node + linkType: hard + "@leichtgewicht/ip-codec@npm:^2.0.1": version: 2.0.4 resolution: "@leichtgewicht/ip-codec@npm:2.0.4" @@ -5089,6 +5534,7 @@ __metadata: "@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.40.3" "@ledgerhq/hw-transport": "npm:^6.31.4" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/eslint-config": "npm:^12.1.0" @@ -9189,6 +9635,13 @@ __metadata: languageName: node linkType: hard +"aes-js@npm:3.0.0": + version: 3.0.0 + resolution: "aes-js@npm:3.0.0" + checksum: 10/1b3772e5ba74abdccb6c6b99bf7f50b49057b38c0db1612b46c7024414f16e65ba7f1643b2d6e38490b1870bdf3ba1b87b35e2c831fd3fdaeff015f08aad19d1 + languageName: node + linkType: hard + "aes-js@npm:4.0.0-beta.3": version: 4.0.0-beta.3 resolution: "aes-js@npm:4.0.0-beta.3" @@ -9713,7 +10166,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: @@ -9923,6 +10376,13 @@ __metadata: languageName: node linkType: hard +"bech32@npm:1.1.4": + version: 1.1.4 + resolution: "bech32@npm:1.1.4" + checksum: 10/63ff37c0ce43be914c685ce89700bba1589c319af0dac1ea04f51b33d0e5ecfd40d14c24f527350b94f0a4e236385373bb9122ec276410f354ddcdbf29ca13f4 + languageName: node + linkType: hard + "big-integer@npm:^1.6.17": version: 1.6.51 resolution: "big-integer@npm:1.6.51" @@ -9937,6 +10397,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" @@ -11386,6 +11853,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" @@ -12231,6 +12705,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" @@ -12249,6 +12732,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" @@ -13307,6 +13805,44 @@ __metadata: languageName: node linkType: hard +"ethers@npm:5.7.2": + version: 5.7.2 + resolution: "ethers@npm:5.7.2" + dependencies: + "@ethersproject/abi": "npm:5.7.0" + "@ethersproject/abstract-provider": "npm:5.7.0" + "@ethersproject/abstract-signer": "npm:5.7.0" + "@ethersproject/address": "npm:5.7.0" + "@ethersproject/base64": "npm:5.7.0" + "@ethersproject/basex": "npm:5.7.0" + "@ethersproject/bignumber": "npm:5.7.0" + "@ethersproject/bytes": "npm:5.7.0" + "@ethersproject/constants": "npm:5.7.0" + "@ethersproject/contracts": "npm:5.7.0" + "@ethersproject/hash": "npm:5.7.0" + "@ethersproject/hdnode": "npm:5.7.0" + "@ethersproject/json-wallets": "npm:5.7.0" + "@ethersproject/keccak256": "npm:5.7.0" + "@ethersproject/logger": "npm:5.7.0" + "@ethersproject/networks": "npm:5.7.1" + "@ethersproject/pbkdf2": "npm:5.7.0" + "@ethersproject/properties": "npm:5.7.0" + "@ethersproject/providers": "npm:5.7.2" + "@ethersproject/random": "npm:5.7.0" + "@ethersproject/rlp": "npm:5.7.0" + "@ethersproject/sha2": "npm:5.7.0" + "@ethersproject/signing-key": "npm:5.7.0" + "@ethersproject/solidity": "npm:5.7.0" + "@ethersproject/strings": "npm:5.7.0" + "@ethersproject/transactions": "npm:5.7.0" + "@ethersproject/units": "npm:5.7.0" + "@ethersproject/wallet": "npm:5.7.0" + "@ethersproject/web": "npm:5.7.1" + "@ethersproject/wordlists": "npm:5.7.0" + checksum: 10/227dfa88a2547c799c0c3c9e92e5e246dd11342f4b495198b3ae7c942d5bf81d3970fcef3fbac974a9125d62939b2d94f3c0458464e702209b839a8e6e615028 + languageName: node + linkType: hard + "ethers@npm:^6.3.0": version: 6.3.0 resolution: "ethers@npm:6.3.0" @@ -14674,7 +15210,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: @@ -16423,6 +16959,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" @@ -16668,6 +17211,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" @@ -18034,6 +18589,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" @@ -18100,6 +18664,17 @@ __metadata: languageName: node linkType: hard +"node-gyp-build@npm:^4.2.0": + version: 4.8.2 + resolution: "node-gyp-build@npm:4.8.2" + bin: + node-gyp-build: bin.js + node-gyp-build-optional: optional.js + node-gyp-build-test: build-test.js + checksum: 10/e3a365eed7a2d950864a1daa34527588c16fe43ae189d0aeb8fd1dfec91ba42a0e1b499322bff86c2832029fec4f5901bf26e32005e1e17a781dcd5177b6a657 + languageName: node + linkType: hard + "node-gyp@npm:^10.0.0": version: 10.0.1 resolution: "node-gyp@npm:10.0.1" @@ -20637,6 +21212,13 @@ __metadata: languageName: node linkType: hard +"scrypt-js@npm:3.0.1": + version: 3.0.1 + resolution: "scrypt-js@npm:3.0.1" + checksum: 10/2f8aa72b7f76a6f9c446bbec5670f80d47497bccce98474203d89b5667717223eeb04a50492ae685ed7adc5a060fc2d8f9fd988f8f7ebdaf3341967f3aeff116 + languageName: node + linkType: hard + "select-hose@npm:^2.0.0": version: 2.0.0 resolution: "select-hose@npm:2.0.0" @@ -22651,6 +23233,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" @@ -23377,6 +23966,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:7.4.6": + version: 7.4.6 + resolution: "ws@npm:7.4.6" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10/150e3f917b7cde568d833a5ea6ccc4132e59c38d04218afcf2b6c7b845752bd011a9e0dc1303c8694d3c402a0bdec5893661a390b71ff88f0fc81a4e4e66b09c + languageName: node + linkType: hard + "ws@npm:8.13.0, ws@npm:^8.11.0, ws@npm:^8.13.0, ws@npm:^8.8.0": version: 8.13.0 resolution: "ws@npm:8.13.0" From 82d286cadb1b99bc4c035cf242ae0c47b4bad691 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Fri, 8 Nov 2024 10:58:04 +0100 Subject: [PATCH 08/15] Add read functionality --- .../packages/ledger/snap.manifest.json | 2 +- .../examples/packages/ledger/src/index.tsx | 46 +++++++++-- .../src/devices/DeviceController.ts | 80 ++++++++++++++++++- .../src/permitted/readDevice.ts | 2 +- 4 files changed, 117 insertions(+), 13 deletions(-) diff --git a/packages/examples/packages/ledger/snap.manifest.json b/packages/examples/packages/ledger/snap.manifest.json index 68aa9d2e7a..1d3a6eff68 100644 --- a/packages/examples/packages/ledger/snap.manifest.json +++ b/packages/examples/packages/ledger/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "mm1QJa11tXxOH00wZe9kxpajdDBxklAJg96Bv2c2o7w=", + "shasum": "LNfNpm9ZciGFP0AlUj/UkFR50cJnJuYtXXE2LM/5HJI=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/ledger/src/index.tsx b/packages/examples/packages/ledger/src/index.tsx index 2491b053b2..1cc4ad1728 100644 --- a/packages/examples/packages/ledger/src/index.tsx +++ b/packages/examples/packages/ledger/src/index.tsx @@ -2,7 +2,7 @@ import type { OnRpcRequestHandler, OnUserInputHandler, } from '@metamask/snaps-sdk'; -import { Box, Button } from '@metamask/snaps-sdk/jsx'; +import { Box, Button, Text, Copyable } from '@metamask/snaps-sdk/jsx'; import { MethodNotFoundError } from '@metamask/snaps-sdk'; import Eth from '@ledgerhq/hw-app-eth'; @@ -30,16 +30,48 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { } }; -export const onUserInput: OnUserInputHandler = async () => { +function hexlifySignature(signature: { r: string; s: string; v: number }) { + const adjustedV = signature.v - 27; + let hexlifiedV = adjustedV.toString(16); + if (hexlifiedV.length < 2) { + hexlifiedV = '0' + hexlifiedV; + } + return `0x${signature.r}${signature.s}${hexlifiedV}`; +} + +export const onUserInput: OnUserInputHandler = async ({ id }) => { try { const transport = await TransportSnapsHID.request(); const eth = new Eth(transport); - console.log( - await eth.signPersonalMessage( - "44'/60'/0'/0/0", - Buffer.from('test').toString('hex'), - ), + const msg = 'test'; + const { address } = await eth.getAddress("44'/60'/0'/0/0"); + const signature = await eth.signPersonalMessage( + "44'/60'/0'/0/0", + Buffer.from(msg).toString('hex'), ); + + const signatureHex = hexlifySignature(signature); + const message = { + address, + msg, + sig: signatureHex, + version: 2, + }; + await snap.request({ + method: 'snap_updateInterface', + params: { + id, + ui: ( + + + Signature: + + JSON: + + + ), + }, + }); } catch (error) { console.error(error); } diff --git a/packages/snaps-controllers/src/devices/DeviceController.ts b/packages/snaps-controllers/src/devices/DeviceController.ts index 146f1e4056..655fb24be8 100644 --- a/packages/snaps-controllers/src/devices/DeviceController.ts +++ b/packages/snaps-controllers/src/devices/DeviceController.ts @@ -24,6 +24,7 @@ import type { import { createDeferredPromise, hasProperty, + Hex, hexToBytes, } from '@metamask/utils'; @@ -112,6 +113,15 @@ export class DeviceController extends BaseController< reject: (error: unknown) => void; }; + #openDevices: Record< + DeviceId, + { + buffer: { reportId: number; data: Hex }[]; + promise?: Promise<{ reportId: number; data: Hex }>; + resolvePromise?: any; + } + > = {}; + constructor({ messenger, state }: DeviceControllerArgs) { super({ messenger, @@ -163,6 +173,51 @@ export class DeviceController extends BaseController< return device.metadata; } + // TODO: Clean up + async #openDevice(id: DeviceId, device: any) { + await device.open(); + + if (!this.#openDevices[id]) { + this.#openDevices[id] = { + buffer: [], + }; + } + + device.addEventListener('inputreport', (event: any) => { + const promiseResolve = this.#openDevices[id].resolvePromise; + + const data = Buffer.from(event.data.buffer).toString('hex') as Hex; + + const result = { + reportId: event.reportId, + data, + }; + + if (promiseResolve) { + promiseResolve(result); + delete this.#openDevices[id].resolvePromise; + delete this.#openDevices[id].promise; + } else { + this.#openDevices[id].buffer.push(result); + } + }); + } + + #waitForNextRead(id: DeviceId) { + if (this.#openDevices[id].promise) { + return this.#openDevices[id].promise; + } + + const { promise, resolve } = createDeferredPromise<{ + reportId: number; + data: Hex; + }>(); + + this.#openDevices[id].resolvePromise = resolve; + this.#openDevices[id].promise = promise; + return promise; + } + async writeDevice( snapId: SnapId, { id, reportId = 0, reportType, data }: WriteDeviceParams, @@ -180,7 +235,7 @@ export class DeviceController extends BaseController< const actualDevice = device.reference; if (!actualDevice.opened) { - await actualDevice.open(); + await this.#openDevice(id, actualDevice); } if (reportType === 'feature') { @@ -188,9 +243,14 @@ export class DeviceController extends BaseController< } else { await actualDevice.sendReport(reportId, hexToBytes(data)); } + + return null; } - async readDevice(snapId: SnapId, { id }: ReadDeviceParams) { + async readDevice( + snapId: SnapId, + { id, reportId = 0, reportType }: ReadDeviceParams, + ) { if (!this.#hasPermission(snapId, id)) { // TODO: Decide on error message throw rpcErrors.invalidParams(); @@ -204,10 +264,22 @@ export class DeviceController extends BaseController< const actualDevice = device.reference; if (!actualDevice.opened) { - await actualDevice.open(); + await this.#openDevice(id, actualDevice); } - // TODO: Actual read + if (reportType === 'feature') { + return actualDevice.receiveFeatureReport(reportId); + } else { + // TODO: Deal with report IDs? + // TODO: Clean up + if (this.#openDevices[id].buffer.length > 0) { + const result = this.#openDevices[id].buffer.shift(); + return result!.data; + } else { + const result = await this.#waitForNextRead(id); + return result!.data; + } + } } async listDevices(snapId: SnapId, { type }: ListDevicesParams) { diff --git a/packages/snaps-rpc-methods/src/permitted/readDevice.ts b/packages/snaps-rpc-methods/src/permitted/readDevice.ts index 9d864fc376..9eeb74bf5a 100644 --- a/packages/snaps-rpc-methods/src/permitted/readDevice.ts +++ b/packages/snaps-rpc-methods/src/permitted/readDevice.ts @@ -45,7 +45,7 @@ export const readDeviceHandler: PermittedHandlerExport< const ReadDeviceParametersStruct = object({ type: literal('hid'), id: deviceId('hid'), - reportType: union([literal('output'), literal('feature')]), + reportType: optional(union([literal('output'), literal('feature')])), reportId: optional(number()), }); From 3905fdcb866390ecd7ed5751d9a6f314044cb9fa Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Mon, 11 Nov 2024 14:08:28 +0100 Subject: [PATCH 09/15] Add tests for device API methods (#2879) This adds tests for the RPC methods related to the devices API. --- .../src/endowments/devices.test.ts | 227 +++++++ .../src/endowments/devices.ts | 17 +- .../snaps-rpc-methods/src/permissions.test.ts | 12 + .../src/permitted/listDevices.test.ts | 104 +++ .../src/permitted/listDevices.ts | 49 +- .../src/permitted/readDevice.test.ts | 105 +++ .../src/permitted/readDevice.ts | 31 +- .../src/permitted/requestDevice.test.ts | 107 +++ .../src/permitted/requestDevice.ts | 37 +- .../src/permitted/writeDevice.test.ts | 102 +++ .../src/permitted/writeDevice.ts | 40 +- packages/snaps-sdk/src/types/device.test.ts | 51 ++ yarn.lock | 618 +----------------- 13 files changed, 863 insertions(+), 637 deletions(-) create mode 100644 packages/snaps-rpc-methods/src/endowments/devices.test.ts create mode 100644 packages/snaps-rpc-methods/src/permitted/listDevices.test.ts create mode 100644 packages/snaps-rpc-methods/src/permitted/readDevice.test.ts create mode 100644 packages/snaps-rpc-methods/src/permitted/requestDevice.test.ts create mode 100644 packages/snaps-rpc-methods/src/permitted/writeDevice.test.ts create mode 100644 packages/snaps-sdk/src/types/device.test.ts 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 index 0ee9d985ea..6fdd1f0b26 100644 --- a/packages/snaps-rpc-methods/src/endowments/devices.ts +++ b/packages/snaps-rpc-methods/src/endowments/devices.ts @@ -4,6 +4,7 @@ import type { EndowmentGetterParams, PermissionConstraint, PermissionSpecificationBuilder, + PermissionValidatorConstraint, ValidPermissionSpecification, } from '@metamask/permission-controller'; import { PermissionType, SubjectType } from '@metamask/permission-controller'; @@ -15,6 +16,7 @@ import { } from '@metamask/snaps-utils'; import { hasProperty, isPlainObject, assert } from '@metamask/utils'; +import { createGenericPermissionValidator } from './caveats'; import { SnapEndowments } from './enum'; const permissionName = SnapEndowments.Devices; @@ -24,6 +26,7 @@ type DevicesEndowmentSpecification = ValidPermissionSpecification<{ targetName: typeof permissionName; endowmentGetter: (_options?: any) => null; allowedCaveats: [SnapCaveatType.DeviceIds]; + validator: PermissionValidatorConstraint; }>; /** @@ -44,6 +47,9 @@ const specificationBuilder: PermissionSpecificationBuilder< allowedCaveats: [SnapCaveatType.DeviceIds], endowmentGetter: (_getterOptions?: EndowmentGetterParams) => null, subjectTypes: [SubjectType.Snap], + validator: createGenericPermissionValidator([ + { type: SnapCaveatType.DeviceIds, optional: true }, + ]), }; }; @@ -99,13 +105,10 @@ export function validateDeviceIdsCaveat(caveat: Caveat) { const { value } = caveat; - if (!hasProperty(value, 'devices') || !isPlainObject(value)) { - throw rpcErrors.invalidParams({ - message: 'Expected a plain object.', - }); - } - - if (!isDeviceSpecificationArray(value.devices)) { + if ( + !hasProperty(value, 'devices') || + !isDeviceSpecificationArray(value.devices) + ) { throw rpcErrors.invalidParams({ message: 'Expected a valid device specification array.', }); 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/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 index eb14e87a6f..09dfe9dae2 100644 --- a/packages/snaps-rpc-methods/src/permitted/listDevices.ts +++ b/packages/snaps-rpc-methods/src/permitted/listDevices.ts @@ -1,13 +1,22 @@ 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, literal, object, optional, union } from '@metamask/superstruct'; -import { assertStruct, type PendingJsonRpcResponse } from '@metamask/utils'; +import { + array, + create, + literal, + object, + optional, + StructError, +} from '@metamask/superstruct'; +import { type PendingJsonRpcResponse } from '@metamask/utils'; import type { MethodHooksObject } from '../utils'; @@ -36,7 +45,15 @@ export const listDevicesHandler: PermittedHandlerExport< }; const ListDevicesParametersStruct = object({ - type: optional(union([literal('hid'), array(literal('hid'))])), + type: optional( + selectiveUnion((value) => { + if (Array.isArray(value)) { + return array(literal('hid')); + } + + return literal('hid'); + }), + ), }); export type ListDevicesParameters = InferMatching< @@ -64,13 +81,35 @@ async function listDevicesImplementation( { listDevices }: ListDevicesHooks, ): Promise { const { params } = request; - assertStruct(params, ListDevicesParametersStruct); + const validatedParams = getValidatedParams(params); try { - response.result = await listDevices(params); + 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 index 9eeb74bf5a..cdf434ab19 100644 --- a/packages/snaps-rpc-methods/src/permitted/readDevice.ts +++ b/packages/snaps-rpc-methods/src/permitted/readDevice.ts @@ -1,5 +1,6 @@ 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, @@ -8,13 +9,15 @@ import type { import { deviceId } from '@metamask/snaps-sdk'; import type { InferMatching } from '@metamask/snaps-utils'; import { + create, literal, number, object, optional, + StructError, union, } from '@metamask/superstruct'; -import { assertStruct, type PendingJsonRpcResponse } from '@metamask/utils'; +import { type PendingJsonRpcResponse } from '@metamask/utils'; import type { MethodHooksObject } from '../utils'; @@ -74,13 +77,35 @@ async function readDeviceImplementation( { readDevice }: ReadDeviceHooks, ): Promise { const { params } = request; - assertStruct(params, ReadDeviceParametersStruct); + const validatedParams = getValidatedParams(params); try { - response.result = await readDevice(params); + 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 index 40ee9b6218..3b2e309fed 100644 --- a/packages/snaps-rpc-methods/src/permitted/requestDevice.ts +++ b/packages/snaps-rpc-methods/src/permitted/requestDevice.ts @@ -1,5 +1,6 @@ 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, @@ -7,8 +8,14 @@ import type { } from '@metamask/snaps-sdk'; import { DeviceFilterStruct, DeviceTypeStruct } from '@metamask/snaps-sdk'; import type { InferMatching } from '@metamask/snaps-utils'; -import { object, optional, array } from '@metamask/superstruct'; -import { assertStruct, type PendingJsonRpcResponse } from '@metamask/utils'; +import { + object, + optional, + array, + create, + StructError, +} from '@metamask/superstruct'; +import { type PendingJsonRpcResponse } from '@metamask/utils'; import type { MethodHooksObject } from '../utils'; @@ -66,13 +73,35 @@ async function requestDeviceImplementation( { requestDevice }: RequestDeviceHooks, ): Promise { const { params } = request; - assertStruct(params, RequestDeviceParametersStruct); + const validatedParams = getValidatedParams(params); try { - response.result = await requestDevice(params); + 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 index 3058f0a3a1..6d81c37c64 100644 --- a/packages/snaps-rpc-methods/src/permitted/writeDevice.ts +++ b/packages/snaps-rpc-methods/src/permitted/writeDevice.ts @@ -1,5 +1,6 @@ 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, @@ -7,12 +8,15 @@ import type { } from '@metamask/snaps-sdk'; import { deviceId } from '@metamask/snaps-sdk'; import type { InferMatching } from '@metamask/snaps-utils'; -import { literal, number, object, optional } from '@metamask/superstruct'; import { - assertStruct, - type PendingJsonRpcResponse, - StrictHexStruct, -} from '@metamask/utils'; + create, + literal, + number, + object, + optional, + StructError, +} from '@metamask/superstruct'; +import { type PendingJsonRpcResponse, StrictHexStruct } from '@metamask/utils'; import type { MethodHooksObject } from '../utils'; @@ -73,13 +77,35 @@ async function writeDeviceImplementation( { writeDevice }: WriteDeviceHooks, ): Promise { const { params } = request; - assertStruct(params, WriteDeviceParametersStruct); + const validatedParams = getValidatedParams(params); try { - response.result = await writeDevice(params); + 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/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/yarn.lock b/yarn.lock index 77fffefebf..c8797cf43c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3231,84 +3231,7 @@ __metadata: languageName: node linkType: hard -"@ethersproject/abi@npm:5.7.0, @ethersproject/abi@npm:^5.5.0, @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, @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, @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, @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, @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/basex@npm:5.7.0, @ethersproject/basex@npm:^5.7.0": - version: 5.7.0 - resolution: "@ethersproject/basex@npm:5.7.0" - dependencies: - "@ethersproject/bytes": "npm:^5.7.0" - "@ethersproject/properties": "npm:^5.7.0" - checksum: 10/840e333e109bff2fcf8d91dcfd45fa951835844ef0e1ba710037e87291c7b5f3c189ba86f6cee2ca7de2ede5b7d59fbb930346607695855bee20d2f9f63371ef - languageName: node - linkType: hard - -"@ethersproject/bignumber@npm:5.7.0, @ethersproject/bignumber@npm:^5.7.0": +"@ethersproject/bignumber@npm:^5.7.0": version: 5.7.0 resolution: "@ethersproject/bignumber@npm:5.7.0" dependencies: @@ -3319,7 +3242,7 @@ __metadata: languageName: node linkType: hard -"@ethersproject/bytes@npm:5.7.0, @ethersproject/bytes@npm:^5.7.0": +"@ethersproject/bytes@npm:^5.7.0": version: 5.7.0 resolution: "@ethersproject/bytes@npm:5.7.0" dependencies: @@ -3328,7 +3251,7 @@ __metadata: languageName: node linkType: hard -"@ethersproject/constants@npm:5.7.0, @ethersproject/constants@npm:^5.7.0": +"@ethersproject/constants@npm:^5.7.0": version: 5.7.0 resolution: "@ethersproject/constants@npm:5.7.0" dependencies: @@ -3337,243 +3260,14 @@ __metadata: languageName: node linkType: hard -"@ethersproject/contracts@npm:5.7.0": - version: 5.7.0 - resolution: "@ethersproject/contracts@npm:5.7.0" - dependencies: - "@ethersproject/abi": "npm:^5.7.0" - "@ethersproject/abstract-provider": "npm:^5.7.0" - "@ethersproject/abstract-signer": "npm:^5.7.0" - "@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/logger": "npm:^5.7.0" - "@ethersproject/properties": "npm:^5.7.0" - "@ethersproject/transactions": "npm:^5.7.0" - checksum: 10/5df66179af242faabea287a83fd2f8f303a4244dc87a6ff802e1e3b643f091451295c8e3d088c7739970b7915a16a581c192d4e007d848f1fdf3cc9e49010053 - languageName: node - linkType: hard - -"@ethersproject/hash@npm:5.7.0, @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/hdnode@npm:5.7.0, @ethersproject/hdnode@npm:^5.7.0": - version: 5.7.0 - resolution: "@ethersproject/hdnode@npm:5.7.0" - dependencies: - "@ethersproject/abstract-signer": "npm:^5.7.0" - "@ethersproject/basex": "npm:^5.7.0" - "@ethersproject/bignumber": "npm:^5.7.0" - "@ethersproject/bytes": "npm:^5.7.0" - "@ethersproject/logger": "npm:^5.7.0" - "@ethersproject/pbkdf2": "npm:^5.7.0" - "@ethersproject/properties": "npm:^5.7.0" - "@ethersproject/sha2": "npm:^5.7.0" - "@ethersproject/signing-key": "npm:^5.7.0" - "@ethersproject/strings": "npm:^5.7.0" - "@ethersproject/transactions": "npm:^5.7.0" - "@ethersproject/wordlists": "npm:^5.7.0" - checksum: 10/2fbe6278c324235afaa88baa5dea24d8674c72b14ad037fe2096134d41025977f410b04fd146e333a1b6cac9482e9de62d6375d1705fd42667543f2d0eb66655 - languageName: node - linkType: hard - -"@ethersproject/json-wallets@npm:5.7.0, @ethersproject/json-wallets@npm:^5.7.0": - version: 5.7.0 - resolution: "@ethersproject/json-wallets@npm:5.7.0" - dependencies: - "@ethersproject/abstract-signer": "npm:^5.7.0" - "@ethersproject/address": "npm:^5.7.0" - "@ethersproject/bytes": "npm:^5.7.0" - "@ethersproject/hdnode": "npm:^5.7.0" - "@ethersproject/keccak256": "npm:^5.7.0" - "@ethersproject/logger": "npm:^5.7.0" - "@ethersproject/pbkdf2": "npm:^5.7.0" - "@ethersproject/properties": "npm:^5.7.0" - "@ethersproject/random": "npm:^5.7.0" - "@ethersproject/strings": "npm:^5.7.0" - "@ethersproject/transactions": "npm:^5.7.0" - aes-js: "npm:3.0.0" - scrypt-js: "npm:3.0.1" - checksum: 10/4a1ef0912ffc8d18c392ae4e292948d86bffd715fe3dd3e66d1cd21f6c9267aeadad4da84261db853327f97cdfd765a377f9a87e39d4c6749223a69226faf0a1 - languageName: node - linkType: hard - -"@ethersproject/keccak256@npm:5.7.0, @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, @ethersproject/logger@npm:^5.7.0": +"@ethersproject/logger@npm:^5.7.0": version: 5.7.0 resolution: "@ethersproject/logger@npm:5.7.0" checksum: 10/683a939f467ae7510deedc23d7611d0932c3046137f5ffb92ba1e3c8cd9cf2fbbaa676b660c248441a0fa9143783137c46d6e6d17d676188dd5a6ef0b72dd091 languageName: node linkType: hard -"@ethersproject/networks@npm:5.7.1, @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/pbkdf2@npm:5.7.0, @ethersproject/pbkdf2@npm:^5.7.0": - version: 5.7.0 - resolution: "@ethersproject/pbkdf2@npm:5.7.0" - dependencies: - "@ethersproject/bytes": "npm:^5.7.0" - "@ethersproject/sha2": "npm:^5.7.0" - checksum: 10/dea7ba747805e24b81dfb99e695eb329509bf5cad1a42e48475ade28e060e567458a3d5bf930f302691bded733fd3fa364f0c7adce920f9f05a5ef8c13267aaa - languageName: node - linkType: hard - -"@ethersproject/properties@npm:5.7.0, @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/providers@npm:5.7.2": - version: 5.7.2 - resolution: "@ethersproject/providers@npm:5.7.2" - dependencies: - "@ethersproject/abstract-provider": "npm:^5.7.0" - "@ethersproject/abstract-signer": "npm:^5.7.0" - "@ethersproject/address": "npm:^5.7.0" - "@ethersproject/base64": "npm:^5.7.0" - "@ethersproject/basex": "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/logger": "npm:^5.7.0" - "@ethersproject/networks": "npm:^5.7.0" - "@ethersproject/properties": "npm:^5.7.0" - "@ethersproject/random": "npm:^5.7.0" - "@ethersproject/rlp": "npm:^5.7.0" - "@ethersproject/sha2": "npm:^5.7.0" - "@ethersproject/strings": "npm:^5.7.0" - "@ethersproject/transactions": "npm:^5.7.0" - "@ethersproject/web": "npm:^5.7.0" - bech32: "npm:1.1.4" - ws: "npm:7.4.6" - checksum: 10/8534a1896e61b9f0b66427a639df64a5fe76d0c08ec59b9f0cc64fdd1d0cc28d9fc3312838ae8d7817c8f5e2e76b7f228b689bc33d1cbb8e1b9517d4c4f678d8 - languageName: node - linkType: hard - -"@ethersproject/random@npm:5.7.0, @ethersproject/random@npm:^5.7.0": - version: 5.7.0 - resolution: "@ethersproject/random@npm:5.7.0" - dependencies: - "@ethersproject/bytes": "npm:^5.7.0" - "@ethersproject/logger": "npm:^5.7.0" - checksum: 10/c23ec447998ce1147651bd58816db4d12dbeb404f66a03d14a13e1edb439879bab18528e1fc46b931502903ac7b1c08ea61d6a86e621a6e060fa63d41aeed3ac - languageName: node - linkType: hard - -"@ethersproject/rlp@npm:5.7.0, @ethersproject/rlp@npm:^5.5.0, @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/sha2@npm:5.7.0, @ethersproject/sha2@npm:^5.7.0": - version: 5.7.0 - resolution: "@ethersproject/sha2@npm:5.7.0" - dependencies: - "@ethersproject/bytes": "npm:^5.7.0" - "@ethersproject/logger": "npm:^5.7.0" - hash.js: "npm:1.1.7" - checksum: 10/09321057c022effbff4cc2d9b9558228690b5dd916329d75c4b1ffe32ba3d24b480a367a7cc92d0f0c0b1c896814d03351ae4630e2f1f7160be2bcfbde435dbc - languageName: node - linkType: hard - -"@ethersproject/signing-key@npm:5.7.0, @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/solidity@npm:5.7.0": - version: 5.7.0 - resolution: "@ethersproject/solidity@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/sha2": "npm:^5.7.0" - "@ethersproject/strings": "npm:^5.7.0" - checksum: 10/9a02f37f801c96068c3e7721f83719d060175bc4e80439fe060e92bd7acfcb6ac1330c7e71c49f4c2535ca1308f2acdcb01e00133129aac00581724c2d6293f3 - languageName: node - linkType: hard - -"@ethersproject/strings@npm:5.7.0, @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, @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, @ethersproject/units@npm:^5.7.0": +"@ethersproject/units@npm:^5.7.0": version: 5.7.0 resolution: "@ethersproject/units@npm:5.7.0" dependencies: @@ -3584,55 +3278,6 @@ __metadata: languageName: node linkType: hard -"@ethersproject/wallet@npm:5.7.0": - version: 5.7.0 - resolution: "@ethersproject/wallet@npm:5.7.0" - dependencies: - "@ethersproject/abstract-provider": "npm:^5.7.0" - "@ethersproject/abstract-signer": "npm:^5.7.0" - "@ethersproject/address": "npm:^5.7.0" - "@ethersproject/bignumber": "npm:^5.7.0" - "@ethersproject/bytes": "npm:^5.7.0" - "@ethersproject/hash": "npm:^5.7.0" - "@ethersproject/hdnode": "npm:^5.7.0" - "@ethersproject/json-wallets": "npm:^5.7.0" - "@ethersproject/keccak256": "npm:^5.7.0" - "@ethersproject/logger": "npm:^5.7.0" - "@ethersproject/properties": "npm:^5.7.0" - "@ethersproject/random": "npm:^5.7.0" - "@ethersproject/signing-key": "npm:^5.7.0" - "@ethersproject/transactions": "npm:^5.7.0" - "@ethersproject/wordlists": "npm:^5.7.0" - checksum: 10/340f8e5c77c6c47c4d1596c200d97c53c1d4b4eb54d9166d0f2a114cb81685e7689255b0627e917fbcdc29cb54c4bd1f1a9909f3096ef9dff9acc0b24972f1c1 - languageName: node - linkType: hard - -"@ethersproject/web@npm:5.7.1, @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 - -"@ethersproject/wordlists@npm:5.7.0, @ethersproject/wordlists@npm:^5.7.0": - version: 5.7.0 - resolution: "@ethersproject/wordlists@npm:5.7.0" - dependencies: - "@ethersproject/bytes": "npm:^5.7.0" - "@ethersproject/hash": "npm:^5.7.0" - "@ethersproject/logger": "npm:^5.7.0" - "@ethersproject/properties": "npm:^5.7.0" - "@ethersproject/strings": "npm:^5.7.0" - checksum: 10/737fca67ad743a32020f50f5b9e147e5683cfba2692367c1124a5a5538be78515865257b426ec9141daac91a70295e5e21bef7a193b79fe745f1be378562ccaa - languageName: node - linkType: hard - "@gar/promisify@npm:^1.1.3": version: 1.1.3 resolution: "@gar/promisify@npm:1.1.3" @@ -4162,16 +3807,6 @@ __metadata: languageName: node linkType: hard -"@ledgerhq/cryptoassets-evm-signatures@npm:^13.5.1": - version: 13.5.1 - resolution: "@ledgerhq/cryptoassets-evm-signatures@npm:13.5.1" - dependencies: - "@ledgerhq/live-env": "npm:^2.4.0" - axios: "npm:1.7.7" - checksum: 10/8e9889a0a4c53afcbac0d42eeff5617ac37d9a69528861080a6dbada0a4fced10b5e7157c27224fb854776f5f248d2a77baa33c39525064b440285434422b83b - languageName: node - linkType: hard - "@ledgerhq/devices@npm:^8.4.4": version: 8.4.4 resolution: "@ledgerhq/devices@npm:8.4.4" @@ -4184,21 +3819,6 @@ __metadata: languageName: node linkType: hard -"@ledgerhq/domain-service@npm:^1.2.10": - version: 1.2.10 - resolution: "@ledgerhq/domain-service@npm:1.2.10" - dependencies: - "@ledgerhq/errors": "npm:^6.19.1" - "@ledgerhq/logs": "npm:^6.12.0" - "@ledgerhq/types-live": "npm:^6.52.4" - axios: "npm:1.7.7" - eip55: "npm:^2.1.1" - react: "npm:^18.2.0" - react-dom: "npm:^18.2.0" - checksum: 10/a46d546bd68ee3f7247e63e89b3425ab7df9d62f75501b6db7a13b1a319fb40cb2272c638ddc6c1bee973f66bd70fa4156d3287fa31e20a5bb2d0164d59aaf61 - languageName: node - linkType: hard - "@ledgerhq/errors@npm:^6.19.1": version: 6.19.1 resolution: "@ledgerhq/errors@npm:6.19.1" @@ -4206,51 +3826,6 @@ __metadata: languageName: node linkType: hard -"@ledgerhq/evm-tools@npm:^1.2.4": - version: 1.2.4 - resolution: "@ledgerhq/evm-tools@npm:1.2.4" - dependencies: - "@ledgerhq/cryptoassets-evm-signatures": "npm:^13.5.1" - "@ledgerhq/live-env": "npm:^2.4.0" - axios: "npm:1.7.7" - crypto-js: "npm:4.2.0" - ethers: "npm:5.7.2" - checksum: 10/5e1e213f39b337a91858ba94418ed816d7fd7591c2798da1754cca6ac96f395c80fbb5e8c41ed568383b669c7eed59e998e5a5f81bee1c92f17f1a4895f97772 - languageName: node - linkType: hard - -"@ledgerhq/hw-app-eth@npm:^6.40.3": - version: 6.40.3 - resolution: "@ledgerhq/hw-app-eth@npm:6.40.3" - dependencies: - "@ethersproject/abi": "npm:^5.5.0" - "@ethersproject/rlp": "npm:^5.5.0" - "@ledgerhq/cryptoassets-evm-signatures": "npm:^13.5.1" - "@ledgerhq/domain-service": "npm:^1.2.10" - "@ledgerhq/errors": "npm:^6.19.1" - "@ledgerhq/evm-tools": "npm:^1.2.4" - "@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.52.4" - axios: "npm:1.7.7" - bignumber.js: "npm:^9.1.2" - semver: "npm:^7.3.5" - checksum: 10/86f2d0a53acb74fe88a64909b846d44e13285210641596fd8c53f033d71c9ea3f058b8e6c599384a54f16a4448793208c33d80f9bdc0a72e0532d0068292a38e - 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" @@ -4263,16 +3838,6 @@ __metadata: languageName: node linkType: hard -"@ledgerhq/live-env@npm:^2.4.0": - version: 2.4.0 - resolution: "@ledgerhq/live-env@npm:2.4.0" - dependencies: - rxjs: "npm:^7.8.1" - utility-types: "npm:^3.10.0" - checksum: 10/825337025181bb97ac9c55f413a0cf0b2fff2be62f53b5230d328f592fd0b8b9ee4e2d979bf55f576361b880dd5f1424a9331b2da597414c111c213ab7a15dba - languageName: node - linkType: hard - "@ledgerhq/logs@npm:^6.12.0": version: 6.12.0 resolution: "@ledgerhq/logs@npm:6.12.0" @@ -4280,16 +3845,6 @@ __metadata: languageName: node linkType: hard -"@ledgerhq/types-live@npm:^6.52.4": - version: 6.52.4 - resolution: "@ledgerhq/types-live@npm:6.52.4" - dependencies: - bignumber.js: "npm:^9.1.2" - rxjs: "npm:^7.8.1" - checksum: 10/54288b5b334f0e9e57e5dbea9e8f9a86391e2e2daea2db755ee812dd9e687d45e426aab8737bac1c1fe308281ec170f75aca8845f2a3dba0201dc32a3dcdec1a - languageName: node - linkType: hard - "@leichtgewicht/ip-codec@npm:^2.0.1": version: 2.0.4 resolution: "@leichtgewicht/ip-codec@npm:2.0.4" @@ -5534,7 +5089,6 @@ __metadata: "@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.40.3" "@ledgerhq/hw-transport": "npm:^6.31.4" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/eslint-config": "npm:^12.1.0" @@ -9635,13 +9189,6 @@ __metadata: languageName: node linkType: hard -"aes-js@npm:3.0.0": - version: 3.0.0 - resolution: "aes-js@npm:3.0.0" - checksum: 10/1b3772e5ba74abdccb6c6b99bf7f50b49057b38c0db1612b46c7024414f16e65ba7f1643b2d6e38490b1870bdf3ba1b87b35e2c831fd3fdaeff015f08aad19d1 - languageName: node - linkType: hard - "aes-js@npm:4.0.0-beta.3": version: 4.0.0-beta.3 resolution: "aes-js@npm:4.0.0-beta.3" @@ -10166,7 +9713,7 @@ __metadata: languageName: node linkType: hard -"axios@npm:1.7.7, axios@npm:^1.7.4": +"axios@npm:^1.7.4": version: 1.7.7 resolution: "axios@npm:1.7.7" dependencies: @@ -10376,13 +9923,6 @@ __metadata: languageName: node linkType: hard -"bech32@npm:1.1.4": - version: 1.1.4 - resolution: "bech32@npm:1.1.4" - checksum: 10/63ff37c0ce43be914c685ce89700bba1589c319af0dac1ea04f51b33d0e5ecfd40d14c24f527350b94f0a4e236385373bb9122ec276410f354ddcdbf29ca13f4 - languageName: node - linkType: hard - "big-integer@npm:^1.6.17": version: 1.6.51 resolution: "big-integer@npm:1.6.51" @@ -10397,13 +9937,6 @@ __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" @@ -11853,13 +11386,6 @@ __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" @@ -12705,15 +12231,6 @@ __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" @@ -12732,21 +12249,6 @@ __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" @@ -13805,44 +13307,6 @@ __metadata: languageName: node linkType: hard -"ethers@npm:5.7.2": - version: 5.7.2 - resolution: "ethers@npm:5.7.2" - dependencies: - "@ethersproject/abi": "npm:5.7.0" - "@ethersproject/abstract-provider": "npm:5.7.0" - "@ethersproject/abstract-signer": "npm:5.7.0" - "@ethersproject/address": "npm:5.7.0" - "@ethersproject/base64": "npm:5.7.0" - "@ethersproject/basex": "npm:5.7.0" - "@ethersproject/bignumber": "npm:5.7.0" - "@ethersproject/bytes": "npm:5.7.0" - "@ethersproject/constants": "npm:5.7.0" - "@ethersproject/contracts": "npm:5.7.0" - "@ethersproject/hash": "npm:5.7.0" - "@ethersproject/hdnode": "npm:5.7.0" - "@ethersproject/json-wallets": "npm:5.7.0" - "@ethersproject/keccak256": "npm:5.7.0" - "@ethersproject/logger": "npm:5.7.0" - "@ethersproject/networks": "npm:5.7.1" - "@ethersproject/pbkdf2": "npm:5.7.0" - "@ethersproject/properties": "npm:5.7.0" - "@ethersproject/providers": "npm:5.7.2" - "@ethersproject/random": "npm:5.7.0" - "@ethersproject/rlp": "npm:5.7.0" - "@ethersproject/sha2": "npm:5.7.0" - "@ethersproject/signing-key": "npm:5.7.0" - "@ethersproject/solidity": "npm:5.7.0" - "@ethersproject/strings": "npm:5.7.0" - "@ethersproject/transactions": "npm:5.7.0" - "@ethersproject/units": "npm:5.7.0" - "@ethersproject/wallet": "npm:5.7.0" - "@ethersproject/web": "npm:5.7.1" - "@ethersproject/wordlists": "npm:5.7.0" - checksum: 10/227dfa88a2547c799c0c3c9e92e5e246dd11342f4b495198b3ae7c942d5bf81d3970fcef3fbac974a9125d62939b2d94f3c0458464e702209b839a8e6e615028 - languageName: node - linkType: hard - "ethers@npm:^6.3.0": version: 6.3.0 resolution: "ethers@npm:6.3.0" @@ -15210,7 +14674,7 @@ __metadata: languageName: node linkType: hard -"hash.js@npm:1.1.7, hash.js@npm:^1.0.0, hash.js@npm:^1.0.3": +"hash.js@npm:^1.0.0, hash.js@npm:^1.0.3": version: 1.1.7 resolution: "hash.js@npm:1.1.7" dependencies: @@ -16959,13 +16423,6 @@ __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" @@ -17211,18 +16668,6 @@ __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" @@ -18589,15 +18034,6 @@ __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" @@ -18664,17 +18100,6 @@ __metadata: languageName: node linkType: hard -"node-gyp-build@npm:^4.2.0": - version: 4.8.2 - resolution: "node-gyp-build@npm:4.8.2" - bin: - node-gyp-build: bin.js - node-gyp-build-optional: optional.js - node-gyp-build-test: build-test.js - checksum: 10/e3a365eed7a2d950864a1daa34527588c16fe43ae189d0aeb8fd1dfec91ba42a0e1b499322bff86c2832029fec4f5901bf26e32005e1e17a781dcd5177b6a657 - languageName: node - linkType: hard - "node-gyp@npm:^10.0.0": version: 10.0.1 resolution: "node-gyp@npm:10.0.1" @@ -21212,13 +20637,6 @@ __metadata: languageName: node linkType: hard -"scrypt-js@npm:3.0.1": - version: 3.0.1 - resolution: "scrypt-js@npm:3.0.1" - checksum: 10/2f8aa72b7f76a6f9c446bbec5670f80d47497bccce98474203d89b5667717223eeb04a50492ae685ed7adc5a060fc2d8f9fd988f8f7ebdaf3341967f3aeff116 - languageName: node - linkType: hard - "select-hose@npm:^2.0.0": version: 2.0.0 resolution: "select-hose@npm:2.0.0" @@ -23233,13 +22651,6 @@ __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" @@ -23966,21 +23377,6 @@ __metadata: languageName: node linkType: hard -"ws@npm:7.4.6": - version: 7.4.6 - resolution: "ws@npm:7.4.6" - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: ^5.0.2 - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - checksum: 10/150e3f917b7cde568d833a5ea6ccc4132e59c38d04218afcf2b6c7b845752bd011a9e0dc1303c8694d3c402a0bdec5893661a390b71ff88f0fc81a4e4e66b09c - languageName: node - linkType: hard - "ws@npm:8.13.0, ws@npm:^8.11.0, ws@npm:^8.13.0, ws@npm:^8.8.0": version: 8.13.0 resolution: "ws@npm:8.13.0" From 1c6ed6038865edd4d9bf290150280f2762728569 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Mon, 11 Nov 2024 14:40:37 +0100 Subject: [PATCH 10/15] Add basic DeviceController test --- .../src/devices/DeviceController.test.ts | 68 +++++++++++++++++++ .../src/devices/DeviceController.ts | 49 ++++++++++++- .../src/test-utils/controller.ts | 59 ++++++++++++++++ .../src/test-utils/devices.ts | 19 ++++++ .../snaps-controllers/src/test-utils/index.ts | 1 + 5 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 packages/snaps-controllers/src/devices/DeviceController.test.ts create mode 100644 packages/snaps-controllers/src/test-utils/devices.ts 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..d570ce3423 --- /dev/null +++ b/packages/snaps-controllers/src/devices/DeviceController.test.ts @@ -0,0 +1,68 @@ +import { MOCK_SNAP_ID } from '@metamask/snaps-utils/test-utils'; +import { + getRestrictedDeviceControllerMessenger, + MOCK_DEVICE_ID, +} from '../test-utils'; +import { DeviceController } from './DeviceController'; +import { bytesToHex } from '@metamask/utils'; + +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(); + const _controller = new DeviceController({ messenger }); + + const pairingPromise = messenger.call( + 'DeviceController:requestDevice', + MOCK_SNAP_ID, + ); + + 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 index 655fb24be8..77bc0a0e6b 100644 --- a/packages/snaps-controllers/src/devices/DeviceController.ts +++ b/packages/snaps-controllers/src/devices/DeviceController.ts @@ -22,6 +22,7 @@ import type { WriteDeviceParams, } from '@metamask/snaps-sdk'; import { + add0x, createDeferredPromise, hasProperty, Hex, @@ -39,6 +40,26 @@ export type DeviceControllerGetStateAction = ControllerGetStateAction< 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']; @@ -52,7 +73,11 @@ export type DeviceControllerRejectPairingAction = { export type DeviceControllerActions = | DeviceControllerGetStateAction | DeviceControllerResolvePairingAction - | DeviceControllerRejectPairingAction; + | DeviceControllerRejectPairingAction + | DeviceControllerRequestDeviceAction + | DeviceControllerListDevicesAction + | DeviceControllerWriteDeviceAction + | DeviceControllerReadDeviceAction; export type DeviceControllerStateChangeEvent = ControllerStateChangeEvent< typeof controllerName, @@ -133,6 +158,26 @@ export class DeviceController extends BaseController< state: { ...state, devices: {}, pairing: null }, }); + 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), @@ -186,7 +231,7 @@ export class DeviceController extends BaseController< device.addEventListener('inputreport', (event: any) => { const promiseResolve = this.#openDevices[id].resolvePromise; - const data = Buffer.from(event.data.buffer).toString('hex') as Hex; + const data = add0x(Buffer.from(event.data.buffer).toString('hex')) as Hex; const result = { reportId: event.reportId, diff --git a/packages/snaps-controllers/src/test-utils/controller.ts b/packages/snaps-controllers/src/test-utils/controller.ts index bcb7e27708..e2c327506b 100644 --- a/packages/snaps-controllers/src/test-utils/controller.ts +++ b/packages/snaps-controllers/src/test-utils/controller.ts @@ -61,6 +61,13 @@ import type { KeyDerivationOptions } from '../types'; import { MOCK_CRONJOB_PERMISSION } from './cronjob'; import { getNodeEES, getNodeEESMessenger } from './execution-environment'; import { MockSnapsRegistry } from './registry'; +import { + DeviceControllerActions, + DeviceControllerAllowedActions, + DeviceControllerAllowedEvents, + DeviceControllerEvents, +} from '../devices'; +import { MOCK_DEVICE_PERMISSION } from './devices'; const asyncNoOp = async () => Promise.resolve(); @@ -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..25fa572e88 --- /dev/null +++ b/packages/snaps-controllers/src/test-utils/devices.ts @@ -0,0 +1,19 @@ +import { 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'; From 8cd79edee03bb4296e7a1e3904b83db30c80f606 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Mon, 11 Nov 2024 14:56:09 +0100 Subject: [PATCH 11/15] Store filters in state --- .../src/devices/DeviceController.test.ts | 1 + .../src/devices/DeviceController.ts | 24 +++++++++++++++---- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/snaps-controllers/src/devices/DeviceController.test.ts b/packages/snaps-controllers/src/devices/DeviceController.test.ts index d570ce3423..3024470278 100644 --- a/packages/snaps-controllers/src/devices/DeviceController.test.ts +++ b/packages/snaps-controllers/src/devices/DeviceController.test.ts @@ -39,6 +39,7 @@ describe('DeviceController', () => { const pairingPromise = messenger.call( 'DeviceController:requestDevice', MOCK_SNAP_ID, + { type: 'hid' }, ); messenger.call('DeviceController:resolvePairing', MOCK_DEVICE_ID); diff --git a/packages/snaps-controllers/src/devices/DeviceController.ts b/packages/snaps-controllers/src/devices/DeviceController.ts index 77bc0a0e6b..d0b5c4dde2 100644 --- a/packages/snaps-controllers/src/devices/DeviceController.ts +++ b/packages/snaps-controllers/src/devices/DeviceController.ts @@ -15,9 +15,11 @@ import { getPermittedDeviceIds, } from '@metamask/snaps-rpc-methods'; import type { + DeviceFilter, DeviceId, ListDevicesParams, ReadDeviceParams, + RequestDeviceParams, SnapId, WriteDeviceParams, } from '@metamask/snaps-sdk'; @@ -117,7 +119,11 @@ export type ConnectedDevice = { export type DeviceControllerState = { devices: Record; - pairing: { snapId: string } | null; + pairing: { + snapId: string; + type: DeviceType; + filters?: DeviceFilter[]; + } | null; }; export type DeviceControllerArgs = { @@ -189,8 +195,8 @@ export class DeviceController extends BaseController< ); } - async requestDevice(snapId: string) { - const deviceId = await this.#requestPairing({ snapId }); + async requestDevice(snapId: string, { type, filters }: RequestDeviceParams) { + const deviceId = await this.#requestPairing({ snapId, type, filters }); // await this.#syncDevices(); @@ -417,7 +423,15 @@ export class DeviceController extends BaseController< return this.#pairing !== undefined; } - async #requestPairing({ snapId }: { snapId: string }) { + 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.'); @@ -431,7 +445,7 @@ export class DeviceController extends BaseController< await this.#syncDevices(); this.update((draftState) => { - draftState.pairing = { snapId }; + draftState.pairing = { snapId, type, filters }; }); return promise; From 372e8f50db06b9aedcd9dc30c6a6319b73ed0b01 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Mon, 11 Nov 2024 15:24:19 +0100 Subject: [PATCH 12/15] Use enum for device type --- .../src/devices/DeviceController.ts | 35 ++++++++++--------- .../src/permitted/readDevice.ts | 4 +-- .../src/permitted/writeDevice.ts | 4 +-- packages/snaps-sdk/src/types/device.ts | 12 ++++--- .../src/types/methods/list-devices.ts | 3 +- .../src/types/methods/read-device.ts | 7 ++-- .../src/types/methods/request-device.ts | 4 +-- .../src/types/methods/write-device.ts | 7 ++-- 8 files changed, 41 insertions(+), 35 deletions(-) diff --git a/packages/snaps-controllers/src/devices/DeviceController.ts b/packages/snaps-controllers/src/devices/DeviceController.ts index d0b5c4dde2..9b0fe6c73b 100644 --- a/packages/snaps-controllers/src/devices/DeviceController.ts +++ b/packages/snaps-controllers/src/devices/DeviceController.ts @@ -23,11 +23,12 @@ import type { SnapId, WriteDeviceParams, } from '@metamask/snaps-sdk'; +import { DeviceType } from '@metamask/snaps-sdk'; +import type { Hex } from '@metamask/utils'; import { add0x, createDeferredPromise, hasProperty, - Hex, hexToBytes, } from '@metamask/utils'; @@ -98,10 +99,6 @@ export type DeviceControllerMessenger = RestrictedControllerMessenger< DeviceControllerAllowedEvents['type'] >; -export enum DeviceType { - HID = 'hid', -} - export type DeviceMetadata = { type: DeviceType; id: DeviceId; @@ -196,7 +193,11 @@ export class DeviceController extends BaseController< } async requestDevice(snapId: string, { type, filters }: RequestDeviceParams) { - const deviceId = await this.#requestPairing({ snapId, type, filters }); + const deviceId = await this.#requestPairing({ + snapId, + type: type as DeviceType, + filters, + }); // await this.#syncDevices(); @@ -237,7 +238,7 @@ export class DeviceController extends BaseController< device.addEventListener('inputreport', (event: any) => { const promiseResolve = this.#openDevices[id].resolvePromise; - const data = add0x(Buffer.from(event.data.buffer).toString('hex')) as Hex; + const data = add0x(Buffer.from(event.data.buffer).toString('hex')); const result = { reportId: event.reportId, @@ -320,17 +321,17 @@ export class DeviceController extends BaseController< if (reportType === 'feature') { return actualDevice.receiveFeatureReport(reportId); - } else { - // TODO: Deal with report IDs? - // TODO: Clean up - if (this.#openDevices[id].buffer.length > 0) { - const result = this.#openDevices[id].buffer.shift(); - return result!.data; - } else { - const result = await this.#waitForNextRead(id); - return result!.data; - } } + // TODO: Deal with report IDs? + // TODO: Clean up + if (this.#openDevices[id].buffer.length > 0) { + const result = this.#openDevices[id].buffer.shift(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return result!.data; + } + const result = await this.#waitForNextRead(id); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return result!.data; } async listDevices(snapId: SnapId, { type }: ListDevicesParams) { diff --git a/packages/snaps-rpc-methods/src/permitted/readDevice.ts b/packages/snaps-rpc-methods/src/permitted/readDevice.ts index cdf434ab19..3e65c5d49a 100644 --- a/packages/snaps-rpc-methods/src/permitted/readDevice.ts +++ b/packages/snaps-rpc-methods/src/permitted/readDevice.ts @@ -6,7 +6,7 @@ import type { ReadDeviceParams, ReadDeviceResult, } from '@metamask/snaps-sdk'; -import { deviceId } from '@metamask/snaps-sdk'; +import { deviceId, DeviceType } from '@metamask/snaps-sdk'; import type { InferMatching } from '@metamask/snaps-utils'; import { create, @@ -47,7 +47,7 @@ export const readDeviceHandler: PermittedHandlerExport< const ReadDeviceParametersStruct = object({ type: literal('hid'), - id: deviceId('hid'), + id: deviceId(DeviceType.HID), reportType: optional(union([literal('output'), literal('feature')])), reportId: optional(number()), }); diff --git a/packages/snaps-rpc-methods/src/permitted/writeDevice.ts b/packages/snaps-rpc-methods/src/permitted/writeDevice.ts index 6d81c37c64..8e291417d0 100644 --- a/packages/snaps-rpc-methods/src/permitted/writeDevice.ts +++ b/packages/snaps-rpc-methods/src/permitted/writeDevice.ts @@ -6,7 +6,7 @@ import type { WriteDeviceParams, WriteDeviceResult, } from '@metamask/snaps-sdk'; -import { deviceId } from '@metamask/snaps-sdk'; +import { deviceId, DeviceType } from '@metamask/snaps-sdk'; import type { InferMatching } from '@metamask/snaps-utils'; import { create, @@ -47,7 +47,7 @@ export const writeDeviceHandler: PermittedHandlerExport< const WriteDeviceParametersStruct = object({ type: literal('hid'), - id: deviceId('hid'), + id: deviceId(DeviceType.HID), data: StrictHexStruct, reportId: optional(number()), }); diff --git a/packages/snaps-sdk/src/types/device.ts b/packages/snaps-sdk/src/types/device.ts index 5942a9739b..ba2b08abd5 100644 --- a/packages/snaps-sdk/src/types/device.ts +++ b/packages/snaps-sdk/src/types/device.ts @@ -1,17 +1,19 @@ import type { Struct } from '@metamask/superstruct'; -import { literal, refine, string } from '@metamask/superstruct'; +import { refine, string } from '@metamask/superstruct'; -import type { Describe } from '../internals'; +import { enumValue } from '../internals'; /** * The type of the device. Currently, only `hid` is supported. */ -export type DeviceType = 'hid'; +export enum DeviceType { + HID = 'hid', +} /** * A struct that represents the `DeviceType` type. */ -export const DeviceTypeStruct: Describe = literal('hid'); +export const DeviceTypeStruct = enumValue(DeviceType.HID); /** * The ID of the device. It consists of the type of the device, the vendor ID, @@ -91,4 +93,4 @@ type ScopedDevice = Device & { id: ScopedDeviceId; }; -export type HidDevice = ScopedDevice<'hid'>; +export type HidDevice = ScopedDevice; diff --git a/packages/snaps-sdk/src/types/methods/list-devices.ts b/packages/snaps-sdk/src/types/methods/list-devices.ts index 6ccdf8bb57..1a951fa1b2 100644 --- a/packages/snaps-sdk/src/types/methods/list-devices.ts +++ b/packages/snaps-sdk/src/types/methods/list-devices.ts @@ -1,3 +1,4 @@ +import type { EnumToUnion } from '../../internals'; import type { Device, DeviceType } from '../device'; /** @@ -7,7 +8,7 @@ export type ListDevicesParams = { /** * The type(s) of the device to list. If not provided, all devices are listed. */ - type?: DeviceType | DeviceType[]; + type?: EnumToUnion | EnumToUnion[]; }; /** diff --git a/packages/snaps-sdk/src/types/methods/read-device.ts b/packages/snaps-sdk/src/types/methods/read-device.ts index 34ab447813..ec5e876417 100644 --- a/packages/snaps-sdk/src/types/methods/read-device.ts +++ b/packages/snaps-sdk/src/types/methods/read-device.ts @@ -1,6 +1,7 @@ import type { Hex } from '@metamask/utils'; -import type { ScopedDeviceId } from '../device'; +import type { EnumToUnion } from '../../internals'; +import type { DeviceType, ScopedDeviceId } from '../device'; /** * The request parameters for the `snap_readDevice` method reading from an HID @@ -10,12 +11,12 @@ type HidReadParams = { /** * The type of the device. */ - type: 'hid'; + type: EnumToUnion; /** * The ID of the device to read from. */ - id: ScopedDeviceId<'hid'>; + id: ScopedDeviceId; /** * The type of the data to read. This is either an output report or a feature diff --git a/packages/snaps-sdk/src/types/methods/request-device.ts b/packages/snaps-sdk/src/types/methods/request-device.ts index f2df8cc6db..df3764a93e 100644 --- a/packages/snaps-sdk/src/types/methods/request-device.ts +++ b/packages/snaps-sdk/src/types/methods/request-device.ts @@ -1,6 +1,6 @@ import { number, object, optional } from '@metamask/superstruct'; -import type { Describe } from '../../internals'; +import type { Describe, EnumToUnion } from '../../internals'; import type { Device, DeviceType } from '../device'; export type DeviceFilter = { @@ -30,7 +30,7 @@ export type RequestDeviceParams = { /** * The type of the device to request. */ - type: DeviceType; + type: EnumToUnion; /** * The filters to apply to the devices. diff --git a/packages/snaps-sdk/src/types/methods/write-device.ts b/packages/snaps-sdk/src/types/methods/write-device.ts index 05152d2c2e..f2536b4805 100644 --- a/packages/snaps-sdk/src/types/methods/write-device.ts +++ b/packages/snaps-sdk/src/types/methods/write-device.ts @@ -1,6 +1,7 @@ import type { Hex } from '@metamask/utils'; -import type { ScopedDeviceId } from '../device'; +import type { EnumToUnion } from '../../internals'; +import type { DeviceType, ScopedDeviceId } from '../device'; /** * The request parameters for the `snap_writeDevice` method when writing to a @@ -10,12 +11,12 @@ type HidWriteParams = { /** * The type of the device. */ - type: 'hid'; + type: EnumToUnion; /** * The ID of the device to write to. */ - id: ScopedDeviceId<'hid'>; + id: ScopedDeviceId; /** * The type of the data to read. This is either an output report or a feature From bf5baed8daaad0b06843148ffab376817f60c0aa Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Mon, 11 Nov 2024 16:21:10 +0100 Subject: [PATCH 13/15] Use Device type --- .../src/devices/DeviceController.ts | 29 ++++++++----------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/packages/snaps-controllers/src/devices/DeviceController.ts b/packages/snaps-controllers/src/devices/DeviceController.ts index 9b0fe6c73b..906da04aea 100644 --- a/packages/snaps-controllers/src/devices/DeviceController.ts +++ b/packages/snaps-controllers/src/devices/DeviceController.ts @@ -15,6 +15,7 @@ import { getPermittedDeviceIds, } from '@metamask/snaps-rpc-methods'; import type { + Device, DeviceFilter, DeviceId, ListDevicesParams, @@ -99,19 +100,9 @@ export type DeviceControllerMessenger = RestrictedControllerMessenger< DeviceControllerAllowedEvents['type'] >; -export type DeviceMetadata = { - type: DeviceType; - id: DeviceId; - name: string; -}; - -export type Device = DeviceMetadata & { - connected: boolean; -}; - export type ConnectedDevice = { reference: any; // TODO: Type this - metadata: DeviceMetadata; + metadata: Device; }; export type DeviceControllerState = { @@ -375,7 +366,7 @@ export class DeviceController extends BaseController< this.update((draftState) => { for (const device of Object.values(draftState.devices)) { - draftState.devices[device.id].connected = hasProperty( + draftState.devices[device.id].available = hasProperty( connectedDevices, device.id, ); @@ -383,10 +374,7 @@ export class DeviceController extends BaseController< for (const device of Object.values(connectedDevices)) { if (!hasProperty(draftState.devices, device.metadata.id)) { // @ts-expect-error Not sure why this is failing, continuing. - draftState.devices[device.metadata.id] = { - ...device.metadata, - connected: true, - }; + draftState.devices[device.metadata.id] = device.metadata; } } }); @@ -406,7 +394,14 @@ export class DeviceController extends BaseController< // TODO: Figure out what to do about duplicates. accumulator[id] = { reference: device, - metadata: { type, id, name: productName }, + metadata: { + type, + id, + name: productName, + vendorId, + productId, + available: true, + }, }; return accumulator; From a891ca367c4f5051e3a2672a4bd274dd79b9ed41 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Mon, 11 Nov 2024 16:44:37 +0100 Subject: [PATCH 14/15] Fix transport after adding 0x to hex responses --- packages/examples/packages/ledger/snap.manifest.json | 2 +- packages/examples/packages/ledger/src/transport.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/examples/packages/ledger/snap.manifest.json b/packages/examples/packages/ledger/snap.manifest.json index 1d3a6eff68..0337c22699 100644 --- a/packages/examples/packages/ledger/snap.manifest.json +++ b/packages/examples/packages/ledger/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "LNfNpm9ZciGFP0AlUj/UkFR50cJnJuYtXXE2LM/5HJI=", + "shasum": "PaohqUxJniFTXeGB1eD3l3vIJHRBBPKAkmmlpk/K7H0=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/ledger/src/transport.ts b/packages/examples/packages/ledger/src/transport.ts index 9c378e03e8..9b7b785b53 100644 --- a/packages/examples/packages/ledger/src/transport.ts +++ b/packages/examples/packages/ledger/src/transport.ts @@ -226,7 +226,7 @@ export default class TransportSnapsHID extends Transport { }, }); - const buffer = Buffer.from(bytes, 'hex'); + const buffer = Buffer.from(bytes.slice(2), 'hex'); accumulator = framing.reduceResponse(accumulator, buffer); } From d6b2d0e1cbe9e9d54734e5003e27415ab931094b Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 15 Nov 2024 12:55:21 +0100 Subject: [PATCH 15/15] Abstract device and device manager (#2880) This adds an abstraction layer for devices using two abstract classes, `Device` (i.e., one device), and `DeviceManager` (which manages one or more `Device` classes). --------- Co-authored-by: Frederik Bolding --- .../browserify-plugin/snap.manifest.json | 2 +- .../packages/browserify/snap.manifest.json | 2 +- .../examples/packages/ledger/CHANGELOG.md | 1 + .../examples/packages/ledger/package.json | 1 + .../packages/ledger/snap.manifest.json | 2 +- .../ledger/src/components/ConnectHID.tsx | 9 + .../ledger/src/components/Unsupported.tsx | 9 + .../packages/ledger/src/components/index.ts | 2 + .../packages/ledger/src/index.test.tsx | 22 ++ .../examples/packages/ledger/src/index.tsx | 121 +++--- .../examples/packages/ledger/src/transport.ts | 38 +- .../examples/packages/ledger/src/utils.ts | 15 + packages/snaps-controllers/package.json | 2 + .../src/devices/DeviceController.test.ts | 14 +- .../src/devices/DeviceController.ts | 305 ++++++--------- .../src/devices/constants.ts | 3 + .../devices/implementations/device-manager.ts | 40 ++ .../src/devices/implementations/device.ts | 130 ++++++ .../devices/implementations/hid-manager.ts | 69 ++++ .../src/devices/implementations/hid.ts | 138 +++++++ .../src/devices/implementations/index.ts | 4 + .../src/test-utils/controller.ts | 14 +- .../src/test-utils/devices.ts | 2 +- .../src/types/event-emitter.ts | 60 +++ packages/snaps-controllers/src/types/index.ts | 1 + .../src/permitted/getSupportedDevices.ts | 48 +++ .../src/permitted/handlers.ts | 2 + packages/snaps-sdk/src/types/device.ts | 6 +- .../types/methods/get-supported-devices.ts | 2 +- .../src/types/methods/list-devices.ts | 4 +- .../src/types/methods/request-device.ts | 4 +- packages/test-snaps/package.json | 1 + .../test-snaps/src/features/snaps/index.ts | 1 + .../src/features/snaps/ledger/Ledger.tsx | 45 +++ .../src/features/snaps/ledger/constants.ts | 5 + .../src/features/snaps/ledger/index.ts | 1 + yarn.lock | 370 +++++++++++++++++- 37 files changed, 1233 insertions(+), 262 deletions(-) create mode 100644 packages/examples/packages/ledger/src/components/ConnectHID.tsx create mode 100644 packages/examples/packages/ledger/src/components/Unsupported.tsx create mode 100644 packages/examples/packages/ledger/src/components/index.ts create mode 100644 packages/examples/packages/ledger/src/index.test.tsx create mode 100644 packages/examples/packages/ledger/src/utils.ts create mode 100644 packages/snaps-controllers/src/devices/constants.ts create mode 100644 packages/snaps-controllers/src/devices/implementations/device-manager.ts create mode 100644 packages/snaps-controllers/src/devices/implementations/device.ts create mode 100644 packages/snaps-controllers/src/devices/implementations/hid-manager.ts create mode 100644 packages/snaps-controllers/src/devices/implementations/hid.ts create mode 100644 packages/snaps-controllers/src/devices/implementations/index.ts create mode 100644 packages/snaps-controllers/src/types/event-emitter.ts create mode 100644 packages/snaps-rpc-methods/src/permitted/getSupportedDevices.ts create mode 100644 packages/test-snaps/src/features/snaps/ledger/Ledger.tsx create mode 100644 packages/test-snaps/src/features/snaps/ledger/constants.ts create mode 100644 packages/test-snaps/src/features/snaps/ledger/index.ts 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/CHANGELOG.md b/packages/examples/packages/ledger/CHANGELOG.md index aa399df1be..720e00537e 100644 --- a/packages/examples/packages/ledger/CHANGELOG.md +++ b/packages/examples/packages/ledger/CHANGELOG.md @@ -1,4 +1,5 @@ # 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/), diff --git a/packages/examples/packages/ledger/package.json b/packages/examples/packages/ledger/package.json index 7e49707302..76dfaeab8f 100644 --- a/packages/examples/packages/ledger/package.json +++ b/packages/examples/packages/ledger/package.json @@ -45,6 +45,7 @@ "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" diff --git a/packages/examples/packages/ledger/snap.manifest.json b/packages/examples/packages/ledger/snap.manifest.json index 0337c22699..793f3dd1c2 100644 --- a/packages/examples/packages/ledger/snap.manifest.json +++ b/packages/examples/packages/ledger/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "PaohqUxJniFTXeGB1eD3l3vIJHRBBPKAkmmlpk/K7H0=", + "shasum": "OvH5LaGRY+j5/bDleyl7BHu7ces0k59Wmssm+fU2rfs=", "location": { "npm": { "filePath": "dist/bundle.js", 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 index 1cc4ad1728..b951fe869e 100644 --- a/packages/examples/packages/ledger/src/index.tsx +++ b/packages/examples/packages/ledger/src/index.tsx @@ -1,24 +1,43 @@ +import Eth from '@ledgerhq/hw-app-eth'; import type { OnRpcRequestHandler, OnUserInputHandler, } from '@metamask/snaps-sdk'; -import { Box, Button, Text, Copyable } from '@metamask/snaps-sdk/jsx'; import { MethodNotFoundError } from '@metamask/snaps-sdk'; -import Eth from '@ledgerhq/hw-app-eth'; +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: ( - - - - ), + content: , }, }); } @@ -30,49 +49,51 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { } }; -function hexlifySignature(signature: { r: string; s: string; v: number }) { - const adjustedV = signature.v - 27; - let hexlifiedV = adjustedV.toString(16); - if (hexlifiedV.length < 2) { - hexlifiedV = '0' + hexlifiedV; - } - return `0x${signature.r}${signature.s}${hexlifiedV}`; -} - +/** + * 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 }) => { - try { - const transport = await TransportSnapsHID.request(); - const eth = new Eth(transport); - const msg = 'test'; - const { address } = await eth.getAddress("44'/60'/0'/0/0"); - const signature = await eth.signPersonalMessage( - "44'/60'/0'/0/0", - Buffer.from(msg).toString('hex'), - ); + // TODO: Handle errors (i.e., Ledger locked, disconnected, etc.) + const transport = await TransportSnapsHID.request(); + const eth = new Eth(transport); - const signatureHex = hexlifySignature(signature); - const message = { - address, - msg, - sig: signatureHex, - version: 2, - }; - await snap.request({ - method: 'snap_updateInterface', - params: { - id, - ui: ( - - - Signature: - - JSON: - - - ), - }, - }); - } catch (error) { - console.error(error); - } + // 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 index 9b7b785b53..4284094164 100644 --- a/packages/examples/packages/ledger/src/transport.ts +++ b/packages/examples/packages/ledger/src/transport.ts @@ -8,7 +8,8 @@ import type { Subscription, } from '@ledgerhq/hw-transport'; import Transport from '@ledgerhq/hw-transport'; -import type { HidDevice } from '@metamask/snaps-sdk'; +import type { HidDeviceMetadata } from '@metamask/snaps-sdk'; +import { DeviceType } from '@metamask/snaps-sdk'; import { bytesToHex } from '@metamask/utils'; /** @@ -21,19 +22,31 @@ async function requestDevice() { return (await snap.request({ method: 'snap_requestDevice', params: { type: 'hid', filters: [{ vendorId: ledgerUSBVendorId }] }, - })) as HidDevice; + })) as HidDeviceMetadata; } export default class TransportSnapsHID extends Transport { - readonly device: HidDevice; + /** + * 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: HidDevice) { + constructor(device: HidDeviceMetadata) { super(); this.device = device; @@ -51,7 +64,7 @@ export default class TransportSnapsHID extends Transport { method: 'snap_getSupportedDevices', }); - return types.includes('hid'); + return types.includes(DeviceType.HID); } /** @@ -63,7 +76,7 @@ export default class TransportSnapsHID extends Transport { const devices = (await snap.request({ method: 'snap_listDevices', params: { type: 'hid' }, - })) as HidDevice[]; + })) as HidDeviceMetadata[]; return devices.filter( (device) => device.vendorId === ledgerUSBVendorId && device.available, @@ -77,7 +90,9 @@ export default class TransportSnapsHID extends Transport { * @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 { + static listen( + observer: Observer>, + ): Subscription { let unsubscribed = false; /** @@ -92,7 +107,7 @@ export default class TransportSnapsHID extends Transport { * * @param device - The device to emit. */ - function emit(device: HidDevice) { + function emit(device: HidDeviceMetadata) { observer.next({ type: 'add', descriptor: device, @@ -181,7 +196,7 @@ export default class TransportSnapsHID extends Transport { * @param device - The device to connect to. * @returns A transport. */ - static async open(device: HidDevice) { + static async open(device: HidDeviceMetadata) { return new TransportSnapsHID(device); } @@ -234,6 +249,11 @@ export default class TransportSnapsHID extends Transport { }); }; + /** + * 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/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 index 3024470278..e9789f31b7 100644 --- a/packages/snaps-controllers/src/devices/DeviceController.test.ts +++ b/packages/snaps-controllers/src/devices/DeviceController.test.ts @@ -1,11 +1,17 @@ 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'; -import { bytesToHex } from '@metamask/utils'; +/** + * 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, @@ -19,12 +25,14 @@ function mockNavigator() { 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 }; @@ -34,7 +42,9 @@ describe('DeviceController', () => { it('can request a device and use read/write', async () => { const { device } = mockNavigator(); const messenger = getRestrictedDeviceControllerMessenger(); - const _controller = new DeviceController({ messenger }); + + // eslint-disable-next-line no-new + new DeviceController({ messenger }); const pairingPromise = messenger.call( 'DeviceController:requestDevice', diff --git a/packages/snaps-controllers/src/devices/DeviceController.ts b/packages/snaps-controllers/src/devices/DeviceController.ts index 906da04aea..72fd5165a1 100644 --- a/packages/snaps-controllers/src/devices/DeviceController.ts +++ b/packages/snaps-controllers/src/devices/DeviceController.ts @@ -1,7 +1,7 @@ import type { - RestrictedControllerMessenger, ControllerGetStateAction, ControllerStateChangeEvent, + RestrictedControllerMessenger, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; import type { @@ -10,12 +10,12 @@ import type { } from '@metamask/permission-controller'; import { rpcErrors } from '@metamask/rpc-errors'; import { + getPermittedDeviceIds, SnapCaveatType, SnapEndowments, - getPermittedDeviceIds, } from '@metamask/snaps-rpc-methods'; import type { - Device, + DeviceMetadata, DeviceFilter, DeviceId, ListDevicesParams, @@ -25,13 +25,11 @@ import type { WriteDeviceParams, } from '@metamask/snaps-sdk'; import { DeviceType } from '@metamask/snaps-sdk'; -import type { Hex } from '@metamask/utils'; -import { - add0x, - createDeferredPromise, - hasProperty, - hexToBytes, -} from '@metamask/utils'; +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'; @@ -100,13 +98,8 @@ export type DeviceControllerMessenger = RestrictedControllerMessenger< DeviceControllerAllowedEvents['type'] >; -export type ConnectedDevice = { - reference: any; // TODO: Type this - metadata: Device; -}; - export type DeviceControllerState = { - devices: Record; + devices: Record; pairing: { snapId: string; type: DeviceType; @@ -132,14 +125,11 @@ export class DeviceController extends BaseController< reject: (error: unknown) => void; }; - #openDevices: Record< - DeviceId, - { - buffer: { reportId: number; data: Hex }[]; - promise?: Promise<{ reportId: number; data: Hex }>; - resolvePromise?: any; - } - > = {}; + #managers: Record = { + [DeviceType.HID]: new HIDManager(), + }; + + #devices: Record = {}; constructor({ messenger, state }: DeviceControllerArgs) { super({ @@ -149,7 +139,7 @@ export class DeviceController extends BaseController< pairing: { persist: false, anonymous: false }, }, name: controllerName, - state: { ...state, devices: {}, pairing: null }, + state: { devices: {}, pairing: null, ...state }, }); this.messagingSystem.registerActionHandler( @@ -181,6 +171,20 @@ export class DeviceController extends BaseController< `${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) { @@ -190,8 +194,6 @@ export class DeviceController extends BaseController< filters, }); - // await this.#syncDevices(); - // TODO: Figure out how to revoke these permissions again? this.messagingSystem.call( 'PermissionController:grantPermissionsIncremental', @@ -211,123 +213,43 @@ export class DeviceController extends BaseController< }, ); - // TODO: Can a paired device by not connected? - const device = await this.#getConnectedDeviceById(deviceId); - return device.metadata; - } - - // TODO: Clean up - async #openDevice(id: DeviceId, device: any) { - await device.open(); - - if (!this.#openDevices[id]) { - this.#openDevices[id] = { - buffer: [], - }; - } + await this.#synchronize(this.#managers[type]); - device.addEventListener('inputreport', (event: any) => { - const promiseResolve = this.#openDevices[id].resolvePromise; - - const data = add0x(Buffer.from(event.data.buffer).toString('hex')); - - const result = { - reportId: event.reportId, - data, - }; - - if (promiseResolve) { - promiseResolve(result); - delete this.#openDevices[id].resolvePromise; - delete this.#openDevices[id].promise; - } else { - this.#openDevices[id].buffer.push(result); - } - }); - } - - #waitForNextRead(id: DeviceId) { - if (this.#openDevices[id].promise) { - return this.#openDevices[id].promise; - } - - const { promise, resolve } = createDeferredPromise<{ - reportId: number; - data: Hex; - }>(); - - this.#openDevices[id].resolvePromise = resolve; - this.#openDevices[id].promise = promise; - return promise; + // TODO: Can a paired device be not connected? + return this.state.devices[deviceId]; } - async writeDevice( - snapId: SnapId, - { id, reportId = 0, reportType, data }: WriteDeviceParams, - ) { + async writeDevice(snapId: SnapId, params: WriteDeviceParams) { + const { id } = params; if (!this.#hasPermission(snapId, id)) { // TODO: Decide on error message throw rpcErrors.invalidParams(); } - const device = await this.#getConnectedDeviceById(id); - if (!device) { - // Handle - } - - const actualDevice = device.reference; - - if (!actualDevice.opened) { - await this.#openDevice(id, actualDevice); - } + const device = this.#devices[id]; + assert(device, 'Device not found.'); - if (reportType === 'feature') { - await actualDevice.sendFeatureReport(reportId, hexToBytes(data)); - } else { - await actualDevice.sendReport(reportId, hexToBytes(data)); - } + await this.#openDevice(id); + await device.write(params); return null; } - async readDevice( - snapId: SnapId, - { id, reportId = 0, reportType }: ReadDeviceParams, - ) { + async readDevice(snapId: SnapId, params: ReadDeviceParams) { + const { id } = params; if (!this.#hasPermission(snapId, id)) { // TODO: Decide on error message throw rpcErrors.invalidParams(); } - const device = await this.#getConnectedDeviceById(id); - if (!device) { - // Handle - } - - const actualDevice = device.reference; - - if (!actualDevice.opened) { - await this.#openDevice(id, actualDevice); - } + const device = this.#devices[id]; + assert(device, 'Device not found.'); - if (reportType === 'feature') { - return actualDevice.receiveFeatureReport(reportId); - } - // TODO: Deal with report IDs? - // TODO: Clean up - if (this.#openDevices[id].buffer.length > 0) { - const result = this.#openDevices[id].buffer.shift(); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return result!.data; - } - const result = await this.#waitForNextRead(id); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return result!.data; + await this.#openDevice(id); + return await device.read(params); } async listDevices(snapId: SnapId, { type }: ListDevicesParams) { - await this.#syncDevices(); - const permittedDevices = this.#getPermittedDevices(snapId); const deviceData = permittedDevices.map( (device) => this.state.devices[device.deviceId], @@ -337,6 +259,7 @@ export class DeviceController extends BaseController< const types = Array.isArray(type) ? type : [type]; return deviceData.filter((device) => types.includes(device.type)); } + return deviceData; } @@ -345,6 +268,7 @@ export class DeviceController extends BaseController< 'PermissionController:getPermissions', snapId, ); + if (!permissions || !hasProperty(permissions, SnapEndowments.Devices)) { return []; } @@ -361,60 +285,6 @@ export class DeviceController extends BaseController< ); } - async #syncDevices() { - const connectedDevices = await this.#getConnectedDevices(); - - this.update((draftState) => { - for (const device of Object.values(draftState.devices)) { - draftState.devices[device.id].available = hasProperty( - connectedDevices, - device.id, - ); - } - for (const device of Object.values(connectedDevices)) { - if (!hasProperty(draftState.devices, device.metadata.id)) { - // @ts-expect-error Not sure why this is failing, continuing. - draftState.devices[device.metadata.id] = device.metadata; - } - } - }); - } - - // Get actually connected devices - async #getConnectedDevices(): Promise> { - const type = DeviceType.HID; - // TODO: Merge multiple device implementations - const devices: any[] = await (navigator as any).hid.getDevices(); - return devices.reduce>( - (accumulator, device) => { - const { vendorId, productId, productName } = device; - - const id = `${type}:${vendorId}:${productId}` as DeviceId; - - // TODO: Figure out what to do about duplicates. - accumulator[id] = { - reference: device, - metadata: { - type, - id, - name: productName, - vendorId, - productId, - available: true, - }, - }; - - return accumulator; - }, - {}, - ); - } - - async #getConnectedDeviceById(id: DeviceId) { - const devices = await this.#getConnectedDevices(); - return devices[id]; - } - #isPairing() { return this.#pairing !== undefined; } @@ -437,9 +307,6 @@ export class DeviceController extends BaseController< this.#pairing = { promise, resolve, reject }; - // TODO: Consider polling this call while pairing is ongoing? - await this.#syncDevices(); - this.update((draftState) => { draftState.pairing = { snapId, type, filters }; }); @@ -470,4 +337,84 @@ export class DeviceController extends BaseController< 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/test-utils/controller.ts b/packages/snaps-controllers/src/test-utils/controller.ts index e2c327506b..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,15 +65,9 @@ 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'; -import { - DeviceControllerActions, - DeviceControllerAllowedActions, - DeviceControllerAllowedEvents, - DeviceControllerEvents, -} from '../devices'; -import { MOCK_DEVICE_PERMISSION } from './devices'; const asyncNoOp = async () => Promise.resolve(); diff --git a/packages/snaps-controllers/src/test-utils/devices.ts b/packages/snaps-controllers/src/test-utils/devices.ts index 25fa572e88..6af846b297 100644 --- a/packages/snaps-controllers/src/test-utils/devices.ts +++ b/packages/snaps-controllers/src/test-utils/devices.ts @@ -1,4 +1,4 @@ -import { PermissionConstraint } from '@metamask/permission-controller'; +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'; 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/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 05f72b9285..d3d8041c46 100644 --- a/packages/snaps-rpc-methods/src/permitted/handlers.ts +++ b/packages/snaps-rpc-methods/src/permitted/handlers.ts @@ -6,6 +6,7 @@ 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'; @@ -33,6 +34,7 @@ export const methodHandlers = { snap_readDevice: readDeviceHandler, snap_requestDevice: requestDeviceHandler, snap_writeDevice: writeDeviceHandler, + snap_getSupportedDevices: getSupportedDevicesHandler, }; /* eslint-enable @typescript-eslint/naming-convention */ diff --git a/packages/snaps-sdk/src/types/device.ts b/packages/snaps-sdk/src/types/device.ts index ba2b08abd5..5a08f071b4 100644 --- a/packages/snaps-sdk/src/types/device.ts +++ b/packages/snaps-sdk/src/types/device.ts @@ -56,7 +56,7 @@ export function deviceId( /** * A device that is available to the Snap. */ -export type Device = { +export type DeviceMetadata = { /** * The ID of the device. */ @@ -88,9 +88,9 @@ export type Device = { available: boolean; }; -type ScopedDevice = Device & { +type ScopedDeviceMetadata = DeviceMetadata & { type: Type; id: ScopedDeviceId; }; -export type HidDevice = ScopedDevice; +export type HidDeviceMetadata = ScopedDeviceMetadata; diff --git a/packages/snaps-sdk/src/types/methods/get-supported-devices.ts b/packages/snaps-sdk/src/types/methods/get-supported-devices.ts index 6f33ec6648..0bf3dcd97e 100644 --- a/packages/snaps-sdk/src/types/methods/get-supported-devices.ts +++ b/packages/snaps-sdk/src/types/methods/get-supported-devices.ts @@ -1,4 +1,4 @@ -import type { DeviceType } from '@metamask/snaps-sdk'; +import type { DeviceType } from '../device'; /** * The request parameters for the `snap_getSupportedDevices` method. diff --git a/packages/snaps-sdk/src/types/methods/list-devices.ts b/packages/snaps-sdk/src/types/methods/list-devices.ts index 1a951fa1b2..a178b9adfe 100644 --- a/packages/snaps-sdk/src/types/methods/list-devices.ts +++ b/packages/snaps-sdk/src/types/methods/list-devices.ts @@ -1,5 +1,5 @@ import type { EnumToUnion } from '../../internals'; -import type { Device, DeviceType } from '../device'; +import type { DeviceMetadata, DeviceType } from '../device'; /** * The request parameters for the `snap_listDevices` method. @@ -15,4 +15,4 @@ export type ListDevicesParams = { * The result returned by the `snap_readDevice` method. This is a list of * devices that are available to the Snap. */ -export type ListDevicesResult = Device[]; +export type ListDevicesResult = DeviceMetadata[]; diff --git a/packages/snaps-sdk/src/types/methods/request-device.ts b/packages/snaps-sdk/src/types/methods/request-device.ts index df3764a93e..6d120a53e2 100644 --- a/packages/snaps-sdk/src/types/methods/request-device.ts +++ b/packages/snaps-sdk/src/types/methods/request-device.ts @@ -1,7 +1,7 @@ import { number, object, optional } from '@metamask/superstruct'; import type { Describe, EnumToUnion } from '../../internals'; -import type { Device, DeviceType } from '../device'; +import type { DeviceMetadata, DeviceType } from '../device'; export type DeviceFilter = { /** @@ -42,4 +42,4 @@ export type RequestDeviceParams = { * The result returned by the `snap_requestDevice` method. This can be a single * device, or `null` if no device was provided. */ -export type RequestDeviceResult = Device | null; +export type RequestDeviceResult = DeviceMetadata | null; 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 c8797cf43c..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,16 @@ __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" @@ -3819,6 +4006,21 @@ __metadata: 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" @@ -3826,6 +4028,53 @@ __metadata: 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" @@ -3838,6 +4087,16 @@ __metadata: 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" @@ -3845,6 +4104,16 @@ __metadata: 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" @@ -5081,7 +5350,7 @@ __metadata: languageName: node linkType: hard -"@metamask/ledger-example-snap@workspace:packages/examples/packages/ledger": +"@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: @@ -5089,6 +5358,7 @@ __metadata: "@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" @@ -5803,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" @@ -5812,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" @@ -6459,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:^" @@ -8256,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" @@ -9713,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: @@ -9937,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" @@ -11386,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" @@ -12231,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" @@ -12249,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" @@ -14674,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: @@ -16423,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" @@ -16668,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" @@ -18034,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" @@ -18100,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" @@ -22651,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"