diff --git a/.github/workflows/build-speculos.yml b/.github/workflows/build-speculos.yml new file mode 100644 index 000000000..c73376612 --- /dev/null +++ b/.github/workflows/build-speculos.yml @@ -0,0 +1,84 @@ +name: Build Speculos Binary + +on: + workflow_dispatch: + inputs: + version: + description: 'Speculos version to build' + required: true + default: '0.25.13' + push: + tags: + - 'speculos-v*' + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pyinstaller + + - name: Install speculos + run: pip install speculos==${{ github.event.inputs.version || '0.25.13' }} + + - name: Build standalone binary + run: | + mkdir -p dist + pyinstaller \ + --onefile \ + --name speculos \ + --distpath dist \ + --workpath build \ + --noupx \ + --collect-all speculos \ + "$(python -c 'import speculos; print(speculos.__file__)')" + + - name: Verify binary architecture + run: | + file dist/speculos | tee /tmp/speculos-file.txt + grep -Fq 'ELF' /tmp/speculos-file.txt + grep -Fq 'x86-64' /tmp/speculos-file.txt + + - name: Package archive + run: | + VERSION="${{ github.event.inputs.version || '0.25.13' }}" + cd dist + tar -czf speculos-v${VERSION}-linux-amd64.tar.gz speculos + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: speculos-linux-amd64 + path: dist/speculos-v*-linux-amd64.tar.gz + if-no-files-found: error + + release: + needs: build + runs-on: ubuntu-latest + + steps: + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + pattern: speculos-linux-* + path: dist + merge-multiple: true + + - name: Upload release assets + uses: softprops/action-gh-release@v2 + with: + tag_name: speculos-v${{ github.event.inputs.version || '0.25.13' }} + files: dist/*.tar.gz diff --git a/.gitignore b/.gitignore index 8d474d7d9..3c91e498b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .DS_Store dist/ +dist-build/ coverage/ # Logs @@ -77,4 +78,7 @@ node_modules/ !.yarn/versions # Cursor rules -.cursorrules \ No newline at end of file +.cursorrules + +# Git worktrees +.worktrees/ \ No newline at end of file diff --git a/README.md b/README.md index 7cc92fd36..76edbad14 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ This repository contains the following packages [^fn1]: - [`@metamask/eth-simple-keyring`](packages/keyring-eth-simple) - [`@metamask/eth-snap-keyring`](packages/keyring-snap-bridge) - [`@metamask/eth-trezor-keyring`](packages/keyring-eth-trezor) +- [`@metamask/hw-emulator`](packages/hw-emulator) - [`@metamask/hw-wallet-sdk`](packages/hw-wallet-sdk) - [`@metamask/keyring-api`](packages/keyring-api) - [`@metamask/keyring-internal-api`](packages/keyring-internal-api) @@ -34,6 +35,7 @@ This repository contains the following packages [^fn1]: - [`@metamask/keyring-snap-client`](packages/keyring-snap-client) - [`@metamask/keyring-snap-sdk`](packages/keyring-snap-sdk) - [`@metamask/keyring-utils`](packages/keyring-utils) +- [`@metamask/speculos-up`](packages/speculos-up) @@ -46,6 +48,7 @@ Or, in graph form [^fn1]: graph LR; linkStyle default opacity:0.5 account_api(["@metamask/account-api"]); + hw_emulator(["@metamask/hw-emulator"]); hw_wallet_sdk(["@metamask/hw-wallet-sdk"]); keyring_api(["@metamask/keyring-api"]); eth_hd_keyring(["@metamask/eth-hd-keyring"]); @@ -61,6 +64,7 @@ linkStyle default opacity:0.5 keyring_snap_client(["@metamask/keyring-snap-client"]); keyring_snap_sdk(["@metamask/keyring-snap-sdk"]); keyring_utils(["@metamask/keyring-utils"]); + speculos_up(["@metamask/speculos-up"]); account_api --> keyring_api; account_api --> keyring_utils; keyring_api --> keyring_utils; diff --git a/packages/hw-emulator/CHANGELOG.md b/packages/hw-emulator/CHANGELOG.md new file mode 100644 index 000000000..6535acb7d --- /dev/null +++ b/packages/hw-emulator/CHANGELOG.md @@ -0,0 +1,34 @@ +# 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] + +### Added + +- Initial release of `@metamask/hw-emulator` ([#TODO](https://github.com/MetaMask/accounts/pull/TODO)) + - Hardware wallet emulator lifecycle, transport, and device interaction for E2E testing + - Ledger emulator via Speculos with support for Nano S+, Nano X, Stax, and Flex devices + - Docker and native run modes + - `SpeculosClient` for APDU exchange and screen events + - `ApduBridge` for WebSocket-to-APDU bridge (WebHID mocking) + - `DockerManager` for Docker Compose lifecycle management + - `ProcessManager` for native Speculos process spawning + - Device interaction automation (button presses, touch gestures) + - Resilience utilities (`withRetry`, `ExponentialBackoff`) + - Ledger HID framing session utilities + - WebHID mock script generation for E2E tests + - Deterministic accounts with pre-configured seed + - Bundled ELF app binaries for all supported devices + - Docker Compose configuration for Speculos + - JSDoc documentation on all public types, classes, methods, and constants + - `getElfFilePath` utility for resolving ELF binary paths (native mode) + - `startNative()` defaults to `@metamask/speculos-up` managed binary when no `binary` option is provided + - Fix Docker mode ignoring custom `apduPort` / `apiPort` by passing host ports to `docker-compose` + - Fix Docker mode ignoring the `seed` option by wiring `SPECULOS_SEED` through `docker-compose.yml` + - Fix Docker mode ignoring the `display` option by wiring `SPECULOS_DISPLAY` through `docker-compose.yml` + +[Unreleased]: https://github.com/MetaMask/accounts/ diff --git a/packages/hw-emulator/LICENSE b/packages/hw-emulator/LICENSE new file mode 100644 index 000000000..fe29e78e0 --- /dev/null +++ b/packages/hw-emulator/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 MetaMask + +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, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +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/hw-emulator/README.md b/packages/hw-emulator/README.md new file mode 100644 index 000000000..c2957de01 --- /dev/null +++ b/packages/hw-emulator/README.md @@ -0,0 +1,140 @@ +# `@metamask/hw-emulator` + +Hardware wallet emulator lifecycle, transport, and device interaction for E2E testing. + +Provides a programmatic interface for launching and controlling hardware wallet emulators (Ledger via [Speculos](https://github.com/LedgerHQ/speculos)), sending APDU commands, and automating device screen interactions (button presses, touch gestures) — all without physical hardware. + +## Installation + +`yarn add @metamask/hw-emulator` + +or + +`npm install @metamask/hw-emulator` + +## Supported Devices + +| Device | Model ID | Interaction Type | +| ------- | -------- | ---------------- | +| Nano S+ | `nanosp` | Button | +| Nano X | `nanox` | Button | +| Stax | `stax` | Touch | +| Flex | `flex` | Touch | + +## Quick Start + +### Docker Mode (recommended for macOS / CI) + +```typescript +import { createEmulator, EmulatorType } from '@metamask/hw-emulator'; + +const emulator = createEmulator(EmulatorType.Ledger, { + device: 'flex', + mode: 'docker', +}); + +await emulator.start(); +await emulator.approveTransaction(); +await emulator.stop(); +``` + +### Native Mode (Linux only) + +Requires the Speculos binary to be installed and available on `PATH`, or passed via the `binary` option. + +```typescript +import { createEmulator, EmulatorType } from '@metamask/hw-emulator'; + +const emulator = createEmulator(EmulatorType.Ledger, { + device: 'nanosp', + mode: 'native', + binary: '/usr/local/bin/speculos', +}); + +await emulator.start(); +await emulator.approveSigning(); +await emulator.stop(); +``` + +## API + +### `createEmulator(type, options?)` + +Factory function that creates a `HardwareWalletEmulator` instance. + +- **`type`** — `'ledger'` (via `EmulatorType.Ledger`). Trezor support is not yet implemented. +- **`options`** — See [`SpeculosOptions`](#speculosoptions). + +### `HardwareWalletEmulator` + +| Method | Description | +| ---------------------- | ------------------------------------------ | +| `start()` | Start the emulator (Docker or native). | +| `stop()` | Stop the emulator and clean up resources. | +| `isRunning()` | Returns whether the emulator is active. | +| `approveTransaction()` | Approve the current transaction on screen. | +| `approveSigning()` | Approve the current signing request. | +| `rejectTransaction()` | Reject the current transaction on screen. | +| `navigateToMainMenu()` | Navigate back to the device main menu. | +| `getInteraction()` | Get the low-level device interaction API. | + +### `SpeculosOptions` + +| Option | Type | Default | Description | +| -------------- | --------- | ------------ | ---------------------------------------------------- | +| `device` | `string` | `'flex'` | Device model ID (`nanosp`, `nanox`, `stax`, `flex`). | +| `seed` | `string` | Built-in | Mnemonic seed for deterministic accounts. | +| `apduPort` | `number` | `9998` | APDU communication port. | +| `apiPort` | `number` | `5001` | Speculos REST API port. | +| `wsBridgePort` | `number` | `9876` | WebSocket bridge port for WebHID mock. | +| `mode` | `string` | Auto | Run mode: `'docker'` or `'native'`. Auto-detected. | +| `binary` | `string` | — | Path to Speculos binary (native mode only). | +| `display` | `string` | `'headless'` | Display mode. | +| `loadNvram` | `boolean` | `true` | Load persisted NVRAM state. | +| `startTimeout` | `number` | `60000` | Startup timeout in ms. | + +### Low-Level APIs + +The package also exports granular components for advanced use cases: + +- **`SpeculosClient`** — HTTP client for the Speculos REST API (APDU exchange, screen events). +- **`ApduBridge`** — WebSocket-to-APDU bridge for browser-based WebHID mocking. +- **`DockerManager`** — Direct Docker Compose lifecycle management. +- **`createProcessManager()`** — Native Speculos process spawning and monitoring. +- **`createDeviceInteraction()`** — Screen automation (button presses, touch coordinates). +- **`withRetry()` / `ExponentialBackoff`** — Resilience utilities for flaky device communication. +- **`createLedgerHidFramingSession()`** — Low-level Ledger HID frame encoding/decoding. +- **`getWebHidMockScript()`** — Generates a browser script to mock WebHID for E2E tests. + +## Docker Setup + +The package includes a `docker-compose.yml` for running Speculos via Docker: + +```bash +# Start with default device (Flex) +docker compose up -d + +# Start with a specific device +SPECULOS_DEVICE=nanosp docker compose up -d + +# Start with custom host ports (must match apduPort / apiPort in createEmulator options) +SPECULOS_APDU_PORT=9997 SPECULOS_API_PORT=5002 docker compose up -d +``` + +The ELF app binaries for all supported devices are bundled in the `apps/` directory. + +## Deterministic Accounts + +The default seed produces these pre-funded Ethereum accounts: + +| Index | Address | +| ----- | -------------------------------------------- | +| 0 | `0x24fC293546A31F5Ce73bAfecE37969A95CCd1aBf` | +| 1 | `0x730A5c73bC3ACcf56daba2D5D897bEb10F852865` | +| 2 | `0x805c2797CCBa57887F5fA0DD95C017145d67604a` | +| 3 | `0x2Bf9972F600D8C3B3f0AEe8f1e17Fc4631242fF4` | +| 4 | `0xDc660e6D52F6f774d0879f99929711155Bc03902` | + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/accounts#readme). diff --git a/packages/hw-emulator/apps/ethereum-apex_p.elf b/packages/hw-emulator/apps/ethereum-apex_p.elf new file mode 100644 index 000000000..85725752c Binary files /dev/null and b/packages/hw-emulator/apps/ethereum-apex_p.elf differ diff --git a/packages/hw-emulator/apps/ethereum-flex.elf b/packages/hw-emulator/apps/ethereum-flex.elf new file mode 100644 index 000000000..c84dd4a41 Binary files /dev/null and b/packages/hw-emulator/apps/ethereum-flex.elf differ diff --git a/packages/hw-emulator/apps/ethereum-nanosp.elf b/packages/hw-emulator/apps/ethereum-nanosp.elf new file mode 100755 index 000000000..52e33f1f1 Binary files /dev/null and b/packages/hw-emulator/apps/ethereum-nanosp.elf differ diff --git a/packages/hw-emulator/apps/ethereum-nanox.elf b/packages/hw-emulator/apps/ethereum-nanox.elf new file mode 100644 index 000000000..673e22816 Binary files /dev/null and b/packages/hw-emulator/apps/ethereum-nanox.elf differ diff --git a/packages/hw-emulator/apps/ethereum-stax.elf b/packages/hw-emulator/apps/ethereum-stax.elf new file mode 100644 index 000000000..1ca95769d Binary files /dev/null and b/packages/hw-emulator/apps/ethereum-stax.elf differ diff --git a/packages/hw-emulator/docker-compose.yml b/packages/hw-emulator/docker-compose.yml new file mode 100644 index 000000000..f2f463cfa --- /dev/null +++ b/packages/hw-emulator/docker-compose.yml @@ -0,0 +1,38 @@ +version: '3.8' +services: + speculos: + image: ghcr.io/ledgerhq/speculos:latest + container_name: metamask-speculos + ports: + - '${SPECULOS_APDU_PORT:-9998}:9999' + - '${SPECULOS_API_PORT:-5001}:5000' + volumes: + - ./apps:/speculos/apps + - ./nvram/main_nvram.bin:/speculos/main_nvram.bin + environment: + - DISPLAY=:99 + command: > + --model ${SPECULOS_DEVICE:-nanosp} + /speculos/apps/${SPECULOS_ELF_FILENAME:-ethereum-nanosp.elf} + --seed "${SPECULOS_SEED:-grit essence story volume tip entry situate found february olympic monitor hybrid}" + --display ${SPECULOS_DISPLAY:-headless} + --apdu-port 9999 + --api-port 5000 + --load-nvram + healthcheck: + test: + - CMD-SHELL + - python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:5000/')" + interval: 2s + timeout: 3s + retries: 10 + +--- +# Note: +# - SPECULOS_APDU_PORT / SPECULOS_API_PORT are host-side ports (defaults 9998 / 5001) +# - Container listens on 9999 (APDU) and 5000 (API) internally +# - SPECULOS_DEVICE selects the device model (nanosp, nanox, stax, flex) +# - SPECULOS_ELF_FILENAME is the ELF filename inside /speculos/apps/ (NOT the host path) +# - SPECULOS_SEED sets the mnemonic for deterministic accounts (DockerManager / Speculos seed option) +# - SPECULOS_DISPLAY sets the Speculos display backend (DockerManager display option, default headless) +# - Defaults to Nano S+ (nanosp) for local development without SPECULOS_DEVICE set diff --git a/packages/hw-emulator/jest.config.js b/packages/hw-emulator/jest.config.js new file mode 100644 index 000000000..e345c1cac --- /dev/null +++ b/packages/hw-emulator/jest.config.js @@ -0,0 +1,10 @@ +const merge = require('deepmerge'); +const path = require('path'); +const baseConfig = require('../../jest.config.packages'); + +module.exports = merge(baseConfig, { + displayName: path.basename(__dirname), + coverageThreshold: { + global: { branches: 10, functions: 20, lines: 20, statements: 20 }, + }, +}); diff --git a/packages/hw-emulator/nvram/main_nvram.bin b/packages/hw-emulator/nvram/main_nvram.bin new file mode 100644 index 000000000..51460ef08 Binary files /dev/null and b/packages/hw-emulator/nvram/main_nvram.bin differ diff --git a/packages/hw-emulator/package.json b/packages/hw-emulator/package.json new file mode 100644 index 000000000..de09d6d06 --- /dev/null +++ b/packages/hw-emulator/package.json @@ -0,0 +1,108 @@ +{ + "name": "@metamask/hw-emulator", + "version": "0.1.0", + "description": "Hardware wallet emulator lifecycle, transport, and device interaction for E2E testing", + "keywords": [ + "e2e", + "emulator", + "hardware-wallet", + "ledger", + "metamask", + "testing", + "trezor" + ], + "homepage": "https://github.com/MetaMask/accounts/tree/main/packages/hw-emulator#readme", + "bugs": { + "url": "https://github.com/MetaMask/accounts/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/accounts.git" + }, + "files": [ + "dist/", + "apps/", + "nvram/", + "docker-compose.yml" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "browser": { + "./dist/ledger/docker-manager.cjs": false, + "./dist/ledger/process-manager.cjs": false + }, + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:clean": "yarn build --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/hw-emulator", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/hw-emulator", + "publish:preview": "yarn npm publish --tag preview", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test": "yarn test:source && yarn test:types", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:source": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:types": "../../scripts/tsd-test.sh ./src", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "dependencies": { + "@ledgerhq/devices": "^7.0.0", + "ws": "^8.18.0" + }, + "devDependencies": { + "@lavamoat/allow-scripts": "^3.2.1", + "@lavamoat/preinstall-always-fail": "^2.1.0", + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.3", + "@types/jest": "^29.5.12", + "@types/node": "^20.12.12", + "@types/ws": "^8.18.1", + "deepmerge": "^4.2.2", + "depcheck": "^1.4.7", + "jest": "^29.5.0", + "jest-it-up": "^3.1.0", + "rimraf": "^5.0.7", + "ts-jest": "^29.0.5", + "ts-node": "^10.9.2", + "tsd": "^0.31.0", + "typedoc": "^0.25.13", + "typescript": "~5.3.3" + }, + "peerDependencies": { + "@metamask/speculos-up": "^0.1.0" + }, + "peerDependenciesMeta": { + "@metamask/speculos-up": { + "optional": true + } + }, + "engines": { + "node": "^18.18 || >=20" + }, + "lavamoat": { + "allowScripts": { + "@lavamoat/preinstall-always-fail": false + } + } +} diff --git a/packages/hw-emulator/src/factory.test.ts b/packages/hw-emulator/src/factory.test.ts new file mode 100644 index 000000000..589f0bd9a --- /dev/null +++ b/packages/hw-emulator/src/factory.test.ts @@ -0,0 +1,50 @@ +import { createEmulator } from './factory'; +import { EmulatorType } from './types'; +import type { EmulatorType as EmulatorTypeValue } from './types'; + +describe('createEmulator', () => { + it('creates a Ledger emulator', () => { + const emu = createEmulator(EmulatorType.Ledger); + expect(emu).toBeDefined(); + expect(emu.isRunning()).toBe(false); + }); + + it('creates a Ledger emulator with options', () => { + const emu = createEmulator(EmulatorType.Ledger, { device: 'nanosp' }); + expect(emu).toBeDefined(); + expect(emu.isRunning()).toBe(false); + }); + + it('creates a Ledger emulator from an EmulatorType value', () => { + const type: EmulatorTypeValue = EmulatorType.Ledger; + + const emu = createEmulator(type); + + expect(emu).toBeDefined(); + expect(emu.isRunning()).toBe(false); + }); + + it('throws for trezor (not yet implemented)', () => { + expect(() => createEmulator(EmulatorType.Trezor)).toThrow( + 'Trezor emulator is not yet implemented', + ); + }); + + it('throws for unknown emulator type', () => { + expect(() => + createEmulator('unknown' as typeof EmulatorType.Ledger), + ).toThrow('Unknown emulator type'); + }); + + it('returns an emulator with the HardwareWalletEmulator interface', () => { + const emu = createEmulator(EmulatorType.Ledger); + expect(typeof emu.start).toBe('function'); + expect(typeof emu.stop).toBe('function'); + expect(typeof emu.isRunning).toBe('function'); + expect(typeof emu.getInteraction).toBe('function'); + expect(typeof emu.approveTransaction).toBe('function'); + expect(typeof emu.approveSigning).toBe('function'); + expect(typeof emu.rejectTransaction).toBe('function'); + expect(typeof emu.navigateToMainMenu).toBe('function'); + }); +}); diff --git a/packages/hw-emulator/src/factory.ts b/packages/hw-emulator/src/factory.ts new file mode 100644 index 000000000..9746a4c90 --- /dev/null +++ b/packages/hw-emulator/src/factory.ts @@ -0,0 +1,39 @@ +import type { SpeculosOptions } from './ledger/speculos'; +import { Speculos } from './ledger/speculos'; +import { EmulatorType } from './types'; +import type { + EmulatorType as EmulatorTypeValue, + HardwareWalletEmulator, +} from './types'; + +export type { HardwareWalletEmulator, DeviceInteraction } from './types'; + +/** + * Create a hardware wallet emulator instance. + * + * @param type - The emulator type to create. + * @param options - Emulator-specific configuration options. + * @returns A HardwareWalletEmulator instance. + * @throws If the emulator type is not supported. + */ +export function createEmulator( + type: typeof EmulatorType.Ledger, + options?: SpeculosOptions, +): HardwareWalletEmulator; +export function createEmulator( + type: EmulatorTypeValue, + options?: Record, +): HardwareWalletEmulator; +export function createEmulator( + type: EmulatorTypeValue, + options?: Record, +): HardwareWalletEmulator { + switch (type) { + case EmulatorType.Ledger: + return new Speculos(options as SpeculosOptions); + case EmulatorType.Trezor: + throw new Error('Trezor emulator is not yet implemented'); + default: + throw new Error(`Unknown emulator type: ${String(type)}`); + } +} diff --git a/packages/hw-emulator/src/index.ts b/packages/hw-emulator/src/index.ts new file mode 100644 index 000000000..d3b8de6e4 --- /dev/null +++ b/packages/hw-emulator/src/index.ts @@ -0,0 +1,51 @@ +export { + EmulatorType, + type HardwareWalletEmulator, + type DeviceInteraction, +} from './types'; + +export { createEmulator } from './factory'; + +export { + Speculos, + type SpeculosOptions, + SpeculosClient, + type SpeculosClientOptions, + type APDUResponse, + ApduBridge, + createDeviceInteraction, + NanoInteraction, + TouchInteraction, + type DeviceInteraction as LedgerDeviceInteraction, + createProcessManager, + type ProcessManager, + type ProcessManagerOptions, + type ProcessManagerStatus, + DockerManager, + type DockerManagerOptions, + type DockerManagerStatus, + getWebHidMockScript, + DEVICE_MODELS, + DEFAULT_DEVICE_MODEL, + DEFAULT_DEVICE, + DEVICE_PRESETS, + SPECULOS_APDU_PORT, + SPECULOS_API_PORT, + SPECULOS_WS_BRIDGE_PORT, + SPECULOS_LEDGER_ADDRESSES, + SPECULOS_LEDGER_ADDRESS, + SPECULOS_SEED, + getDeviceModel, + detectRunMode, + type DeviceModel, + type DeviceConfig, + type InteractionType, + type RunMode, + withRetry, + ExponentialBackoff, + isRetryableError, + createLedgerHidFramingSession, + pushLedgerHidFrame, + encodeLedgerHidResponse, + type LedgerHidFramingSession, +} from './ledger'; diff --git a/packages/hw-emulator/src/ledger/apdu-bridge.test.ts b/packages/hw-emulator/src/ledger/apdu-bridge.test.ts new file mode 100644 index 000000000..0ef6a6f80 --- /dev/null +++ b/packages/hw-emulator/src/ledger/apdu-bridge.test.ts @@ -0,0 +1,141 @@ +import type { WebSocket as WsWebSocket } from 'ws'; + +import { ApduBridge } from './apdu-bridge'; +import { SpeculosClient } from './client'; +import * as ledgerHidFraming from './ledger-hid-framing'; + +describe('ApduBridge', () => { + describe('constructor', () => { + it('creates an instance with client and port', () => { + const client = new SpeculosClient(); + const bridge = new ApduBridge(client, 9876); + expect(bridge.getPort()).toBe(9876); + }); + + it('returns the client', () => { + const client = new SpeculosClient(); + const bridge = new ApduBridge(client, 9876); + expect(bridge.getClient()).toBe(client); + }); + }); + + describe('start and stop', () => { + it('starts and stops the WebSocket server', async () => { + const client = new SpeculosClient(); + const bridge = new ApduBridge(client, 0); + await bridge.start(); + expect(bridge.getPort()).toBe(0); + await bridge.stop(); + }); + + it('stop resolves when not started', async () => { + const client = new SpeculosClient(); + const bridge = new ApduBridge(client, 9877); + await bridge.stop(); + expect(true).toBe(true); + }); + }); + + describe('injectNextErrorResponse', () => { + it('does not throw', () => { + const client = new SpeculosClient(); + const bridge = new ApduBridge(client, 9878); + expect(() => bridge.injectNextErrorResponse(0x6d00)).not.toThrow(); + }); + }); + + describe('handleHidSend signing-ready timer', () => { + const signTxContinuation = Buffer.from([ + 0xe0, 0x04, 0x80, 0x00, 0x01, 0x00, + ]); + const personalSignApdu = Buffer.from([0xe0, 0x08, 0x00, 0x00, 0x00]); + + function createMockWebSocket(): WsWebSocket { + return { send: jest.fn() } as unknown as WsWebSocket; + } + + beforeEach(() => { + jest.useFakeTimers(); + jest + .spyOn(ledgerHidFraming, 'createLedgerHidFramingSession') + .mockReturnValue({ + channel: 0, + framing: {} as never, + acc: null, + }); + jest + .spyOn(ledgerHidFraming, 'encodeLedgerHidResponse') + .mockReturnValue([Buffer.alloc(1)]); + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.useRealTimers(); + }); + + it('does not emit signing-ready from a fallback timer on queued signing APDUs', async () => { + const client = new SpeculosClient(); + const bridge = new ApduBridge(client, 9879); + const ws = createMockWebSocket(); + + const blockedExchange = new Promise(() => undefined); + jest.spyOn(client, 'exchange').mockReturnValue(blockedExchange); + + jest + .spyOn(ledgerHidFraming, 'pushLedgerHidFrame') + .mockReturnValueOnce(signTxContinuation) + .mockReturnValueOnce(personalSignApdu); + + let readyResolved = false; + // eslint-disable-next-line no-void, promise/always-return + void bridge.waitForSigningReady(10_000).then(() => { + readyResolved = true; + }); + + // eslint-disable-next-line no-void + void bridge.handleHidSend(ws, { id: 1, data: [] }); + // eslint-disable-next-line no-void + void bridge.handleHidSend(ws, { id: 2, data: [] }); + + await jest.advanceTimersByTimeAsync(500); + await Promise.resolve(); + + expect(readyResolved).toBe(false); + }); + + it('exchanges each queued signing APDU with Speculos individually', async () => { + const client = new SpeculosClient(); + const bridge = new ApduBridge(client, 9880); + const ws = createMockWebSocket(); + + const firstResponse = Buffer.from([0x90, 0x00]); + const secondResponse = Buffer.from([0x41, 0x42, 0x90, 0x00]); + + let resolveFirst!: (response: Buffer) => void; + const blockedFirst = new Promise((resolve) => { + resolveFirst = resolve; + }); + + const exchangeMock = jest + .spyOn(client, 'exchange') + .mockReturnValueOnce(blockedFirst) + .mockResolvedValueOnce(secondResponse); + + jest + .spyOn(ledgerHidFraming, 'pushLedgerHidFrame') + .mockReturnValueOnce(signTxContinuation) + .mockReturnValueOnce(personalSignApdu); + + const firstSend = bridge.handleHidSend(ws, { id: 1, data: [] }); + const secondSend = bridge.handleHidSend(ws, { id: 2, data: [] }); + + resolveFirst(firstResponse); + + await Promise.all([firstSend, secondSend]); + + expect(exchangeMock).toHaveBeenCalledTimes(2); + expect(exchangeMock).toHaveBeenNthCalledWith(1, signTxContinuation); + expect(exchangeMock).toHaveBeenNthCalledWith(2, personalSignApdu); + }); + }); +}); diff --git a/packages/hw-emulator/src/ledger/apdu-bridge.ts b/packages/hw-emulator/src/ledger/apdu-bridge.ts new file mode 100644 index 000000000..ee44553df --- /dev/null +++ b/packages/hw-emulator/src/ledger/apdu-bridge.ts @@ -0,0 +1,616 @@ +// eslint-disable-next-line import-x/no-nodejs-modules +import { EventEmitter } from 'node:events'; +import { WebSocketServer } from 'ws'; +import type { WebSocket as WsWebSocket } from 'ws'; + +import { SpeculosClient } from './client'; +import type { DeviceInteraction } from './device-interaction'; +import { + createLedgerHidFramingSession, + encodeLedgerHidResponse, + pushLedgerHidFrame, +} from './ledger-hid-framing'; +import type { LedgerHidFramingSession } from './ledger-hid-framing'; + +/** + * Per-connection state tracking the HID framing session. + */ +type WsConnectionState = { + framingSession: LedgerHidFramingSession | null; +}; + +/** + * Result of decoding an RLP length prefix. + */ +type RlpDecodeResult = { + /** Number of bytes in the RLP header. */ + headerSize: number; + /** Decoded payload length. */ + length: number; +}; + +/** + * WebSocket bridge that relays HID-framed APDUs between browser WebHID mock and Speculos. + */ +export class ApduBridge { + #wss: WebSocketServer | null = null; + + readonly #client: SpeculosClient; + + readonly #port: number; + + readonly #emitter = new EventEmitter(); + + readonly #signingReadyEmitter = new EventEmitter(); + + readonly #connectionState = new WeakMap(); + + #signingGateResolve: (() => void) | null = null; + + #injectedErrorStatusCode: number | null = null; + + #signingLockChain: Promise = Promise.resolve(); + + #signingLockDepth = 0; + + #signTxTotalDataLen: number | null = null; + + #signTxDataSent = 0; + + /** + * @param client - The Speculos client for APDU communication. + * @param port - The port to listen on for WebSocket connections. + */ + constructor(client: SpeculosClient, port: number) { + this.#client = client; + this.#port = port; + this.#emitter.setMaxListeners(20); + } + + /** + * Wait for a signing APDU to be sent to Speculos. + * + * @param timeout - Maximum wait time in milliseconds. + * @returns The signing APDU buffer. + */ + async waitForSigningApdu(timeout = 30000): Promise { + let timer: ReturnType; + return new Promise((resolve, reject) => { + const handler = (apdu: Buffer): void => { + clearTimeout(timer); + resolve(apdu); + }; + timer = setTimeout(() => { + this.#emitter.removeListener('signing-apdu', handler); + reject(new Error('Timeout waiting for signing APDU')); + }, timeout); + this.#emitter.once('signing-apdu', handler); + }); + } + + /** + * Wait for a signing APDU and then approve the transaction via device interaction. + * + * @param interaction - The device interaction handler. + * @param timeout - Maximum wait time in milliseconds. + * @returns The signing APDU buffer. + */ + async waitForSigningApduAndApprove( + interaction: DeviceInteraction, + timeout = 30000, + ): Promise { + const apdu = await this.waitForSigningApdu(timeout); + await new Promise((resolve) => setTimeout(resolve, 1500)); + await interaction.approveTransaction(); + return apdu; + } + + /** + * Wait for a signing APDU and then approve personal signing. + * + * @param interaction - The device interaction handler. + * @param timeout - Maximum wait time in milliseconds. + * @returns The signing APDU buffer. + */ + async waitForSigningApduAndApproveSigning( + interaction: DeviceInteraction, + timeout = 30000, + ): Promise { + const apdu = await this.waitForSigningApdu(timeout); + await new Promise((resolve) => setTimeout(resolve, 1500)); + await interaction.approveSigning(); + return apdu; + } + + /** + * Wait for a signing APDU and then approve blind signing. + * + * @param interaction - The device interaction handler. + * @param timeout - Maximum wait time in milliseconds. + * @param scrollCount - Number of review screens to scroll through. + * @returns The signing APDU buffer. + */ + async waitForSigningApduAndApproveBlindSigning( + interaction: DeviceInteraction, + timeout = 30000, + scrollCount?: number, + ): Promise { + const apdu = await this.waitForSigningApdu(timeout); + await this.waitForSigningReady(timeout); + await interaction.approveBlindSigning(scrollCount); + return apdu; + } + + /** + * Wait for the Ledger to show the signing review UI. + * + * @param timeout - Maximum wait time in milliseconds. + * @returns A promise that resolves when signing is ready. + */ + async waitForSigningReady(timeout = 30000): Promise { + let timer: ReturnType; + return new Promise((resolve, reject) => { + const handler = (): void => { + clearTimeout(timer); + resolve(); + }; + timer = setTimeout(() => { + this.#signingReadyEmitter.removeListener('signing-ready', handler); + reject(new Error('Timeout waiting for signing ready')); + }, timeout); + this.#signingReadyEmitter.once('signing-ready', handler); + }); + } + + /** + * Release the signing gate to forward the APDU to Speculos. + */ + releaseSigningGate(): void { + if (this.#signingGateResolve) { + this.#signingGateResolve(); + this.#signingGateResolve = null; + } + } + + /** + * Get the underlying SpeculosClient. + * + * @returns The client instance. + */ + getClient(): SpeculosClient { + return this.#client; + } + + /** + * Inject an error status code on the next APDU exchange. + * + * @param statusCode - 16-bit APDU status word. + */ + injectNextErrorResponse(statusCode: number): void { + this.#injectedErrorStatusCode = statusCode; + } + + /** + * Acquire the signing lock so concurrent signing APDUs are serialized. + * + * @returns A release function and whether this caller waited behind another signing flow. + */ + async #acquireSigningLock(): Promise<{ + release: () => void; + wasQueued: boolean; + }> { + const wasQueued = this.#signingLockDepth > 0; + this.#signingLockDepth += 1; + + let releaseLock!: () => void; + const previousLock = this.#signingLockChain; + this.#signingLockChain = new Promise((resolve) => { + releaseLock = resolve; + }); + + await previousLock; + + return { + wasQueued, + release: (): void => { + this.#signingLockDepth -= 1; + releaseLock(); + }, + }; + } + + /** + * Start the WebSocket server. + * + * @returns A promise that resolves when listening. + */ + async start(): Promise { + return new Promise((resolve, reject) => { + this.#wss = new WebSocketServer({ port: this.#port }); + + this.#wss.on('connection', (ws: WsWebSocket) => { + this.#connectionState.set(ws, { framingSession: null }); + + ws.on('message', (data: Buffer): void => { + this.#handleMessage(ws, data).catch((caughtError: unknown) => { + const errorMessage = + caughtError instanceof Error + ? caughtError.message + : String(caughtError); + ws.send( + JSON.stringify({ type: 'APDU_ERROR', error: errorMessage }), + ); + }); + }); + + ws.on('close', () => { + this.#connectionState.delete(ws); + }); + }); + + this.#wss.on('error', (serverError: Error) => { + reject(serverError); + }); + + this.#wss.on('listening', () => { + console.log(`[ApduBridge] Server listening on port ${this.#port}`); + resolve(); + }); + }); + } + + async #handleMessage(ws: WsWebSocket, data: Buffer): Promise { + const messageStr = data.toString(); + + try { + const parsed = JSON.parse(messageStr); + if (parsed.type === 'DEBUG') { + return; + } + } catch { + // Not JSON, continue + } + + let messageId: number | undefined; + try { + const message = JSON.parse(data.toString()); + messageId = message.id; + + if (message.type === 'HID_SEND') { + await this.handleHidSend(ws, message); + return; + } + + if (message.type === 'APDU_REQUEST') { + // eslint-disable-next-line no-restricted-globals + const apduData = Buffer.from(message.data); + const response = await this.#client.exchange(apduData); + + ws.send( + JSON.stringify({ + type: 'APDU_RESPONSE', + id: message.id, + data: Array.from(response), + }), + ); + } + } catch (bridgeError: unknown) { + const errorMessage = + bridgeError instanceof Error + ? bridgeError.message + : String(bridgeError); + ws.send( + JSON.stringify({ + type: 'APDU_ERROR', + id: messageId, + error: errorMessage, + }), + ); + } + } + + /** + * Handle an incoming HID_SEND WebSocket message, reassembling HID frames into + * APDUs, forwarding them to Speculos, and sending the HID-framed response back. + * + * @param ws - The WebSocket connection. + * @param message - The parsed message with raw frame data. + * @param message.id - Optional message identifier for response correlation. + * @param message.data - Raw HID frame bytes as a number array. + */ + async handleHidSend( + ws: WsWebSocket, + message: { id?: number; data: number[] }, + ): Promise { + // eslint-disable-next-line no-restricted-globals + const frame = Buffer.from(message.data); + let state = this.#connectionState.get(ws); + state ??= { framingSession: null }; + this.#connectionState.set(ws, state); + + state.framingSession ??= createLedgerHidFramingSession(frame); + + const apdu = pushLedgerHidFrame(state.framingSession, frame); + if (!apdu) { + ws.send( + JSON.stringify({ + type: 'HID_FRAME_ACK', + id: message.id, + }), + ); + return; + } + + const isSignTx = apdu.length >= 2 && apdu[0] === 0xe0 && apdu[1] === 0x04; + const isSigningIns = + apdu.length >= 2 && + apdu[0] === 0xe0 && + (apdu[1] === 0x04 || + apdu[1] === 0x08 || + apdu[1] === 0x1a || + apdu[1] === 0x20 || + apdu[1] === 0x22); + + const isSignTxFirst = isSignTx && apdu[2] === 0x00; + const isSignTxContinuation = isSignTx && apdu[2] === 0x80; + const dataLen = apdu.length > 5 ? apdu.length - 5 : 0; + + if (isSignTxFirst && apdu.length > 5) { + const totalPayloadLen = this.parseTxPayloadLength(apdu.subarray(5)); + if (totalPayloadLen !== null) { + this.#signTxTotalDataLen = totalPayloadLen; + this.#signTxDataSent = dataLen; + } + } else if (isSignTxContinuation) { + this.#signTxDataSent += dataLen; + } + + const isSigningFirstChunk = isSigningIns && apdu[2] === 0x00; + const isOtherSigning = isSigningIns && apdu[1] !== 0x04; + + if (isSigningFirstChunk || isOtherSigning) { + this.#emitter.emit('signing-apdu', apdu); + } + + const isLastSignTxChunk = + isSignTxContinuation && + this.#signTxTotalDataLen !== null && + this.#signTxDataSent >= this.#signTxTotalDataLen; + const isSingleChunkSignTx = + isSignTxFirst && + this.#signTxTotalDataLen !== null && + this.#signTxDataSent >= this.#signTxTotalDataLen; + const shouldStartSigningTimer = + isSigningIns && (!isSignTx || isLastSignTxChunk || isSingleChunkSignTx); + + const signingLock = isSigningIns ? await this.#acquireSigningLock() : null; + const wasQueuedSigning = signingLock?.wasQueued ?? false; + + let signingReadyFired = false; + const signingReadyTimer = + shouldStartSigningTimer && !wasQueuedSigning + ? setTimeout(() => { + signingReadyFired = true; + this.#signingReadyEmitter.emit('signing-ready'); + }, 500) + : null; + const shouldEmitSigningReadyOnLastChunk = + (isLastSignTxChunk || isSingleChunkSignTx) && !signingReadyFired; + + let response: Buffer; + + try { + response = await this.#client.exchange(apdu); + + if (signingReadyTimer) { + clearTimeout(signingReadyTimer); + } + + if (shouldEmitSigningReadyOnLastChunk && !signingReadyFired) { + signingReadyFired = true; + this.#signingReadyEmitter.emit('signing-ready'); + } + + const isLastChunkWithAck = + (isSignTxContinuation || isSingleChunkSignTx) && + this.#signTxTotalDataLen !== null && + this.#signTxDataSent >= this.#signTxTotalDataLen && + response.length === 2 && + response[0] === 0x90 && + response[1] === 0x00; + + if (isLastChunkWithAck) { + // eslint-disable-next-line no-restricted-globals + const emptyChunk = Buffer.from([0xe0, 0x04, 0x80, 0x00, 0x00]); + const readyTimer = setTimeout(() => { + signingReadyFired = true; + this.#signingReadyEmitter.emit('signing-ready'); + }, 500); + response = await this.#client.exchange(emptyChunk); + clearTimeout(readyTimer); + this.#signTxTotalDataLen = null; + this.#signTxDataSent = 0; + } else if (!isSignTx || (isSingleChunkSignTx && response.length > 2)) { + this.#signTxTotalDataLen = null; + this.#signTxDataSent = 0; + } + + const injectedCode = this.#injectedErrorStatusCode; + this.#injectedErrorStatusCode = null; + if (injectedCode !== null) { + const sw1 = Math.floor(injectedCode / 256); + const sw2 = injectedCode % 256; + // eslint-disable-next-line no-restricted-globals + response = Buffer.from([sw1, sw2]); + } + + if ( + apdu.length >= 2 && + apdu[0] === 0xe0 && + apdu[1] === 0x06 && + response.length >= 3 + ) { + const responseSw1 = response[response.length - 2]; + const responseSw2 = response[response.length - 1]; + if (responseSw1 === 0x90 && responseSw2 === 0x00 && response[0] !== 1) { + // eslint-disable-next-line no-restricted-globals + response = Buffer.from([1, ...response.subarray(1)]); + } + } + + const responseFrames = encodeLedgerHidResponse( + state.framingSession, + response, + ); + + for (const responseFrame of responseFrames) { + ws.send( + JSON.stringify({ + type: 'HID_RECV', + id: message.id, + data: Array.from(responseFrame), + }), + ); + } + + ws.send( + JSON.stringify({ + type: 'HID_EXCHANGE_COMPLETE', + id: message.id, + }), + ); + + state.framingSession = null; + } finally { + signingLock?.release(); + } + } + + /** + * Stop the WebSocket server. + * + * @returns A promise that resolves when stopped. + */ + async stop(): Promise { + return new Promise((resolve) => { + if (!this.#wss) { + resolve(); + return; + } + + this.#wss.clients.forEach((client) => { + client.terminate(); + }); + + const forceCloseTimer = setTimeout(() => { + if (this.#wss) { + this.#wss = null; + resolve(); + } + }, 1000); + + this.#wss.close(() => { + clearTimeout(forceCloseTimer); + this.#wss = null; + resolve(); + }); + }); + } + + /** + * Get the port the bridge is listening on. + * + * @returns The port number. + */ + getPort(): number { + return this.#port; + } + + /** + * Parse the total transaction payload length from the first signing APDU chunk. + * + * @param firstChunkData - The payload bytes from the first APDU chunk (after the header). + * @returns The total payload length in bytes, or null if parsing fails. + */ + parseTxPayloadLength(firstChunkData: Buffer): number | null { + if (firstChunkData.length < 6) { + return null; + } + try { + const pathCount = firstChunkData[0] ?? 0; + const pathBytes = pathCount * 4; + const txStart = 1 + pathBytes; + if (txStart >= firstChunkData.length) { + return null; + } + const txData = firstChunkData.subarray(txStart); + if (txData.length === 0) { + return null; + } + let rlpStart = 0; + if (txData[0] !== undefined && txData[0] >= 0x01 && txData[0] <= 0x7f) { + rlpStart = 1; + } + if (rlpStart >= txData.length) { + return null; + } + const rlpResult = this.decodeRlpLength(txData, rlpStart); + if (!rlpResult) { + return null; + } + return pathBytes + 1 + rlpStart + rlpResult.headerSize + rlpResult.length; + } catch { + return null; + } + } + + /** + * Decode an RLP length prefix at the given offset. + * + * @param data - The buffer containing RLP-encoded data. + * @param offset - The byte offset to start decoding from. + * @returns The header size and decoded length, or null if the data is incomplete. + */ + decodeRlpLength(data: Buffer, offset: number): RlpDecodeResult | null { + if (offset >= data.length) { + return null; + } + const prefix = data[offset]; + if (prefix === undefined) { + return null; + } + if (prefix <= 0x7f) { + return { headerSize: 0, length: 1 }; + } + if (prefix <= 0xb7) { + return { headerSize: 1, length: prefix - 0x80 }; + } + if (prefix <= 0xbf) { + const lenOfLen = prefix - 0xb7; + if (offset + 1 + lenOfLen > data.length) { + return null; + } + let decodedLen = 0; + for (let byteIdx = 0; byteIdx < lenOfLen; byteIdx++) { + const byteVal = data[offset + 1 + byteIdx] ?? 0; + // eslint-disable-next-line no-bitwise + decodedLen = (decodedLen << 8) | byteVal; + } + return { headerSize: 1 + lenOfLen, length: decodedLen }; + } + if (prefix <= 0xf7) { + return { headerSize: 1, length: prefix - 0xc0 }; + } + const lenOfLen2 = prefix - 0xf7; + if (offset + 1 + lenOfLen2 > data.length) { + return null; + } + let decodedLen2 = 0; + for (let byteIdx = 0; byteIdx < lenOfLen2; byteIdx++) { + const byteVal = data[offset + 1 + byteIdx] ?? 0; + // eslint-disable-next-line no-bitwise + decodedLen2 = (decodedLen2 << 8) | byteVal; + } + return { headerSize: 1 + lenOfLen2, length: decodedLen2 }; + } +} diff --git a/packages/hw-emulator/src/ledger/client.test.ts b/packages/hw-emulator/src/ledger/client.test.ts new file mode 100644 index 000000000..f714c9423 --- /dev/null +++ b/packages/hw-emulator/src/ledger/client.test.ts @@ -0,0 +1,72 @@ +import { SpeculosClient } from './client'; + +describe('SpeculosClient', () => { + describe('constructor', () => { + it('uses default ports when no options provided', () => { + const client = new SpeculosClient(); + expect(client.isHealthy()).toBe(false); + }); + + it('uses custom ports when provided', () => { + const client = new SpeculosClient({ + apduPort: 9997, + apiPort: 5002, + }); + expect(client.isHealthy()).toBe(false); + }); + + it('builds correct base URL', () => { + const client = new SpeculosClient({ + apiHost: '192.168.1.1', + apiPort: 5003, + }); + expect(client.isHealthy()).toBe(false); + }); + }); + + describe('isHealthy', () => { + it('returns false before connecting', () => { + const client = new SpeculosClient(); + expect(client.isHealthy()).toBe(false); + }); + }); + + describe('exchange', () => { + it('throws if not connected', async () => { + const client = new SpeculosClient(); + await expect( + client.exchange(Buffer.from([0xe0, 0x06, 0x00, 0x00, 0x00])), + ).rejects.toThrow('Not connected to Speculos'); + }); + }); + + describe('disconnect', () => { + it('does not throw when not connected', async () => { + const client = new SpeculosClient(); + await client.disconnect(); + expect(true).toBe(true); + }); + }); + + describe('exchangeWithRetry', () => { + it('throws if not connected', async () => { + const client = new SpeculosClient(); + await expect( + client.exchangeWithRetry(Buffer.from([0xe0, 0x06, 0x00, 0x00, 0x00])), + ).rejects.toThrow('Not connected to Speculos'); + }); + }); + + describe('connectWithRetry', () => { + it('returns immediately if already connected', async () => { + const client = new SpeculosClient(); + await client + .connectWithRetry({ + autoReconnect: false, + reconnectAttempts: 0, + }) + .catch(() => undefined); + expect(client.isHealthy()).toBe(false); + }); + }); +}); diff --git a/packages/hw-emulator/src/ledger/client.ts b/packages/hw-emulator/src/ledger/client.ts new file mode 100644 index 000000000..2b34afb6c --- /dev/null +++ b/packages/hw-emulator/src/ledger/client.ts @@ -0,0 +1,464 @@ +// eslint-disable-next-line import-x/no-nodejs-modules +import net from 'node:net'; + +import { SPECULOS_APDU_PORT, SPECULOS_API_PORT } from './constants'; +import { withRetry, isRetryableError } from './resilience'; + +/** + * Options for configuring the SpeculosClient. + */ +export type SpeculosClientOptions = { + /** Hostname for the APDU TCP socket. */ + apduHost?: string; + /** Port for the APDU TCP socket. */ + apduPort?: number; + /** Hostname for the REST API. */ + apiHost?: string; + /** Port for the REST API. */ + apiPort?: number; + /** Timeout in milliseconds for APDU exchanges and API requests. */ + timeout?: number; +}; + +/** + * Response from an APDU exchange. + */ +export type APDUResponse = { + /** Hex-encoded response data. */ + data: string; +}; + +/** + * TCP and REST client for communicating with a Speculos emulator instance. + */ +export class SpeculosClient { + #apduSocket: net.Socket | null = null; + + readonly #baseUrl: string; + + readonly #options: Required; + + #connected = false; + + #healthy = false; + + #exchangeChain: Promise = Promise.resolve(); + + /** + * @param options - Client configuration options. + */ + constructor(options: SpeculosClientOptions = {}) { + this.#options = { + apduHost: '127.0.0.1', + apduPort: SPECULOS_APDU_PORT, + apiHost: '127.0.0.1', + apiPort: SPECULOS_API_PORT, + timeout: 30000, + ...options, + }; + + this.#baseUrl = `http://${this.#options.apiHost}:${this.#options.apiPort}`; + } + + /** + * Connect to the Speculos APDU TCP socket. + * + * @returns A promise that resolves when connected. + */ + async connect(): Promise { + if (this.#connected) { + return; + } + + await new Promise((resolve, reject) => { + this.#apduSocket = net.createConnection({ + host: this.#options.apduHost, + port: this.#options.apduPort, + }); + + this.#apduSocket.on('connect', () => { + this.#connected = true; + this.#healthy = true; + resolve(); + }); + + this.#apduSocket.on('error', (socketError: Error) => { + this.#healthy = false; + reject(socketError); + }); + + this.#apduSocket.on('close', () => { + this.#connected = false; + this.#healthy = false; + }); + }); + } + + /** + * Send an APDU and receive the response via TCP. + * + * @param apdu - The APDU buffer to send. + * @returns The response buffer. + */ + async exchange(apdu: Buffer): Promise { + if (!this.#apduSocket || !this.#connected) { + throw new Error('Not connected to Speculos'); + } + + let releaseMutex: () => void = () => undefined; + const mutexSlot = new Promise((resolve) => { + releaseMutex = resolve; + }); + const prior = this.#exchangeChain.catch(() => undefined); + this.#exchangeChain = prior.then(async () => mutexSlot); + + await prior; + + try { + return await this.exchangeOnce(apdu); + } finally { + releaseMutex(); + } + } + + /** + * Send a single APDU frame without mutual exclusion. + * + * @param apdu - The APDU buffer to send. + * @returns The response buffer. + */ + async exchangeOnce(apdu: Buffer): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + // eslint-disable-next-line prefer-const + let timeoutId: ReturnType | undefined; + // eslint-disable-next-line prefer-const + let cleanup: () => void; + + const onData = (data: Buffer): void => { + chunks.push(data); + // eslint-disable-next-line no-restricted-globals + const combined = Buffer.concat(chunks); + + if (combined.length < 4) { + return; + } + const payloadSize = combined.readUInt32BE(0); + const expectedTotal = 4 + payloadSize + 2; + if (combined.length < expectedTotal) { + return; + } + + if (timeoutId) { + clearTimeout(timeoutId); + } + cleanup(); + resolve(combined.subarray(4, expectedTotal)); + }; + + const onError = (exchangeError: Error): void => { + if (timeoutId) { + clearTimeout(timeoutId); + } + cleanup(); + reject(exchangeError); + }; + + cleanup = (): void => { + if (this.#apduSocket) { + this.#apduSocket.off('data', onData); + this.#apduSocket.off('error', onError); + } + }; + + if (!this.#apduSocket) { + reject(new Error('APDU socket not initialized')); + return; + } + this.#apduSocket.on('data', onData); + this.#apduSocket.on('error', onError); + + timeoutId = setTimeout(() => { + cleanup(); + reject(new Error('APDU exchange timeout')); + }, this.#options.timeout); + + // eslint-disable-next-line no-restricted-globals + const lengthHeader = Buffer.alloc(4); + lengthHeader.writeUInt32BE(apdu.length, 0); + // eslint-disable-next-line no-restricted-globals + this.#apduSocket.write(Buffer.concat([lengthHeader, apdu])); + }); + } + + /** + * Fetch a Speculos REST API endpoint with timeout support. + * + * @param urlPath - The API path (e.g. '/screenshot'). + * @param init - Fetch options with optional timeout override. + * @returns The fetch Response. + */ + async fetchEndpoint( + urlPath: string, + init: RequestInit & { timeout?: number } = {}, + ): Promise { + const { timeout: fetchTimeout = this.#options.timeout, ...requestInit } = + init; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), fetchTimeout); + try { + const response = await fetch(`${this.#baseUrl}${urlPath}`, { + ...requestInit, + signal: controller.signal, + }); + if (!response.ok) { + throw new Error( + `Speculos API ${urlPath} returned ${response.status}: ${await response.text().catch(() => '')}`, + ); + } + return response; + } finally { + clearTimeout(timer); + } + } + + /** + * Press a button on the emulated device. + * + * @param button - Which button to press. + */ + async pressButton(button: 'left' | 'right' | 'both'): Promise { + await this.fetchEndpoint(`/button/${button}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'press-and-release' }), + }); + } + + /** + * Simulate a finger tap on the emulated touchscreen. + * + * @param tapX - X coordinate. + * @param tapY - Y coordinate. + * @param delay - Tap duration in seconds. + */ + async fingerTap(tapX: number, tapY: number, delay = 0.1): Promise { + await this.fetchEndpoint('/finger', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'press-and-release', + x: tapX, + y: tapY, + delay, + }), + }); + } + + /** + * Simulate a finger swipe on the emulated touchscreen. + * + * @param startX - Start X coordinate. + * @param startY - Start Y coordinate. + * @param endX - End X coordinate. + * @param endY - End Y coordinate. + * @param delay - Swipe duration in seconds. + */ + async fingerSwipe( + startX: number, + startY: number, + endX: number, + endY: number, + delay = 0.3, + ): Promise { + await this.fetchEndpoint('/finger', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'press-and-release', + x: startX, + y: startY, + x2: endX, + y2: endY, + delay, + }), + }); + } + + /** + * Take a screenshot of the emulated device screen. + * + * @returns A PNG buffer. + */ + async getScreenshot(): Promise { + const response = await this.fetchEndpoint('/screenshot'); + const arrayBuffer = await response.arrayBuffer(); + // eslint-disable-next-line no-restricted-globals + return Buffer.from(arrayBuffer); + } + + /** + * Get screen events from the emulator. + * + * @returns An array of screen events. + */ + async getEvents(): Promise< + { text?: string; x: number; y: number; w: number; h: number }[] + > { + const response = await this.fetchEndpoint('/events'); + const data = (await response.json()) as { + events: { text?: string; x: number; y: number; w: number; h: number }[]; + }; + return data.events; + } + + /** + * Set automation rules for the emulator. + * + * @param rulesJson - JSON string of automation rules. + */ + async setAutomation(rulesJson: string): Promise { + await this.fetchEndpoint('/automation', { + method: 'POST', + body: rulesJson, + }); + } + + /** + * Clear automation rules. + */ + async clearAutomation(): Promise { + await this.fetchEndpoint('/automation', { + method: 'POST', + body: JSON.stringify({ version: 1, rules: [] }), + }); + } + + /** + * Query the Ethereum app configuration via APDU. + * + * @returns App version and blind signing status. + */ + async getAppConfiguration(): Promise<{ + major: number; + minor: number; + patch: number; + blindSigningEnabled: boolean; + }> { + const apduHex = 'e006000000'; + const resp = await this.sendAPDU(apduHex); + // eslint-disable-next-line no-restricted-globals + const bytes = Buffer.from(resp.data, 'hex'); + const payloadLen = bytes.length - 2; + if (payloadLen === 4) { + const flags = bytes[0] ?? 0; + return { + major: bytes[1] ?? 0, + minor: bytes[2] ?? 0, + patch: bytes[3] ?? 0, + // eslint-disable-next-line no-bitwise + blindSigningEnabled: (flags & 0x01) !== 0, + }; + } + const flags = payloadLen > 7 ? (bytes[7] ?? 0) : 0; + return { + major: payloadLen > 1 ? (bytes[1] ?? 0) : 0, + minor: payloadLen > 2 ? (bytes[2] ?? 0) : 0, + patch: payloadLen > 3 ? (bytes[3] ?? 0) : 0, + // eslint-disable-next-line no-bitwise + blindSigningEnabled: (flags & 0x01) !== 0, + }; + } + + /** + * Send a raw APDU via the REST API. + * + * @param data - Hex-encoded APDU data. + * @returns The APDU response. + */ + async sendAPDU(data: string): Promise { + const response = await this.fetchEndpoint('/apdu', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data }), + }); + return response.json(); + } + + /** + * Disconnect from the Speculos APDU socket. + */ + async disconnect(): Promise { + if (this.#apduSocket) { + this.#apduSocket.end(); + this.#apduSocket = null; + this.#connected = false; + this.#healthy = false; + } + } + + /** + * Connect with automatic retries on transient errors. + * + * @param options - Reconnection options. + * @param options.autoReconnect - Whether to automatically reconnect. + * @param options.reconnectAttempts - Maximum number of reconnection attempts. + * @param options.reconnectDelayMs - Delay between reconnection attempts in milliseconds. + */ + async connectWithRetry(options?: { + autoReconnect?: boolean; + reconnectAttempts?: number; + reconnectDelayMs?: number; + }): Promise { + const autoReconnect = options?.autoReconnect ?? true; + const maxReconnects = options?.reconnectAttempts ?? 5; + const reconnectDelay = options?.reconnectDelayMs ?? 1000; + + if (this.#connected) { + return; + } + + let attempts = 0; + while (!this.#connected && attempts <= maxReconnects) { + try { + await this.connect(); + } catch (connectError: unknown) { + attempts += 1; + if (!autoReconnect || attempts > maxReconnects) { + throw connectError; + } + await new Promise((resolve) => setTimeout(resolve, reconnectDelay)); + continue; + } + } + } + + /** + * Exchange an APDU with automatic retries on transient errors. + * + * @param apdu - The APDU buffer to send. + * @param maxAttempts - Maximum number of attempts. + * @returns The response buffer. + */ + async exchangeWithRetry(apdu: Buffer, maxAttempts = 3): Promise { + const exchangeFn = async (): Promise => this.exchange(apdu); + return withRetry(exchangeFn, { + maxRetries: maxAttempts - 1, + shouldRetry: (retryError: Error) => isRetryableError(retryError), + onRetry: (retryError: Error, attempt: number) => { + console.warn( + `[SpeculosClient] APDU exchange retry ${attempt} due to: ${retryError.message ?? retryError}`, + ); + }, + }); + } + + /** + * Check if the client is healthy and connected. + * + * @returns True if connected and healthy. + */ + isHealthy(): boolean { + return this.#connected && this.#healthy; + } +} diff --git a/packages/hw-emulator/src/ledger/constants.test.ts b/packages/hw-emulator/src/ledger/constants.test.ts new file mode 100644 index 000000000..6ba844042 --- /dev/null +++ b/packages/hw-emulator/src/ledger/constants.test.ts @@ -0,0 +1,123 @@ +import { + DEVICE_MODELS, + DEFAULT_DEVICE_MODEL, + DEFAULT_DEVICE, + DEVICE_PRESETS, + SPECULOS_APDU_PORT, + SPECULOS_API_PORT, + SPECULOS_WS_BRIDGE_PORT, + SPECULOS_LEDGER_ADDRESSES, + SPECULOS_LEDGER_ADDRESS, + SPECULOS_SEED, + getDeviceModel, + detectRunMode, +} from './constants'; + +describe('constants', () => { + describe('DEVICE_MODELS', () => { + it('contains all expected device models', () => { + expect(Object.keys(DEVICE_MODELS)).toStrictEqual([ + 'nanosp', + 'nanox', + 'stax', + 'flex', + ]); + }); + + it('each model has required properties', () => { + for (const model of Object.values(DEVICE_MODELS)) { + expect(model.id).toBeDefined(); + expect(model.name).toBeDefined(); + expect(model.speculosModel).toBeDefined(); + expect(model.interactionType).toMatch(/^(button|touch)$/u); + expect(model.elfFile).toMatch(/\.elf$/u); + expect(model.screenSize.width).toBeGreaterThan(0); + expect(model.screenSize.height).toBeGreaterThan(0); + } + }); + + it('touch devices have button coordinates', () => { + const touchModels = Object.values(DEVICE_MODELS).filter( + (item) => item.interactionType === 'touch', + ); + for (const model of touchModels) { + expect(model.backButton).toBeDefined(); + expect(model.confirmButton).toBeDefined(); + } + }); + }); + + describe('DEFAULT_DEVICE_MODEL', () => { + it('is the flex model', () => { + expect(DEFAULT_DEVICE_MODEL.id).toBe('flex'); + }); + }); + + describe('DEFAULT_DEVICE', () => { + it('has expected port values', () => { + expect(DEFAULT_DEVICE.apduPort).toBe(9998); + expect(DEFAULT_DEVICE.apiPort).toBe(5001); + expect(DEFAULT_DEVICE.wsBridgePort).toBe(9876); + }); + }); + + describe('DEVICE_PRESETS', () => { + it('contains at least two presets', () => { + expect(DEVICE_PRESETS.length).toBeGreaterThanOrEqual(2); + }); + }); + + describe('port exports', () => { + it('matches DEFAULT_DEVICE', () => { + expect(SPECULOS_APDU_PORT).toBe(DEFAULT_DEVICE.apduPort); + expect(SPECULOS_API_PORT).toBe(DEFAULT_DEVICE.apiPort); + expect(SPECULOS_WS_BRIDGE_PORT).toBe(DEFAULT_DEVICE.wsBridgePort); + }); + }); + + describe('SPECULOS_LEDGER_ADDRESSES', () => { + it('contains 5 addresses', () => { + expect(SPECULOS_LEDGER_ADDRESSES).toHaveLength(5); + }); + + it('sPECULOS_LEDGER_ADDRESS is the first address', () => { + expect(SPECULOS_LEDGER_ADDRESS).toBe(SPECULOS_LEDGER_ADDRESSES[0]); + }); + + it('all addresses are valid hex', () => { + for (const address of SPECULOS_LEDGER_ADDRESSES) { + expect(address).toMatch(/^0x[0-9a-fA-F]{40}$/u); + } + }); + }); + + describe('SPECULOS_SEED', () => { + it('is a non-empty string', () => { + expect(typeof SPECULOS_SEED).toBe('string'); + expect(SPECULOS_SEED.length).toBeGreaterThan(0); + }); + }); + + describe('getDeviceModel', () => { + it('returns flex by default', () => { + expect(getDeviceModel().id).toBe('flex'); + }); + + it('returns nanosp when specified', () => { + expect(getDeviceModel('nanosp').id).toBe('nanosp'); + }); + + it('throws for unknown model', () => { + expect(() => getDeviceModel('unknown')).toThrow( + 'Unknown device model "unknown"', + ); + }); + }); + + describe('detectRunMode', () => { + it('returns a valid run mode', () => { + const mode = detectRunMode(); + expect(mode === 'native' || mode === 'docker').toBe(true); + }); + }); +}); diff --git a/packages/hw-emulator/src/ledger/constants.ts b/packages/hw-emulator/src/ledger/constants.ts new file mode 100644 index 000000000..fda2af850 --- /dev/null +++ b/packages/hw-emulator/src/ledger/constants.ts @@ -0,0 +1,174 @@ +/** + * Port and connection configuration for a Speculos device instance. + */ +export type DeviceConfig = { + /** Unique identifier for this device configuration. */ + id: string; + /** TCP port for the APDU protocol. */ + apduPort: number; + /** TCP port for the REST API. */ + apiPort: number; + /** TCP port for the WebSocket bridge. */ + wsBridgePort: number; +}; + +/** Default device configuration with standard ports. */ +export const DEFAULT_DEVICE: DeviceConfig = { + id: 'default', + apduPort: 9998, + apiPort: 5001, + wsBridgePort: 9876, +}; + +/** Pre-configured device presets supporting multiple concurrent emulator instances. */ +export const DEVICE_PRESETS: DeviceConfig[] = [ + DEFAULT_DEVICE, + { + id: 'second', + apduPort: 9997, + apiPort: 5002, + wsBridgePort: 9875, + }, +]; + +/** Default APDU port, derived from the default device configuration. */ +export const SPECULOS_APDU_PORT = DEFAULT_DEVICE.apduPort; +/** Default REST API port, derived from the default device configuration. */ +export const SPECULOS_API_PORT = DEFAULT_DEVICE.apiPort; +/** Default WebSocket bridge port, derived from the default device configuration. */ +export const SPECULOS_WS_BRIDGE_PORT = DEFAULT_DEVICE.wsBridgePort; + +/** Ethereum addresses derived from the default Speculos seed. */ +export const SPECULOS_LEDGER_ADDRESSES = [ + '0xb0358b8F2314F6f6a392a4be8C7C422e631d9F63', + '0xE004F1e6F8bB51106fD488550f7e6e6f54430018', + '0xd957f2200aEDA0Ac1604d29F6C823bD113A13780', + '0x797b3EF4B1807c30F6831381dE79be50217B53a5', + '0x335Fcb7dd8d2190c9698026Df2dBa62A990371F4', +] as const; + +/** Primary Ledger address derived from the default Speculos seed. */ +export const SPECULOS_LEDGER_ADDRESS = SPECULOS_LEDGER_ADDRESSES[0]; + +/** BIP-39 mnemonic seed used by default in the Speculos emulator. */ +export const SPECULOS_SEED = + 'grit essence story volume tip entry situate found february olympic monitor hybrid'; + +/** Interaction method used by the device — button-based or touch-based. */ +export type InteractionType = 'button' | 'touch'; + +/** + * Describes a Ledger device model with its Speculos configuration, screen size, + * and touch button coordinates (for touch-based devices). + */ +export type DeviceModel = { + /** Unique model identifier (e.g. 'nanosp', 'stax'). */ + id: string; + /** Human-readable model name. */ + name: string; + /** Model identifier passed to the Speculos binary. */ + speculosModel: string; + /** Whether the device uses button or touch interaction. */ + interactionType: InteractionType; + /** Filename of the Ethereum app ELF binary. */ + elfFile: string; + /** Screen dimensions in pixels. */ + screenSize: { width: number; height: number }; + /** Touch coordinate for the generic confirm button. */ + confirmButton?: { x: number; y: number }; + /** Touch coordinate for the generic reject button. */ + rejectButton?: { x: number; y: number }; + /** Touch coordinate for the back button. */ + backButton?: { x: number; y: number }; + /** Touch coordinate for the confirm button during transaction review. */ + reviewConfirmButton?: { x: number; y: number }; + /** Touch coordinate for the reject button during transaction review. */ + reviewRejectButton?: { x: number; y: number }; + /** Touch coordinate for the home button. */ + homeButton?: { x: number; y: number }; +}; + +/** Registry of all supported Ledger device models. */ +export const DEVICE_MODELS: Record = { + nanosp: { + id: 'nanosp', + name: 'Nano S+', + speculosModel: 'nanosp', + interactionType: 'button', + elfFile: 'ethereum-nanosp.elf', + screenSize: { width: 128, height: 64 }, + }, + nanox: { + id: 'nanox', + name: 'Nano X', + speculosModel: 'nanox', + interactionType: 'button', + elfFile: 'ethereum-nanox.elf', + screenSize: { width: 128, height: 64 }, + }, + stax: { + id: 'stax', + name: 'Stax', + speculosModel: 'stax', + interactionType: 'touch', + elfFile: 'ethereum-stax.elf', + screenSize: { width: 400, height: 672 }, + backButton: { x: 36, y: 36 }, + confirmButton: { x: 200, y: 606 }, + rejectButton: { x: 36, y: 606 }, + reviewConfirmButton: { x: 200, y: 515 }, + reviewRejectButton: { x: 36, y: 606 }, + homeButton: { x: 200, y: 606 }, + }, + flex: { + id: 'flex', + name: 'Flex', + speculosModel: 'flex', + interactionType: 'touch', + elfFile: 'ethereum-flex.elf', + screenSize: { width: 480, height: 600 }, + backButton: { x: 45, y: 45 }, + confirmButton: { x: 240, y: 550 }, + rejectButton: { x: 55, y: 530 }, + reviewConfirmButton: { x: 240, y: 435 }, + reviewRejectButton: { x: 55, y: 530 }, + homeButton: { x: 240, y: 550 }, + }, +}; + +const FLEX_MODEL = DEVICE_MODELS.flex; +if (!FLEX_MODEL) { + throw new Error('Flex device model not found in DEVICE_MODELS'); +} +/** The default device model used when none is specified. */ +export const DEFAULT_DEVICE_MODEL: DeviceModel = FLEX_MODEL; + +/** + * Look up a device model by its identifier. + * + * @param id - The device model identifier (defaults to 'flex'). + * @returns The matching DeviceModel. + * @throws If the model identifier is not recognized. + */ +export function getDeviceModel(id = 'flex'): DeviceModel { + const model = DEVICE_MODELS[id]; + if (!model) { + throw new Error( + `Unknown device model "${id}". Valid: ${Object.keys(DEVICE_MODELS).join(', ')}`, + ); + } + return model; +} + +/** Execution mode for Speculos — native binary or Docker container. */ +export type RunMode = 'native' | 'docker'; + +/** + * Detect the appropriate Speculos run mode based on the current platform. + * + * @returns 'native' on Linux, 'docker' on all other platforms. + */ +export function detectRunMode(): RunMode { + // eslint-disable-next-line no-restricted-globals + return process.platform === 'linux' ? 'native' : 'docker'; +} diff --git a/packages/hw-emulator/src/ledger/device-interaction.test.ts b/packages/hw-emulator/src/ledger/device-interaction.test.ts new file mode 100644 index 000000000..99d88f429 --- /dev/null +++ b/packages/hw-emulator/src/ledger/device-interaction.test.ts @@ -0,0 +1,103 @@ +import type { SpeculosClient } from './client'; +import { DEVICE_MODELS } from './constants'; +import type { DeviceModel } from './constants'; +import { + createDeviceInteraction, + NanoInteraction, + TouchInteraction, +} from './device-interaction'; + +function createMockClient(): jest.Mocked { + return { + pressButton: jest.fn().mockResolvedValue(undefined), + fingerTap: jest.fn().mockResolvedValue(undefined), + fingerSwipe: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked; +} + +describe('createDeviceInteraction', () => { + it('creates NanoInteraction for button devices', () => { + const client = createMockClient(); + const model = DEVICE_MODELS.nanosp as DeviceModel; + const interaction = createDeviceInteraction(client, model); + expect(interaction).toBeInstanceOf(NanoInteraction); + }); + + it('creates TouchInteraction for touch devices', () => { + const client = createMockClient(); + const model = DEVICE_MODELS.flex as DeviceModel; + const interaction = createDeviceInteraction(client, model); + expect(interaction).toBeInstanceOf(TouchInteraction); + }); +}); + +describe('NanoInteraction', () => { + describe('approveTransaction', () => { + it('presses right 6 times then both', async () => { + const client = createMockClient(); + const interaction = new NanoInteraction(client); + await interaction.approveTransaction(); + expect(client.pressButton).toHaveBeenCalledTimes(7); + for (let callIdx = 0; callIdx < 6; callIdx++) { + expect(client.pressButton).toHaveBeenNthCalledWith( + callIdx + 1, + 'right', + ); + } + expect(client.pressButton).toHaveBeenNthCalledWith(7, 'both'); + }, 10000); + }); + + describe('approveSigning', () => { + it('presses right 2 times then both', async () => { + const client = createMockClient(); + const interaction = new NanoInteraction(client); + await interaction.approveSigning(); + expect(client.pressButton).toHaveBeenCalledTimes(3); + }); + }); + + describe('rejectTransaction', () => { + it('presses right then both', async () => { + const client = createMockClient(); + const interaction = new NanoInteraction(client); + await interaction.rejectTransaction(); + expect(client.pressButton).toHaveBeenCalledWith('right'); + expect(client.pressButton).toHaveBeenCalledWith('both'); + }); + }); +}); + +describe('TouchInteraction', () => { + describe('approveTransaction', () => { + it('swipes left 3 times then taps confirm', async () => { + const client = createMockClient(); + const model = DEVICE_MODELS.flex as DeviceModel; + const interaction = new TouchInteraction(client, model); + await interaction.approveTransaction(); + expect(client.fingerSwipe).toHaveBeenCalledTimes(3); + expect(client.fingerTap).toHaveBeenCalledTimes(1); + }, 10000); + }); + + describe('rejectTransaction', () => { + it('taps reject button', async () => { + const client = createMockClient(); + const model = DEVICE_MODELS.flex as DeviceModel; + const interaction = new TouchInteraction(client, model); + await interaction.rejectTransaction(); + expect(client.fingerTap).toHaveBeenCalledTimes(1); + }); + }); + + describe('enableBlindSigning', () => { + it('does nothing for NBGL devices', async () => { + const client = createMockClient(); + const model = DEVICE_MODELS.flex as DeviceModel; + const interaction = new TouchInteraction(client, model); + await interaction.enableBlindSigning(); + expect(client.fingerTap).not.toHaveBeenCalled(); + expect(client.fingerSwipe).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/hw-emulator/src/ledger/device-interaction.ts b/packages/hw-emulator/src/ledger/device-interaction.ts new file mode 100644 index 000000000..010eba2c5 --- /dev/null +++ b/packages/hw-emulator/src/ledger/device-interaction.ts @@ -0,0 +1,280 @@ +import type { SpeculosClient } from './client'; +import type { DeviceModel } from './constants'; + +/** + * Interface for device screen interaction — pressing buttons or tapping the screen + * to approve, reject, or navigate through on-screen prompts. + */ +export type DeviceInteraction = { + /** Approve a transaction on the device screen. */ + approveTransaction(): Promise; + /** Approve a personal message or typed signing request. */ + approveSigning(): Promise; + /** Reject a transaction on the device screen. */ + rejectTransaction(): Promise; + /** Approve a blind signing request, scrolling through review screens. */ + approveBlindSigning(scrollCount?: number): Promise; + /** Enable blind signing in the Ethereum app settings. */ + enableBlindSigning(): Promise; + /** Navigate back to the main menu. */ + navigateToMainMenu(): Promise; +}; + +async function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Device interaction handler for Nano S+ and Nano X (button-based). + */ +export class NanoInteraction implements DeviceInteraction { + readonly #client: SpeculosClient; + + /** + * @param client - The Speculos client for sending button presses. + */ + constructor(client: SpeculosClient) { + this.#client = client; + } + + /** + * Approve a transaction by scrolling through 6 review screens and confirming. + */ + async approveTransaction(): Promise { + for (let step = 0; step < 6; step++) { + await this.#client.pressButton('right'); + await delay(500); + } + await this.#client.pressButton('both'); + await delay(500); + } + + /** + * Approve a personal signing request by scrolling through 2 review screens and confirming. + */ + async approveSigning(): Promise { + for (let step = 0; step < 2; step++) { + await this.#client.pressButton('right'); + await delay(500); + } + await this.#client.pressButton('both'); + await delay(500); + } + + /** + * Approve blind signing by enabling it and scrolling through review screens. + * + * @param scrollCount - Number of screens to scroll through (default 4). + */ + async approveBlindSigning(scrollCount = 4): Promise { + await this.#client.pressButton('both'); + await delay(800); + for (let step = 0; step < scrollCount; step++) { + await this.#client.pressButton('right'); + await delay(500); + } + await this.#client.pressButton('both'); + await delay(500); + } + + /** + * Reject a transaction by scrolling to the reject option and pressing both buttons. + */ + async rejectTransaction(): Promise { + await this.#client.pressButton('right'); + await delay(300); + await this.#client.pressButton('both'); + await delay(500); + } + + /** + * Enable blind signing in the Ethereum app settings via button navigation. + */ + async enableBlindSigning(): Promise { + await this.#client.pressButton('both'); + await delay(800); + await this.#client.pressButton('right'); + await delay(400); + await this.#client.pressButton('both'); + await delay(800); + await this.#client.pressButton('both'); + await delay(800); + for (let step = 0; step < 6; step++) { + await this.#client.pressButton('right'); + await delay(200); + } + await this.#client.pressButton('both'); + await delay(500); + await this.#client.pressButton('left'); + await delay(400); + } + + /** + * Navigate back to the main menu by pressing the left button. + */ + async navigateToMainMenu(): Promise { + await this.#client.pressButton('left'); + await delay(400); + } +} + +/** + * Device interaction handler for Stax and Flex (touch-based). + */ +export class TouchInteraction implements DeviceInteraction { + readonly #client: SpeculosClient; + + readonly #model: DeviceModel; + + /** + * @param client - The Speculos client for sending touch events. + * @param model - The device model with screen and button coordinates. + */ + constructor(client: SpeculosClient, model: DeviceModel) { + this.#client = client; + this.#model = model; + } + + /** + * Swipe left on the touchscreen from center to near-center left. + */ + async swipeLeft(): Promise { + const { width, height } = this.#model.screenSize; + const centerX = width / 2; + const centerY = height / 2; + await this.#client.fingerSwipe( + centerX, + centerY, + centerX - 10, + centerY, + 0.5, + ); + await delay(800); + } + + /** + * Tap and hold the review confirm button. + * + * @param holdSeconds - Duration to hold the tap in seconds. + */ + async tapConfirm(holdSeconds = 3.0): Promise { + if (this.#model.reviewConfirmButton) { + await this.#client.fingerTap( + this.#model.reviewConfirmButton.x, + this.#model.reviewConfirmButton.y, + holdSeconds, + ); + } + } + + /** + * Tap the review reject button. + */ + async tapReject(): Promise { + if (this.#model.reviewRejectButton) { + await this.#client.fingerTap( + this.#model.reviewRejectButton.x, + this.#model.reviewRejectButton.y, + 0.1, + ); + } + } + + /** + * Tap the back button. + */ + async tapBack(): Promise { + if (this.#model.backButton) { + await this.#client.fingerTap( + this.#model.backButton.x, + this.#model.backButton.y, + 0.1, + ); + } + } + + /** + * Approve a transaction by swiping through review screens and tapping confirm. + */ + async approveTransaction(): Promise { + for (let step = 0; step < 3; step++) { + await this.swipeLeft(); + } + await this.tapConfirm(); + await delay(500); + } + + /** + * Approve a personal signing request by swiping through review screens and tapping confirm. + */ + async approveSigning(): Promise { + for (let step = 0; step < 2; step++) { + await this.swipeLeft(); + } + await this.tapConfirm(); + await delay(500); + } + + /** + * Approve blind signing by tapping confirm, scrolling, and confirming. + * + * @param scrollCount - Number of screens to scroll through (default 4). + */ + async approveBlindSigning(scrollCount = 4): Promise { + await this.#client.fingerTap( + this.#model.confirmButton?.x ?? 240, + this.#model.confirmButton?.y ?? 530, + 0.1, + ); + await delay(800); + + for (let step = 0; step < scrollCount; step++) { + await this.swipeLeft(); + await delay(300); + } + await this.tapConfirm(); + await delay(500); + } + + /** + * Reject a transaction by tapping the reject button. + */ + async rejectTransaction(): Promise { + await this.tapReject(); + await delay(500); + } + + /** + * Enable blind signing (no-op for NBGL devices — pre-enabled via NVRAM). + */ + async enableBlindSigning(): Promise { + console.log( + '[DeviceInteraction] Blind signing pre-enabled via NVRAM for NBGL device', + ); + } + + /** + * Navigate back to the main menu by tapping the back button. + */ + async navigateToMainMenu(): Promise { + await this.tapBack(); + await delay(400); + } +} + +/** + * Create the appropriate device interaction handler based on the device model. + * + * @param client - The Speculos client. + * @param model - The device model configuration. + * @returns A DeviceInteraction instance. + */ +export function createDeviceInteraction( + client: SpeculosClient, + model: DeviceModel, +): DeviceInteraction { + if (model.interactionType === 'touch') { + return new TouchInteraction(client, model); + } + return new NanoInteraction(client); +} diff --git a/packages/hw-emulator/src/ledger/docker-manager.test.ts b/packages/hw-emulator/src/ledger/docker-manager.test.ts new file mode 100644 index 000000000..97cae8587 --- /dev/null +++ b/packages/hw-emulator/src/ledger/docker-manager.test.ts @@ -0,0 +1,106 @@ +import { DockerManager } from './docker-manager'; + +describe('DockerManager', () => { + it('initializes with idle status', () => { + const manager = new DockerManager({ + composeFile: '/path/to/docker-compose.yml', + apduPort: 9998, + apiPort: 5001, + app: '/path/to/app.elf', + }); + expect(manager.getStatus()).toBe('idle'); + }); + + it('throws if start is called when not idle', async () => { + const manager = new DockerManager({ + composeFile: '/path/to/docker-compose.yml', + apduPort: 9998, + apiPort: 5001, + app: '/path/to/app.elf', + }); + // Direct start with invalid compose file should fail + await expect(manager.start()).rejects.toThrow('docker compose'); + expect(manager.getStatus()).toBe('idle'); + }, 10_000); + + it('buildDockerEnv includes SPECULOS_SEED when seed option is set', () => { + const manager = new DockerManager({ + composeFile: '/path/to/docker-compose.yml', + apduPort: 9998, + apiPort: 5001, + app: '/path/to/apps/ethereum-flex.elf', + model: 'flex', + seed: 'custom test seed phrase', + }); + + expect(manager.buildDockerEnv()).toStrictEqual({ + SPECULOS_DEVICE: 'flex', + SPECULOS_ELF_FILENAME: 'ethereum-flex.elf', + SPECULOS_APDU_PORT: '9998', + SPECULOS_API_PORT: '5001', + SPECULOS_SEED: 'custom test seed phrase', + }); + }); + + it('buildDockerEnv omits SPECULOS_SEED when seed option is unset', () => { + const manager = new DockerManager({ + composeFile: '/path/to/docker-compose.yml', + apduPort: 9998, + apiPort: 5001, + app: '/path/to/app.elf', + }); + + expect(manager.buildDockerEnv()).toStrictEqual({ + SPECULOS_DEVICE: 'nanosp', + SPECULOS_ELF_FILENAME: 'app.elf', + SPECULOS_APDU_PORT: '9998', + SPECULOS_API_PORT: '5001', + }); + }); + + it('buildDockerEnv maps non-default host ports for DEVICE_PRESETS', () => { + const manager = new DockerManager({ + composeFile: '/path/to/docker-compose.yml', + apduPort: 9997, + apiPort: 5002, + app: 'ethereum-nanosp.elf', + }); + + expect(manager.buildDockerEnv()).toMatchObject({ + SPECULOS_APDU_PORT: '9997', + SPECULOS_API_PORT: '5002', + }); + }); + + it('stop resolves immediately when idle', async () => { + const manager = new DockerManager({ + composeFile: '/path/to/docker-compose.yml', + apduPort: 9998, + apiPort: 5001, + app: '/path/to/app.elf', + }); + await manager.stop(); + expect(manager.getStatus()).toBe('idle'); + }); + + it('buildDockerEnv includes SPECULOS_DISPLAY when display option is set', () => { + const manager = new DockerManager({ + composeFile: '/path/to/docker-compose.yml', + apduPort: 9997, + apiPort: 5002, + app: '/apps/ethereum-flex.elf', + model: 'flex', + seed: 'test seed words', + display: 'headless', + }); + + expect(manager.buildDockerEnv()).toStrictEqual({ + SPECULOS_DEVICE: 'flex', + SPECULOS_ELF_FILENAME: 'ethereum-flex.elf', + SPECULOS_APDU_PORT: '9997', + SPECULOS_API_PORT: '5002', + SPECULOS_SEED: 'test seed words', + SPECULOS_DISPLAY: 'headless', + }); + }); +}); diff --git a/packages/hw-emulator/src/ledger/docker-manager.ts b/packages/hw-emulator/src/ledger/docker-manager.ts new file mode 100644 index 000000000..02e65c09d --- /dev/null +++ b/packages/hw-emulator/src/ledger/docker-manager.ts @@ -0,0 +1,180 @@ +// eslint-disable-next-line import-x/no-nodejs-modules +import { execFile } from 'node:child_process'; +// eslint-disable-next-line import-x/no-nodejs-modules +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +const CONTAINER_NAME = 'metamask-speculos'; +const HEALTH_CHECK_INTERVAL_MS = 1000; +const HEALTH_CHECK_TIMEOUT_MS = 60_000; + +/** + * Options for configuring the Docker-based Speculos manager. + */ +export type DockerManagerOptions = { + /** Path to the docker-compose.yml file. */ + composeFile: string; + /** TCP port for the APDU protocol. */ + apduPort: number; + /** TCP port for the REST API. */ + apiPort: number; + /** Path to the Ethereum app ELF binary. */ + app: string; + /** Speculos device model identifier. */ + model?: string; + /** BIP-39 mnemonic seed. */ + seed?: string; + /** Display backend. */ + display?: string; + /** Whether to load NVRAM state. */ + loadNvram?: boolean; + /** Maximum time in milliseconds to wait for the container to start. */ + startTimeout?: number; + /** Maximum time in milliseconds to wait for the container to stop. */ + stopTimeout?: number; +}; + +/** Lifecycle status of the Docker container. */ +export type DockerManagerStatus = 'idle' | 'starting' | 'running' | 'stopping'; + +/** + * Check if the Docker container's health status is "healthy". + * + * @returns True if the container reports healthy. + */ +async function isContainerHealthy(): Promise { + try { + const { stdout } = await execFileAsync('docker', [ + 'inspect', + '--format={{.State.Health.Status}}', + CONTAINER_NAME, + ]); + return stdout.trim() === 'healthy'; + } catch { + return false; + } +} + +/** + * Manage a Speculos Docker container via docker-compose. + */ +export class DockerManager { + readonly #options: DockerManagerOptions; + + #containerStatus: DockerManagerStatus = 'idle'; + + /** + * @param options - Docker manager configuration. + */ + constructor(options: DockerManagerOptions) { + this.#options = options; + } + + /** + * Build the environment variables map for docker-compose variable substitution. + * + * @returns A record of environment variable names to values. + */ + buildDockerEnv(): Record { + // Extract ELF filename from full path for docker-compose.yml variable substitution + const elfFilename = this.#options.app.split('/').pop() ?? this.#options.app; + + const env: Record = { + SPECULOS_DEVICE: this.#options.model ?? 'nanosp', + SPECULOS_ELF_FILENAME: elfFilename, + SPECULOS_APDU_PORT: String(this.#options.apduPort), + SPECULOS_API_PORT: String(this.#options.apiPort), + }; + if (this.#options.seed) { + env.SPECULOS_SEED = this.#options.seed; + } + if (this.#options.display) { + env.SPECULOS_DISPLAY = this.#options.display; + } + return env; + } + + /** + * Start the Docker container and wait for health checks. + * + * @returns A promise that resolves when the container is healthy. + */ + async start(): Promise { + if (this.#containerStatus !== 'idle') { + throw new Error( + `Docker container not idle (current: ${this.#containerStatus})`, + ); + } + this.#containerStatus = 'starting'; + + const env = this.buildDockerEnv(); + + const timeout = this.#options.startTimeout ?? HEALTH_CHECK_TIMEOUT_MS; + + try { + // Merge docker env vars into the process environment so that + // docker-compose.yml variable substitution picks them up. + // (docker compose up does NOT support -e flags — only docker compose run does.) + // eslint-disable-next-line no-restricted-globals + const childEnv = { ...process.env, ...env }; + + await execFileAsync( + 'docker', + ['compose', '-f', this.#options.composeFile, 'up', '-d'], + { timeout, env: childEnv }, + ); + + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + if (await isContainerHealthy()) { + this.#containerStatus = 'running'; + return; + } + await new Promise((resolve) => + setTimeout(resolve, HEALTH_CHECK_INTERVAL_MS), + ); + } + + throw new Error( + `Docker container health check timed out after ${timeout}ms`, + ); + } catch (startError: unknown) { + this.#containerStatus = 'idle'; + throw startError; + } + } + + /** + * Stop and remove the Docker container. + * + * @returns A promise that resolves when the container is stopped. + */ + async stop(): Promise { + if (this.#containerStatus === 'idle') { + return; + } + this.#containerStatus = 'stopping'; + + const timeout = this.#options.stopTimeout ?? 30_000; + + try { + await execFileAsync( + 'docker', + ['compose', '-f', this.#options.composeFile, 'down', '--timeout', '10'], + { timeout }, + ); + } finally { + this.#containerStatus = 'idle'; + } + } + + /** + * Get the current container status. + * + * @returns The container status. + */ + getStatus(): DockerManagerStatus { + return this.#containerStatus; + } +} diff --git a/packages/hw-emulator/src/ledger/index.ts b/packages/hw-emulator/src/ledger/index.ts new file mode 100644 index 000000000..f840a4a16 --- /dev/null +++ b/packages/hw-emulator/src/ledger/index.ts @@ -0,0 +1,50 @@ +export { Speculos, type SpeculosOptions } from './speculos'; +export { + SpeculosClient, + type SpeculosClientOptions, + type APDUResponse, +} from './client'; +export { ApduBridge } from './apdu-bridge'; +export { + createDeviceInteraction, + NanoInteraction, + TouchInteraction, + type DeviceInteraction, +} from './device-interaction'; +export { + createProcessManager, + type ProcessManager, + type ProcessManagerOptions, + type ProcessManagerStatus, +} from './process-manager'; +export { + DockerManager, + type DockerManagerOptions, + type DockerManagerStatus, +} from './docker-manager'; +export { getWebHidMockScript } from './webhid-mock-script'; +export { + DEVICE_MODELS, + DEFAULT_DEVICE_MODEL, + DEFAULT_DEVICE, + DEVICE_PRESETS, + SPECULOS_APDU_PORT, + SPECULOS_API_PORT, + SPECULOS_WS_BRIDGE_PORT, + SPECULOS_LEDGER_ADDRESSES, + SPECULOS_LEDGER_ADDRESS, + SPECULOS_SEED, + getDeviceModel, + detectRunMode, + type DeviceModel, + type DeviceConfig, + type InteractionType, + type RunMode, +} from './constants'; +export { withRetry, ExponentialBackoff, isRetryableError } from './resilience'; +export { + createLedgerHidFramingSession, + pushLedgerHidFrame, + encodeLedgerHidResponse, + type LedgerHidFramingSession, +} from './ledger-hid-framing'; diff --git a/packages/hw-emulator/src/ledger/ledger-hid-framing.ts b/packages/hw-emulator/src/ledger/ledger-hid-framing.ts new file mode 100644 index 000000000..dc6ffa698 --- /dev/null +++ b/packages/hw-emulator/src/ledger/ledger-hid-framing.ts @@ -0,0 +1,81 @@ +import createHIDframing from '@ledgerhq/devices/lib/hid-framing'; +import type { ResponseAcc } from '@ledgerhq/devices/lib/hid-framing'; + +/** + * HID packet size in bytes used by Ledger devices. + */ +const PACKET_SIZE = 64; + +/** + * Session state for reassembling and encoding Ledger HID frames. + */ +export type LedgerHidFramingSession = { + /** The communication channel ID. */ + channel: number; + /** The Ledger HID framing instance for encoding/decoding. */ + framing: ReturnType; + /** The accumulated response accumulator. */ + acc: ResponseAcc; +}; + +/** + * Create a framing session from the first HID frame. + * + * @param firstFrame - The first HID frame to initialize the session. + * @returns A new framing session. + */ +export function createLedgerHidFramingSession( + firstFrame: Buffer, +): LedgerHidFramingSession { + const channel = firstFrame.readUInt16BE(0); + return { + channel, + framing: createHIDframing(channel, PACKET_SIZE), + acc: null, + }; +} + +/** + * Push an incoming HID frame and return the complete APDU when ready. + * + * @param session - The framing session. + * @param frame - The HID frame to push. + * @returns The complete APDU buffer, or null if more frames are needed. + */ +export function pushLedgerHidFrame( + session: LedgerHidFramingSession, + frame: Buffer, +): Buffer | null { + try { + session.acc = session.framing.reduceResponse(session.acc, frame); + const result = session.framing.getReducedResult(session.acc); + if (result) { + session.acc = null; + return result; + } + return null; + } catch (rawError: unknown) { + const errorId = + rawError && typeof rawError === 'object' && 'id' in rawError + ? String((rawError as { id: string }).id) + : ''; + if (errorId === 'InvalidChannel') { + return null; + } + throw rawError; + } +} + +/** + * Encode a raw APDU response into HID frames. + * + * @param session - The framing session. + * @param apduResponse - The raw APDU response to encode. + * @returns An array of HID frame buffers. + */ +export function encodeLedgerHidResponse( + session: LedgerHidFramingSession, + apduResponse: Buffer, +): Buffer[] { + return session.framing.makeBlocks(apduResponse); +} diff --git a/packages/hw-emulator/src/ledger/process-manager.test.ts b/packages/hw-emulator/src/ledger/process-manager.test.ts new file mode 100644 index 000000000..7e4d81c1a --- /dev/null +++ b/packages/hw-emulator/src/ledger/process-manager.test.ts @@ -0,0 +1,33 @@ +import { createProcessManager } from './process-manager'; + +describe('createProcessManager', () => { + it('returns an object with start, stop, status, and pid', () => { + const manager = createProcessManager({ + binary: '/usr/bin/speculos', + app: '/path/to/app.elf', + }); + expect(manager.status).toBe('idle'); + expect(manager.pid).toBeUndefined(); + expect(typeof manager.start).toBe('function'); + expect(typeof manager.stop).toBe('function'); + }); + + it('throws if start is called when not idle', async () => { + const manager = createProcessManager({ + binary: '/usr/bin/speculos', + app: '/path/to/app.elf', + }); + // First start will try to spawn and fail (binary doesn't exist) + // But we can test the status check by mocking + await expect(manager.start()).rejects.toThrow('ENOENT'); + }); + + it('stop resolves immediately when idle', async () => { + const manager = createProcessManager({ + binary: '/usr/bin/speculos', + app: '/path/to/app.elf', + }); + await manager.stop(); + expect(manager.status).toBe('idle'); + }); +}); diff --git a/packages/hw-emulator/src/ledger/process-manager.ts b/packages/hw-emulator/src/ledger/process-manager.ts new file mode 100644 index 000000000..832784851 --- /dev/null +++ b/packages/hw-emulator/src/ledger/process-manager.ts @@ -0,0 +1,304 @@ +// eslint-disable-next-line import-x/no-nodejs-modules +import { spawn } from 'node:child_process'; +// eslint-disable-next-line import-x/no-nodejs-modules +import type { ChildProcess } from 'node:child_process'; +// eslint-disable-next-line import-x/no-nodejs-modules +import { EventEmitter } from 'node:events'; +// eslint-disable-next-line import-x/no-nodejs-modules +import net from 'node:net'; + +const READINESS_POLL_INTERVAL_MS = 250; +const READINESS_PROBE_TIMEOUT_MS = 1000; +const DEFAULT_APDU_PORT = 9999; +const DEFAULT_API_PORT = 5000; + +/** + * Options for configuring the native Speculos process manager. + */ +export type ProcessManagerOptions = { + /** Path to the Speculos binary. */ + binary: string; + /** Path to the Ethereum app ELF binary. */ + app: string; + /** Speculos device model identifier. */ + model?: string; + /** BIP-39 mnemonic seed. */ + seed?: string; + /** TCP port for the APDU protocol. */ + apduPort?: number; + /** TCP port for the REST API. */ + apiPort?: number; + /** Display backend. */ + display?: string; + /** Whether to load NVRAM state. */ + loadNvram?: boolean; + /** Working directory for the spawned process. */ + cwd?: string; + /** Maximum time in milliseconds to wait for the process to start. */ + startTimeout?: number; + /** Maximum time in milliseconds to wait for the process to stop. */ + stopTimeout?: number; +}; + +/** Lifecycle status of the Speculos process. */ +export type ProcessManagerStatus = + | 'idle' + | 'starting' + | 'listening' + | 'stopping'; + +/** + * Interface for managing the Speculos native process lifecycle. + */ +export type ProcessManager = { + /** Start the Speculos process and wait until APDU and API ports are reachable. */ + start(): Promise; + /** Stop the Speculos process gracefully. */ + stop(): Promise; + /** Current process status. */ + readonly status: ProcessManagerStatus; + /** Process ID of the running Speculos instance, or undefined. */ + readonly pid: number | undefined; +}; + +/** + * Check if a TCP port is reachable within a given timeout. + * + * @param port - The port number to check. + * @param host - The hostname to connect to. + * @param timeoutMs - Connection timeout in milliseconds. + * @returns True if the port is reachable. + */ +async function isTcpReachable( + port: number, + host = '127.0.0.1', + timeoutMs = READINESS_PROBE_TIMEOUT_MS, +): Promise { + return new Promise((resolve) => { + const sock = new net.Socket(); + let settled = false; + const finish = (ok: boolean): void => { + if (settled) { + return; + } + settled = true; + sock.destroy(); + resolve(ok); + }; + sock.setTimeout(timeoutMs); + sock.once('connect', () => finish(true)); + sock.once('timeout', () => finish(false)); + sock.once('error', () => finish(false)); + sock.connect(port, host); + }); +} + +/** + * Create a process manager for spawning a native Speculos binary. + * + * @param options - Process configuration. + * @returns A ProcessManager instance. + */ +export function createProcessManager( + options: ProcessManagerOptions, +): ProcessManager { + let proc: ChildProcess | undefined; + let status: ProcessManagerStatus = 'idle'; + const emitter = new EventEmitter(); + + function buildArgs(): string[] { + const args: string[] = []; + if (options.model) { + args.push('--model', options.model); + } + if (options.seed) { + args.push('--seed', options.seed); + } + if (options.apduPort !== undefined) { + args.push('--apdu-port', String(options.apduPort)); + } + if (options.apiPort !== undefined) { + args.push('--api-port', String(options.apiPort)); + } + if (options.display) { + args.push('--display', options.display); + } + if (options.loadNvram) { + args.push('--load-nvram'); + } + args.push(options.app); + return args; + } + + async function start(): Promise { + if (status !== 'idle') { + throw new Error(`Speculos process not idle (current: ${status})`); + } + status = 'starting'; + + const binaryPath = options.binary; + const args = buildArgs(); + const timeout = options.startTimeout ?? 60_000; + const apduPort = options.apduPort ?? DEFAULT_APDU_PORT; + const apiPort = options.apiPort ?? DEFAULT_API_PORT; + + return new Promise((resolve, reject) => { + let lastLog: string | undefined; + let settled = false; + let pollHandle: ReturnType | undefined; + let startTimer: ReturnType | undefined; + + const onExit = (exitCode: number | null): void => { + if (settled) { + return; + } + if (status === 'starting') { + settled = true; + status = 'idle'; + if (pollHandle) { + clearTimeout(pollHandle); + pollHandle = undefined; + } + if (startTimer) { + clearTimeout(startTimer); + startTimer = undefined; + } + reject( + new Error( + `Speculos exited during startup${lastLog ? `: ${lastLog.trim()}` : ''} (code ${exitCode})`, + ), + ); + } + }; + + const cleanup = (): void => { + if (pollHandle) { + clearTimeout(pollHandle); + pollHandle = undefined; + } + if (startTimer) { + clearTimeout(startTimer); + startTimer = undefined; + } + proc?.removeListener('exit', onExit); + }; + + const onStdout = (data: Buffer): void => { + const line = data.toString(); + emitter.emit('log', line); + lastLog = line; + }; + + startTimer = setTimeout(() => { + if (settled) { + return; + } + settled = true; + cleanup(); + proc?.kill('SIGTERM'); + status = 'idle'; + reject(new Error('Speculos failed to start within timeout')); + }, timeout); + + const pollReady = async (): Promise => { + if (settled) { + return; + } + try { + const [apduOk, apiOk] = await Promise.all([ + isTcpReachable(apduPort), + isTcpReachable(apiPort), + ]); + if (apduOk && apiOk) { + if (settled) { + return; + } + settled = true; + status = 'listening'; + cleanup(); + resolve(); + return; + } + } catch { + // probe failure is non-fatal + } + if (!settled) { + pollHandle = setTimeout( + () => + // eslint-disable-next-line no-void + void pollReady(), + READINESS_POLL_INTERVAL_MS, + ); + } + }; + + const spawnOpts: import('node:child_process').SpawnOptions = { + stdio: ['pipe', 'pipe', 'pipe'], + }; + if (options.cwd) { + spawnOpts.cwd = options.cwd; + } + proc = spawn(binaryPath, args, spawnOpts); + proc.stdout?.on('data', onStdout); + proc.stderr?.on('data', onStdout); + proc.on('exit', onExit as (code: number | null) => void); + proc.on('error', (spawnError: Error) => { + if (settled) { + return; + } + settled = true; + cleanup(); + status = 'idle'; + reject(spawnError); + }); + + pollHandle = setTimeout( + () => + // eslint-disable-next-line no-void + void pollReady(), + READINESS_POLL_INTERVAL_MS, + ); + }); + } + + async function stop(): Promise { + if (!proc || status === 'idle') { + return undefined; + } + status = 'stopping'; + const timeout = options.stopTimeout ?? 10_000; + + return new Promise((resolve) => { + const stopTimer = setTimeout((): void => { + proc?.kill('SIGKILL'); + status = 'idle'; + resolve(); + }, timeout); + + if (proc) { + proc.on('exit', () => { + clearTimeout(stopTimer); + status = 'idle'; + proc = undefined; + resolve(); + }); + proc.kill('SIGTERM'); + } else { + clearTimeout(stopTimer); + status = 'idle'; + resolve(); + } + }); + } + + return { + start, + stop, + get status(): ProcessManagerStatus { + return status; + }, + get pid(): number | undefined { + return proc?.pid; + }, + }; +} diff --git a/packages/hw-emulator/src/ledger/resilience.test.ts b/packages/hw-emulator/src/ledger/resilience.test.ts new file mode 100644 index 000000000..c4a294c38 --- /dev/null +++ b/packages/hw-emulator/src/ledger/resilience.test.ts @@ -0,0 +1,127 @@ +import { withRetry, ExponentialBackoff, isRetryableError } from './resilience'; + +describe('resilience', () => { + describe('withRetry', () => { + it('returns the result on first success', async () => { + const result = await withRetry(async () => Promise.resolve(42), { + maxRetries: 3, + }); + expect(result).toBe(42); + }); + + it('retries on failure and eventually succeeds', async () => { + let attempt = 0; + const fn = async (): Promise => { + attempt += 1; + if (attempt < 3) { + throw new Error('transient'); + } + return 'ok'; + }; + + const result = await withRetry(fn, { maxRetries: 3 }); + expect(result).toBe('ok'); + expect(attempt).toBe(3); + }, 15000); + + it('throws after exhausting retries', async () => { + const fn = async (): Promise => { + throw new Error('permanent'); + }; + + await expect(withRetry(fn, { maxRetries: 2 })).rejects.toThrow( + 'permanent', + ); + }, 15000); + + it('respects shouldRetry predicate', async () => { + const fn = async (): Promise => { + throw new Error('not-retryable'); + }; + + await expect( + withRetry(fn, { + maxRetries: 3, + shouldRetry: (error) => error.message === 'retryable', + }), + ).rejects.toThrow('not-retryable'); + }); + + it('calls onRetry callback', async () => { + const onRetry = jest.fn(); + let attempt = 0; + const fn = async (): Promise => { + attempt += 1; + if (attempt < 2) { + throw new Error('fail'); + } + return 'ok'; + }; + + await withRetry(fn, { maxRetries: 3, onRetry }); + expect(onRetry).toHaveBeenCalledTimes(1); + expect(onRetry).toHaveBeenCalledWith(expect.any(Error), 1); + }); + }); + + describe('ExponentialBackoff', () => { + it('starts with initial delay', () => { + const backoff = new ExponentialBackoff(100, 5000); + expect(backoff.next()).toBe(100); + }); + + it('doubles each step', () => { + const backoff = new ExponentialBackoff(100, 5000); + expect(backoff.next()).toBe(100); + expect(backoff.next()).toBe(200); + expect(backoff.next()).toBe(400); + }); + + it('caps at maxMs', () => { + const backoff = new ExponentialBackoff(100, 300); + expect(backoff.next()).toBe(100); + expect(backoff.next()).toBe(200); + expect(backoff.next()).toBe(300); + expect(backoff.next()).toBe(300); + }); + + it('resets to initial', () => { + const backoff = new ExponentialBackoff(100, 5000); + backoff.next(); + backoff.next(); + backoff.reset(); + expect(backoff.next()).toBe(100); + }); + + it('supports custom multiplier', () => { + const backoff = new ExponentialBackoff(100, 5000, 3); + expect(backoff.next()).toBe(100); + expect(backoff.next()).toBe(300); + expect(backoff.next()).toBe(900); + }); + }); + + describe('isRetryableError', () => { + it('returns true for ECONNREFUSED', () => { + const error = new Error() as Error & { code: string }; + error.code = 'ECONNREFUSED'; + expect(isRetryableError(error)).toBe(true); + }); + + it('returns true for ETIMEDOUT', () => { + const error = new Error() as Error & { code: string }; + error.code = 'ETIMEDOUT'; + expect(isRetryableError(error)).toBe(true); + }); + + it('returns true for error message containing ECONNRESET', () => { + const error = new Error('Connection failed: ECONNRESET'); + expect(isRetryableError(error)).toBe(true); + }); + + it('returns false for non-retryable errors', () => { + const error = new Error('Something else went wrong'); + expect(isRetryableError(error)).toBe(false); + }); + }); +}); diff --git a/packages/hw-emulator/src/ledger/resilience.ts b/packages/hw-emulator/src/ledger/resilience.ts new file mode 100644 index 000000000..15d2b7e3e --- /dev/null +++ b/packages/hw-emulator/src/ledger/resilience.ts @@ -0,0 +1,109 @@ +/** + * Retry a function with exponential backoff. + * + * @param fn - The async function to retry. + * @param options - Retry configuration. + * @param options.maxRetries - Maximum number of retry attempts. + * @param options.shouldRetry - Optional predicate to determine if an error is retryable. + * @param options.onRetry - Optional callback invoked before each retry. + * @returns The result of the function. + */ +export async function withRetry( + fn: () => Promise, + options: { + maxRetries: number; + shouldRetry?: (error: Error) => boolean; + onRetry?: (error: Error, attempt: number) => void; + }, +): Promise { + const { maxRetries, shouldRetry, onRetry } = options; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await fn(); + } catch (rawError: unknown) { + const error = + rawError instanceof Error ? rawError : new Error(String(rawError)); + const canRetry = + attempt < maxRetries && (shouldRetry ? shouldRetry(error) : true); + if (!canRetry) { + throw error; + } + onRetry?.(error, attempt + 1); + const delay = Math.min(1000 * Math.pow(2, attempt), 8000); + await new Promise((resolve) => { + setTimeout(resolve, delay); + }); + } + } + throw new Error('Unreachable'); +} + +/** + * Exponential backoff delay calculator. + */ +export class ExponentialBackoff { + #current: number; + + readonly #initialMs: number; + + readonly #maxMs: number; + + readonly #multiplier: number; + + constructor(initialMs: number, maxMs: number, multiplier = 2) { + this.#initialMs = initialMs; + this.#maxMs = maxMs; + this.#multiplier = multiplier; + this.#current = initialMs; + } + + /** + * Get the next delay value and advance the backoff. + * + * @returns The delay in milliseconds. + */ + next(): number { + const wait = this.#current; + this.#current = Math.min(this.#current * this.#multiplier, this.#maxMs); + return wait; + } + + /** + * Reset the backoff to its initial state. + */ + reset(): void { + this.#current = this.#initialMs; + } +} + +/** + * Check if an error is transient and worth retrying. + * + * @param error - The error to check. + * @returns True if the error is retryable. + */ +export function isRetryableError(error: Error): boolean { + const errorData = error as { message?: unknown; code?: unknown }; + const message = + typeof errorData.message === 'string' ? errorData.message : ''; + const code = typeof errorData.code === 'string' ? errorData.code : ''; + const retryableCodes = new Set([ + 'ECONNREFUSED', + 'ETIMEDOUT', + 'ECONNRESET', + 'EHOSTUNREACH', + 'EPIPE', + ]); + if (retryableCodes.has(code)) { + return true; + } + if ( + message.includes('ECONNREFUSED') || + message.includes('ETIMEDOUT') || + message.includes('ECONNRESET') + ) { + return true; + } + return false; +} diff --git a/packages/hw-emulator/src/ledger/speculos.test.ts b/packages/hw-emulator/src/ledger/speculos.test.ts new file mode 100644 index 000000000..ca1fc75ed --- /dev/null +++ b/packages/hw-emulator/src/ledger/speculos.test.ts @@ -0,0 +1,78 @@ +import { DEVICE_MODELS } from './constants'; +import type { DeviceModel } from './constants'; +import { Speculos } from './speculos'; + +describe('Speculos', () => { + it('constructs with default options', () => { + const speculos = new Speculos(); + expect(speculos.isRunning()).toBe(false); + }); + + it('constructs with a string device id', () => { + const speculos = new Speculos({ device: 'nanosp' }); + expect(speculos.isRunning()).toBe(false); + expect(speculos.getDeviceModel().id).toBe('nanosp'); + }); + + it('constructs with a DeviceModel object', () => { + const model = DEVICE_MODELS.stax as DeviceModel; + const speculos = new Speculos({ device: model }); + expect(speculos.getDeviceModel().id).toBe('stax'); + }); + + it('returns default device config', () => { + const speculos = new Speculos(); + const config = speculos.getDeviceConfig(); + expect(config.apduPort).toBe(9998); + expect(config.apiPort).toBe(5001); + expect(config.wsBridgePort).toBe(9876); + }); + + it('uses custom ports', () => { + const speculos = new Speculos({ + apduPort: 9997, + apiPort: 5002, + wsBridgePort: 9875, + }); + const config = speculos.getDeviceConfig(); + expect(config.apduPort).toBe(9997); + expect(config.apiPort).toBe(5002); + expect(config.wsBridgePort).toBe(9875); + }); + + it('throws when getClient called before start', () => { + const speculos = new Speculos(); + expect(() => speculos.getClient()).toThrow('Speculos not started'); + }); + + it('throws when getInteraction called before start', () => { + const speculos = new Speculos(); + expect(() => speculos.getInteraction()).toThrow('Speculos not started'); + }); + + it('throws when startBridge called before start', async () => { + const speculos = new Speculos(); + await expect(speculos.startBridge()).rejects.toThrow( + 'Speculos not started', + ); + }); + + it('stop resolves when not started', async () => { + const speculos = new Speculos(); + await speculos.stop(); + expect(true).toBe(true); + }); + + it('returns WebHID mock script', () => { + const speculos = new Speculos(); + const script = speculos.getWebHIDMockScript(9999); + expect(script).toContain('9999'); + expect(script).toContain('WebSocket'); + expect(script).toContain('mockHID'); + }); + + it('detects device model for unknown id', () => { + const speculos = new Speculos({ device: 'nonexistent' }); + expect(() => speculos.getDeviceModel()).toThrow('Unknown device model'); + }); +}); diff --git a/packages/hw-emulator/src/ledger/speculos.ts b/packages/hw-emulator/src/ledger/speculos.ts new file mode 100644 index 000000000..798ea0b62 --- /dev/null +++ b/packages/hw-emulator/src/ledger/speculos.ts @@ -0,0 +1,407 @@ +import type { + HardwareWalletEmulator, + DeviceInteraction as SharedDeviceInteraction, +} from '../types'; +import { ApduBridge } from './apdu-bridge'; +import { SpeculosClient } from './client'; +import { + getDeviceModel, + detectRunMode, + SPECULOS_SEED, + DEFAULT_DEVICE, +} from './constants'; +import type { DeviceModel, DeviceConfig, RunMode } from './constants'; +import { createDeviceInteraction } from './device-interaction'; +import type { DeviceInteraction } from './device-interaction'; +import { DockerManager } from './docker-manager'; +import { createProcessManager } from './process-manager'; +import type { ProcessManager } from './process-manager'; +import { getWebHidMockScript } from './webhid-mock-script'; + +/** + * Configuration options for the Speculos Ledger emulator. + */ +export type SpeculosOptions = { + /** Device model or model identifier string (defaults to 'flex'). */ + device?: DeviceModel | string; + /** BIP-39 mnemonic seed (defaults to SPECULOS_SEED). */ + seed?: string; + /** TCP port for the APDU protocol. */ + apduPort?: number; + /** TCP port for the REST API. */ + apiPort?: number; + /** TCP port for the WebSocket bridge. */ + wsBridgePort?: number; + /** Execution mode — 'native' or 'docker' (auto-detected by default). */ + mode?: RunMode; + /** Path to the Speculos binary (required in native mode). */ + binary?: string; + /** Display backend (defaults to 'headless'). */ + display?: string; + /** Whether to load NVRAM state (defaults to true). */ + loadNvram?: boolean; + /** Maximum time in milliseconds to wait for the emulator to start. */ + startTimeout?: number; +}; + +/** + * Fully resolved configuration after applying defaults. + */ +type ResolvedConfig = { + deviceModel: DeviceModel; + deviceConfig: DeviceConfig; + seed: string; + mode: RunMode; +}; + +const DEFAULT_DISPLAY = 'headless'; + +/** + * Speculos Ledger emulator — manages the full lifecycle of a Ledger device emulation, + * including starting/stopping the emulator process or Docker container, communicating + * via APDU, and providing device screen interaction capabilities. + */ +export class Speculos implements HardwareWalletEmulator { + readonly #options: SpeculosOptions; + + #resolvedConfig: ResolvedConfig | null = null; + + #processManager: ProcessManager | null = null; + + #dockerManager: DockerManager | null = null; + + #bridgeInstance: ApduBridge | null = null; + + #clientInstance: SpeculosClient | null = null; + + #interactionInstance: DeviceInteraction | null = null; + + #started = false; + + /** + * @param options - Configuration options for the emulator. + */ + constructor(options: SpeculosOptions = {}) { + this.#options = options; + } + + /** + * Resolve and cache the full configuration, applying defaults for any unset options. + * + * @returns The resolved configuration. + */ + resolveConfig(): ResolvedConfig { + if (this.#resolvedConfig) { + return this.#resolvedConfig; + } + + const deviceModel = + typeof this.#options.device === 'string' + ? getDeviceModel(this.#options.device) + : (this.#options.device ?? getDeviceModel()); + + const deviceConfig: DeviceConfig = { + id: 'default', + apduPort: this.#options.apduPort ?? DEFAULT_DEVICE.apduPort, + apiPort: this.#options.apiPort ?? DEFAULT_DEVICE.apiPort, + wsBridgePort: this.#options.wsBridgePort ?? DEFAULT_DEVICE.wsBridgePort, + }; + + const mode = this.#options.mode ?? detectRunMode(); + + this.#resolvedConfig = { + deviceModel, + deviceConfig, + seed: this.#options.seed ?? SPECULOS_SEED, + mode, + }; + + return this.#resolvedConfig; + } + + /** + * Get the filesystem path to the Ethereum app ELF binary for the configured device. + * + * @returns The absolute path to the ELF file. + */ + getElfPath(): string { + const config = this.resolveConfig(); + // eslint-disable-next-line no-restricted-globals + return `${__dirname}/../../apps/${config.deviceModel.elfFile}`; + } + + /** + * Get the filesystem path to the NVRAM binary file for persistent device state. + * + * @returns The absolute path to the NVRAM file. + */ + getNvramPath(): string { + // eslint-disable-next-line no-restricted-globals + return `${__dirname}/../../nvram/main_nvram.bin`; + } + + /** + * Get the filesystem path to the docker-compose.yml file. + * + * @returns The absolute path to the compose file. + */ + getDockerComposePath(): string { + // eslint-disable-next-line no-restricted-globals + return `${__dirname}/../../docker-compose.yml`; + } + + /** + * Attempt to resolve the speculos binary via @metamask/speculos-up. + * + * @returns The binary path if speculos-up is installed, null otherwise. + */ + async #resolveSpeculosUpBinary(): Promise { + try { + const mod = (await import('@metamask/speculos-up' as string)) as { + getSpeculosBinaryPath: () => string | null; + }; + return mod.getSpeculosBinaryPath(); + } catch { + return null; + } + } + + /** + * Start the Speculos emulator and connect the APDU client. + * + * @throws If already started. + */ + async start(): Promise { + if (this.#started) { + throw new Error('Speculos already started'); + } + + const config = this.resolveConfig(); + + if (config.mode === 'native') { + await this.startNative(config); + } else { + await this.startDocker(config); + } + + this.#clientInstance = new SpeculosClient({ + apduPort: config.deviceConfig.apduPort, + apiPort: config.deviceConfig.apiPort, + }); + + await this.#clientInstance.connectWithRetry({ + autoReconnect: true, + reconnectAttempts: 5, + reconnectDelayMs: 2000, + }); + + this.#interactionInstance = createDeviceInteraction( + this.#clientInstance, + config.deviceModel, + ); + + this.#started = true; + } + + /** + * Start Speculos as a native process (Linux only). + * + * @param config - The resolved emulator configuration. + */ + async startNative(config: ResolvedConfig): Promise { + const speculosUpBinary = await this.#resolveSpeculosUpBinary(); + const binary = this.#options.binary ?? speculosUpBinary ?? 'speculos'; + + this.#processManager = createProcessManager({ + binary, + app: this.getElfPath(), + model: config.deviceModel.speculosModel, + seed: config.seed, + apduPort: config.deviceConfig.apduPort, + apiPort: config.deviceConfig.apiPort, + display: this.#options.display ?? DEFAULT_DISPLAY, + loadNvram: this.#options.loadNvram ?? true, + ...(this.#options.startTimeout === undefined + ? {} + : { startTimeout: this.#options.startTimeout }), + }); + + await this.#processManager.start(); + } + + /** + * Start Speculos via Docker Compose. + * + * @param config - The resolved emulator configuration. + */ + async startDocker(config: ResolvedConfig): Promise { + this.#dockerManager = new DockerManager({ + composeFile: this.getDockerComposePath(), + apduPort: config.deviceConfig.apduPort, + apiPort: config.deviceConfig.apiPort, + app: this.getElfPath(), + model: config.deviceModel.speculosModel, + seed: config.seed, + display: this.#options.display ?? DEFAULT_DISPLAY, + loadNvram: this.#options.loadNvram ?? true, + ...(this.#options.startTimeout === undefined + ? {} + : { startTimeout: this.#options.startTimeout }), + }); + + await this.#dockerManager.start(); + } + + /** + * Stop the emulator and release all resources (bridge, process/docker, client). + */ + async stop(): Promise { + if (!this.#started) { + return; + } + + if (this.#bridgeInstance) { + await this.#bridgeInstance.stop(); + this.#bridgeInstance = null; + } + + if (this.#processManager) { + await this.#processManager.stop(); + this.#processManager = null; + } + + if (this.#dockerManager) { + await this.#dockerManager.stop(); + this.#dockerManager = null; + } + + if (this.#clientInstance) { + await this.#clientInstance.disconnect(); + this.#clientInstance = null; + } + + this.#interactionInstance = null; + this.#started = false; + } + + /** + * Get the Speculos client for direct APDU and API communication. + * + * @returns The connected SpeculosClient instance. + * @throws If the emulator has not been started. + */ + getClient(): SpeculosClient { + if (!this.#clientInstance) { + throw new Error('Speculos not started. Call start() first.'); + } + return this.#clientInstance; + } + + /** + * Get the device interaction handler for screen actions. + * + * @returns The device interaction instance. + * @throws If the emulator has not been started. + */ + getInteraction(): SharedDeviceInteraction { + if (!this.#interactionInstance) { + throw new Error('Speculos not started. Call start() first.'); + } + return this.#interactionInstance; + } + + /** + * Start a WebSocket bridge that relays HID-framed APDUs between a browser and Speculos. + * + * @param wsPort - Override port for the WebSocket server. + * @returns The started ApduBridge instance. + * @throws If the emulator has not been started. + */ + async startBridge(wsPort?: number): Promise { + if (!this.#clientInstance) { + throw new Error('Speculos not started. Call start() first.'); + } + + const config = this.resolveConfig(); + const port = wsPort ?? config.deviceConfig.wsBridgePort; + + this.#bridgeInstance = new ApduBridge(this.#clientInstance, port); + await this.#bridgeInstance.start(); + return this.#bridgeInstance; + } + + /** + * Generate the browser-side WebHID mock script for connecting to the WebSocket bridge. + * + * @param wsPort - Override port for the WebSocket connection. + * @returns JavaScript source code as a string. + */ + getWebHIDMockScript(wsPort?: number): string { + const config = this.resolveConfig(); + const port = wsPort ?? config.deviceConfig.wsBridgePort; + return getWebHidMockScript(port); + } + + /** + * Check whether the emulator is currently running. + * + * @returns True if started and running. + */ + isRunning(): boolean { + return this.#started; + } + + /** + * Get the resolved device model configuration. + * + * @returns The device model. + */ + getDeviceModel(): DeviceModel { + return this.resolveConfig().deviceModel; + } + + /** + * Get the resolved device port configuration. + * + * @returns The device config with port numbers. + */ + getDeviceConfig(): DeviceConfig { + return this.resolveConfig().deviceConfig; + } + + /** + * Approve a transaction on the device screen. + * + * @returns A promise that resolves when the approval is complete. + */ + async approveTransaction(): Promise { + return this.getInteraction().approveTransaction(); + } + + /** + * Approve a signing request on the device screen. + * + * @returns A promise that resolves when the approval is complete. + */ + async approveSigning(): Promise { + return this.getInteraction().approveSigning(); + } + + /** + * Reject a transaction on the device screen. + * + * @returns A promise that resolves when the rejection is complete. + */ + async rejectTransaction(): Promise { + return this.getInteraction().rejectTransaction(); + } + + /** + * Navigate to the main menu on the device screen. + * + * @returns A promise that resolves when navigation is complete. + */ + async navigateToMainMenu(): Promise { + return this.getInteraction().navigateToMainMenu(); + } +} diff --git a/packages/hw-emulator/src/ledger/webhid-mock-script.test.ts b/packages/hw-emulator/src/ledger/webhid-mock-script.test.ts new file mode 100644 index 000000000..d625fe185 --- /dev/null +++ b/packages/hw-emulator/src/ledger/webhid-mock-script.test.ts @@ -0,0 +1,26 @@ +import { getWebHidMockScript } from './webhid-mock-script'; + +describe('getWebHidMockScript', () => { + it('removes pendingExchanges entries on HID_FRAME_ACK', () => { + const script = getWebHidMockScript(9876); + + const ackHandler = script.match( + /else if \(response\.type === 'HID_FRAME_ACK'\) \{[\s\S]*?\} else if \(response\.type === 'APDU_ERROR'\)/u, + )?.[0]; + + expect(ackHandler).toBeDefined(); + expect(ackHandler).toContain('pendingExchanges.delete(response.id)'); + expect(ackHandler).toContain('pending.resolve()'); + }); + + it('removes pendingExchanges entries on HID_EXCHANGE_COMPLETE and APDU_ERROR', () => { + const script = getWebHidMockScript(9876); + + expect(script).toMatch( + /HID_EXCHANGE_COMPLETE[\s\S]*?pendingExchanges\.delete\(response\.id\)/u, + ); + expect(script).toMatch( + /APDU_ERROR[\s\S]*?pendingExchanges\.delete\(response\.id\)/u, + ); + }); +}); diff --git a/packages/hw-emulator/src/ledger/webhid-mock-script.ts b/packages/hw-emulator/src/ledger/webhid-mock-script.ts new file mode 100644 index 000000000..53d26a040 --- /dev/null +++ b/packages/hw-emulator/src/ledger/webhid-mock-script.ts @@ -0,0 +1,203 @@ +/** + * Returns browser-side WebHID mock source for Speculos over WebSocket. + * + * @param wsPort - The WebSocket port to connect to. + * @returns The JavaScript source code as a string. + */ +export function getWebHidMockScript(wsPort: number): string { + return ` + (function() { + if (window.__webHIDMockInjected) return; + window.__webHIDMockInjected = true; + + const _WS = WebSocket; + const _WS_OPEN = WebSocket.OPEN; + const _WS_CONNECTING = WebSocket.CONNECTING; + + const wsPort = ${wsPort}; + let ws = null; + let messageId = 0; + const pendingExchanges = new Map(); + + const LEDGER_COLLECTIONS = [{ + usagePage: 0xffa0, + usage: 0x01, + inputReports: [{ reportId: 0 }], + outputReports: [{ reportId: 0 }], + featureReports: [], + }]; + + const connectWebSocket = function() { + if (ws && (ws.readyState === _WS_OPEN || ws.readyState === _WS_CONNECTING)) { + return; + } + ws = new _WS('ws://localhost:' + wsPort); + + ws.onopen = function() { + console.log('[WebHID Mock] WebSocket connected'); + }; + + ws.onmessage = function(event) { + const response = JSON.parse(event.data); + if (response.type === 'HID_RECV') { + const pending = pendingExchanges.get(response.id); + if (!pending) return; + const cbs = window.__inputReportCallbacks || []; + const frame = new Uint8Array(response.data); + cbs.forEach(function(cb) { + cb({ + type: 'inputreport', + device: pending.device, + data: new DataView(frame.buffer, frame.byteOffset, frame.byteLength), + reportId: 0, + }); + }); + } else if (response.type === 'HID_EXCHANGE_COMPLETE') { + const pending = pendingExchanges.get(response.id); + if (pending) { + pendingExchanges.delete(response.id); + pending.resolve(); + } + } else if (response.type === 'HID_FRAME_ACK') { + const pending = pendingExchanges.get(response.id); + if (pending) { + pendingExchanges.delete(response.id); + pending.resolve(); + } + } else if (response.type === 'APDU_ERROR') { + const pending = pendingExchanges.get(response.id); + if (pending) { + pendingExchanges.delete(response.id); + pending.reject(new Error(response.error)); + } + } + }; + + ws.onclose = function() { + console.log('[WebHID Mock] WebSocket disconnected'); + ws = null; + }; + + ws.onerror = function(socketError) { + console.error('[WebHID Mock] WebSocket error:', socketError); + }; + }; + + const runHidExchange = function(device, frameData) { + return new Promise(function(resolve, reject) { + const startSend = function() { + if (!ws || ws.readyState !== _WS_OPEN) { + reject(new Error('WebSocket not connected')); + return; + } + messageId += 1; + const exchangeId = messageId; + pendingExchanges.set(exchangeId, { resolve: resolve, reject: reject, device: device }); + ws.send(JSON.stringify({ + type: 'HID_SEND', + id: exchangeId, + data: Array.from(frameData), + })); + }; + + if (ws && ws.readyState === _WS_OPEN) { + startSend(); + return; + } + if (!ws || ws.readyState !== _WS_CONNECTING) { + connectWebSocket(); + } + const socket = ws; + if (!socket) { + reject(new Error('WebSocket not initialized')); + return; + } + const onOpen = function() { + socket.removeEventListener('open', onOpen); + socket.removeEventListener('error', onErr); + startSend(); + }; + const onErr = function() { + socket.removeEventListener('open', onOpen); + socket.removeEventListener('error', onErr); + reject(new Error('WebSocket failed to open')); + }; + socket.addEventListener('open', onOpen); + socket.addEventListener('error', onErr); + }); + }; + + const mockDevice = { + vendorId: 0x2c97, + productId: 0x0001, + productName: 'Ledger Nano S Plus', + collections: LEDGER_COLLECTIONS, + opened: false, + + open: function() { + this.opened = true; + return Promise.resolve(); + }, + + close: function() { + this.opened = false; + return Promise.resolve(); + }, + + forget: function() { + this.opened = false; + return Promise.resolve(); + }, + + sendReport: function(reportId, data) { + const frame = new Uint8Array(data); + return runHidExchange(this, frame); + }, + + receiveReport: function() { + return Promise.resolve(new DataView(new ArrayBuffer(0))); + }, + + addEventListener: function(type, cb) { + if (type === 'inputreport' && cb) { + window.__inputReportCallbacks = window.__inputReportCallbacks || []; + window.__inputReportCallbacks.push(cb); + } + }, + + removeEventListener: function(type, cb) { + if (type === 'inputreport' && cb) { + const cbs = window.__inputReportCallbacks || []; + const idx = cbs.indexOf(cb); + if (idx > -1) { + cbs.splice(idx, 1); + } + } + }, + }; + + const mockHID = { + getDevices: function() { + return Promise.resolve([mockDevice]); + }, + + requestDevice: function() { + return Promise.resolve([mockDevice]); + }, + + addEventListener: function() {}, + removeEventListener: function() {}, + }; + + Object.defineProperty(navigator, 'hid', { + value: mockHID, + writable: true, + configurable: true, + }); + + window.__speculosDevice = mockDevice; + connectWebSocket(); + console.log('[WebHID Mock] Installed (HID framing → ApduBridge)'); + })(); + `; +} diff --git a/packages/hw-emulator/src/types.ts b/packages/hw-emulator/src/types.ts new file mode 100644 index 000000000..01910d794 --- /dev/null +++ b/packages/hw-emulator/src/types.ts @@ -0,0 +1,75 @@ +/** + * Supported hardware wallet emulator types. + */ +export const EmulatorType = { + Ledger: 'ledger', + Trezor: 'trezor', +} as const; + +/** + * String union of supported emulator type identifiers. + */ +export type EmulatorType = (typeof EmulatorType)[keyof typeof EmulatorType]; + +/** + * Interface for interacting with an emulated hardware wallet device screen. + * Provides methods to approve, reject, and navigate through on-screen prompts. + */ +export type DeviceInteraction = { + /** + * Approve a transaction on the device screen. + */ + approveTransaction(): Promise; + /** + * Approve a signing request on the device screen. + */ + approveSigning(): Promise; + /** + * Reject a transaction on the device screen. + */ + rejectTransaction(): Promise; + /** + * Navigate back to the main menu on the device screen. + */ + navigateToMainMenu(): Promise; +}; + +/** + * Hardware wallet emulator providing lifecycle management and device interaction. + */ +export type HardwareWalletEmulator = { + /** + * Start the emulator. + */ + start(): Promise; + /** + * Stop the emulator and release all resources. + */ + stop(): Promise; + /** + * Check whether the emulator is currently running. + */ + isRunning(): boolean; + /** + * Get the device interaction handler for screen actions. + * + * @returns The device interaction instance. + */ + getInteraction(): DeviceInteraction; + /** + * Approve a transaction via the device screen. + */ + approveTransaction(): Promise; + /** + * Approve a signing request via the device screen. + */ + approveSigning(): Promise; + /** + * Reject a transaction via the device screen. + */ + rejectTransaction(): Promise; + /** + * Navigate to the main menu on the device screen. + */ + navigateToMainMenu(): Promise; +}; diff --git a/packages/hw-emulator/tsconfig.build.json b/packages/hw-emulator/tsconfig.build.json new file mode 100644 index 000000000..5d4c50e3d --- /dev/null +++ b/packages/hw-emulator/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "dist", + "rootDir": "src", + "paths": {} + }, + "include": ["./src/**/*.ts"], + "exclude": ["./src/**/*.test.ts", "./src/**/*.test-d.ts"] +} diff --git a/packages/hw-emulator/tsconfig.json b/packages/hw-emulator/tsconfig.json new file mode 100644 index 000000000..0842f4dd7 --- /dev/null +++ b/packages/hw-emulator/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { "baseUrl": "./" }, + "include": ["./src"], + "exclude": ["./dist/**/*"] +} diff --git a/packages/speculos-up/CHANGELOG.md b/packages/speculos-up/CHANGELOG.md new file mode 100644 index 000000000..455a6eee4 --- /dev/null +++ b/packages/speculos-up/CHANGELOG.md @@ -0,0 +1,23 @@ +# 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] + +### Added + +- Initial release of `@metamask/speculos-up` ([#TODO](https://github.com/MetaMask/accounts/pull/TODO)) + - Download and manage pre-built Speculos Ledger emulator binaries from GitHub releases + - `downloadAndInstall()` — downloads, caches, and symlinks the speculos binary + - `getSpeculosBinaryPath()` — resolves the path to the managed binary + - `isSpeculosInstalled()` — checks if the managed binary exists + - `cleanCache()` — removes cached installations + - HTTP streaming download with redirect support + - tar.gz extraction with optional SHA-256 checksum verification + - CLI entry point `mm-speculos-up` + - Add pre-packaged speculos binaries (linux-amd64 + linux-arm64) to avoid runtime downloads + +[Unreleased]: https://github.com/MetaMask/accounts/ diff --git a/packages/speculos-up/LICENSE b/packages/speculos-up/LICENSE new file mode 100644 index 000000000..fe29e78e0 --- /dev/null +++ b/packages/speculos-up/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 MetaMask + +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, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +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/speculos-up/README.md b/packages/speculos-up/README.md new file mode 100644 index 000000000..7d1160f97 --- /dev/null +++ b/packages/speculos-up/README.md @@ -0,0 +1,126 @@ +# `@metamask/speculos-up` + +Download and manage pre-built [Speculos](https://github.com/LedgerHQ/speculos) Ledger emulator binaries from GitHub releases. + +Inspired by [`@metamask/foundryup`](https://github.com/MetaMask/core/tree/main/packages/foundryup) — provides zero-dependency, cross-platform binary management for the Speculos hardware wallet emulator. + +## Installation + +`yarn add @metamask/speculos-up` + +or + +`npm install @metamask/speculos-up` + +## Overview + +Speculos is a Python-based Ledger device emulator. This package downloads pre-built standalone Speculos binaries (produced by PyInstaller on Linux) from GitHub releases, caches them locally, and provides a simple API for resolving the binary path at runtime. + +This avoids requiring developers to install Python, set up a virtual environment, or manage Speculos manually. + +## Usage + +### CLI + +```bash +# Download and install the default version +yarn mm-speculos-up + +# The binary is available at node_modules/.bin/speculos +``` + +### Programmatic + +```typescript +import { + getSpeculosBinaryPath, + downloadAndInstall, +} from '@metamask/speculos-up'; + +// Check if already installed +const binaryPath = getSpeculosBinaryPath(); + +// Or download explicitly +await downloadAndInstall(); +``` + +### With `@metamask/hw-emulator` + +`hw-emulator` automatically resolves the managed binary via an optional peer dependency: + +```typescript +import { createEmulator, EmulatorType } from '@metamask/hw-emulator'; + +const emulator = createEmulator(EmulatorType.Ledger, { + device: 'flex', + mode: 'native', + // No `binary` option needed — uses speculos-up's managed binary +}); + +await emulator.start(); +``` + +## API + +### `downloadAndInstall(options?)` + +Download, cache, and symlink the Speculos binary. + +### `getSpeculosBinaryPath(options?)` + +Returns the absolute path to the managed binary, or `null` if not installed. + +### `isSpeculosInstalled(options?)` + +Returns `true` if the managed binary exists on disk. + +### `cleanCache(options?)` + +Removes all cached installations. + +### `SpeculosupOptions` + +| Option | Type | Default | Description | +| ---------- | -------------- | ------------------------------- | ---------------------------------- | +| `version` | string | `'0.25.13'` | Speculos version to install. | +| `repo` | string | `'MetaMask/accounts'` | GitHub repo hosting releases. | +| `cacheDir` | string | `~/.cache/metamask/speculos-up` | Custom cache directory. | +| `platform` | `Platform` | `Platform.Linux` | Target platform. | +| `arch` | `Architecture` | Auto-detected | Target architecture (amd64/arm64). | + +## How It Works + +1. Computes a download URL for the target platform/architecture +2. Checks a local cache (`~/.cache/metamask/speculos-up/`) for existing binaries +3. If not cached, downloads the tar.gz from GitHub releases +4. Extracts and verifies checksums (if provided) +5. Symlinks (or copies) into `node_modules/.bin/speculos` + +## Requirements + +- **Linux** — native binary (pre-built via PyInstaller) +- **macOS / Windows** — use Docker mode via `@metamask/hw-emulator` instead + +## Building release binaries (maintainers) + +Release archives are **Linux ELF** binaries (`speculos-v-linux-.tar.gz`). CI builds **linux-amd64** only (see `.github/workflows/build-speculos.yml`); build **linux-arm64** locally with the Docker script below. + +On **macOS** (including Apple Silicon) or Windows, use Docker so PyInstaller runs inside Linux: + +```bash +cd packages/speculos-up + +# Both linux-amd64 and linux-arm64 (arm64 is fast on M-series; amd64 uses emulation) +./scripts/build-speculos-docker.sh 0.25.13 + +# Single architecture +./scripts/build-speculos-docker.sh 0.25.13 arm64 +``` + +Artifacts land in `packages/speculos-up/dist-build/`. Upload them to a GitHub release tagged `speculos-v` on `MetaMask/accounts`. + +On **Linux**, you can use `./scripts/build-speculos.sh` for the host architecture only, or the Docker script for both arches. + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/accounts#readme). diff --git a/packages/speculos-up/bundled/checksums.json b/packages/speculos-up/bundled/checksums.json new file mode 100644 index 000000000..8c36c4954 --- /dev/null +++ b/packages/speculos-up/bundled/checksums.json @@ -0,0 +1,4 @@ +{ + "speculos-v0.25.13-linux-amd64.tar.gz": "44debafdda53051a0860f6a2c3b079662145884deb13ee137dcb9da0249dcbf0", + "speculos-v0.25.13-linux-arm64.tar.gz": "1e67c6d15aa85599b6d7e2111540feabf0fe50a8e8ac3a5a8bd0a872dcbb3fd5" +} diff --git a/packages/speculos-up/bundled/speculos-v0.25.13-linux-amd64.tar.gz b/packages/speculos-up/bundled/speculos-v0.25.13-linux-amd64.tar.gz new file mode 100644 index 000000000..495fe3401 Binary files /dev/null and b/packages/speculos-up/bundled/speculos-v0.25.13-linux-amd64.tar.gz differ diff --git a/packages/speculos-up/bundled/speculos-v0.25.13-linux-arm64.tar.gz b/packages/speculos-up/bundled/speculos-v0.25.13-linux-arm64.tar.gz new file mode 100644 index 000000000..d60881d76 Binary files /dev/null and b/packages/speculos-up/bundled/speculos-v0.25.13-linux-arm64.tar.gz differ diff --git a/packages/speculos-up/jest.config.js b/packages/speculos-up/jest.config.js new file mode 100644 index 000000000..95c8a70ae --- /dev/null +++ b/packages/speculos-up/jest.config.js @@ -0,0 +1,11 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +const merge = require('deepmerge'); +const path = require('path'); +const baseConfig = require('../../jest.config.packages'); + +module.exports = merge(baseConfig, { + displayName: path.basename(__dirname), + coverageThreshold: { + global: { branches: 10, functions: 20, lines: 20, statements: 20 }, + }, +}); diff --git a/packages/speculos-up/package.json b/packages/speculos-up/package.json new file mode 100644 index 000000000..0cec49a99 --- /dev/null +++ b/packages/speculos-up/package.json @@ -0,0 +1,95 @@ +{ + "name": "@metamask/speculos-up", + "version": "0.1.0", + "description": "Download and manage the Speculos Ledger emulator binary", + "keywords": [ + "emulator", + "hardware-wallet", + "ledger", + "metamask", + "speculos" + ], + "homepage": "https://github.com/MetaMask/accounts/tree/main/packages/speculos-up#readme", + "bugs": { + "url": "https://github.com/MetaMask/accounts/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/accounts.git" + }, + "bin": { + "mm-speculos-up": "./dist/cli.mjs" + }, + "files": [ + "dist/", + "bundled/*.tar.gz", + "bundled/checksums.json" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:clean": "yarn build --clean", + "build:docs": "typedoc", + "build:speculos:docker": "bash ./scripts/build-speculos-docker.sh", + "changelog:update": "../../scripts/update-changelog.sh @metamask/speculos-up", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/speculos-up", + "publish:preview": "yarn npm publish --tag preview", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test": "yarn test:source && yarn test:types", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:source": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:types": "../../scripts/tsd-test.sh ./src", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "dependencies": { + "tar": "^7.4.3" + }, + "devDependencies": { + "@lavamoat/allow-scripts": "^3.2.1", + "@lavamoat/preinstall-always-fail": "^2.1.0", + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.3", + "@types/jest": "^29.5.12", + "@types/node": "^20.12.12", + "deepmerge": "^4.2.2", + "depcheck": "^1.4.7", + "jest": "^29.5.0", + "jest-it-up": "^3.1.0", + "rimraf": "^5.0.7", + "ts-jest": "^29.0.5", + "ts-node": "^10.9.2", + "tsd": "^0.31.0", + "typedoc": "^0.25.13", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + }, + "lavamoat": { + "allowScripts": { + "@lavamoat/preinstall-always-fail": false + } + } +} diff --git a/packages/speculos-up/scripts/build-speculos-docker.sh b/packages/speculos-up/scripts/build-speculos-docker.sh new file mode 100755 index 000000000..00cfe0818 --- /dev/null +++ b/packages/speculos-up/scripts/build-speculos-docker.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Build Linux Speculos release archives via Docker (macOS, Windows, or Linux hosts). +# PyInstaller cannot cross-compile: each arch runs in a matching linux/ container. +# +# Usage: ./scripts/build-speculos-docker.sh [version] [arch...] +# +# version Speculos pip version (default: 0.25.13) +# arch One or more of: amd64, arm64 (default: both) +# +# Produces in packages/speculos-up/dist-build/: +# speculos-v-linux-amd64.tar.gz +# speculos-v-linux-arm64.tar.gz + +DEFAULT_VERSION="0.25.13" +PYTHON_IMAGE="python:3.11-bookworm" +DEBIAN_IMAGE="debian:bookworm-slim" + +usage() { + cat <<'EOF' +Usage: ./scripts/build-speculos-docker.sh [version] [arch...] + +Build Linux ELF Speculos binaries with PyInstaller inside Docker. + +Arguments: + version Speculos pip version (default: 0.25.13) + arch amd64 and/or arm64 (default: both) + +Examples: + ./scripts/build-speculos-docker.sh + ./scripts/build-speculos-docker.sh 0.25.13 arm64 + ./scripts/build-speculos-docker.sh 0.25.13 amd64 arm64 + +Requires Docker. On Apple Silicon, linux/arm64 builds are fast; linux/amd64 uses emulation. +EOF +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +VERSION="${1:-$DEFAULT_VERSION}" +shift || true + +ARCHS=() +if [[ $# -eq 0 ]]; then + ARCHS=(amd64 arm64) +else + for arch in "$@"; do + case "${arch}" in + amd64 | arm64) + ARCHS+=("${arch}") + ;; + *) + echo "error: unknown arch '${arch}' (expected amd64 or arm64)" >&2 + exit 1 + ;; + esac + done +fi + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +DIST_DIR="${SCRIPT_DIR}/../dist-build" +mkdir -p "${DIST_DIR}" + +if ! command -v docker >/dev/null 2>&1; then + echo "error: docker is not installed or not on PATH" >&2 + exit 1 +fi + +if ! docker info >/dev/null 2>&1; then + echo "error: Docker daemon is not running" >&2 + exit 1 +fi + +docker_platform() { + case "$1" in + amd64) echo "linux/amd64" ;; + arm64) echo "linux/arm64" ;; + esac +} + +file_arch_pattern() { + case "$1" in + amd64) echo "x86-64" ;; + arm64) echo "ARM aarch64" ;; + esac +} + +build_arch() { + local arch="$1" + local platform file_arch image + platform="$(docker_platform "${arch}")" + file_arch="$(file_arch_pattern "${arch}")" + + # PyQt6 publishes pre-built wheels for x86_64 but not always aarch64. + # For arm64 use a Debian image where we install PyQt6 from system packages + # so pip never tries to build from source. + if [[ "${arch}" == "arm64" ]]; then + image="${DEBIAN_IMAGE}" + else + image="${PYTHON_IMAGE}" + fi + + echo "[build] speculos v${VERSION} linux-${arch} (${platform})" + + if [[ "${arch}" == "arm64" ]]; then + docker run --rm \ + --platform "${platform}" \ + -v "${DIST_DIR}:/out" \ + -w /out \ + -e "SPECULOS_VERSION=${VERSION}" \ + -e "TARGET_ARCH=${arch}" \ + -e "FILE_ARCH=${file_arch}" \ + "${image}" \ + bash -c ' +set -euo pipefail +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq && apt-get install -qq -y --no-install-recommends \ + python3 python3-pip python3-venv python3-pyqt6 binutils file \ + libpython3.11-dev libgl1 libglib2.0-0 > /dev/null +python3 -m venv --system-site-packages /tmp/venv +source /tmp/venv/bin/activate +pip install --upgrade pip +pip install pyinstaller "speculos==${SPECULOS_VERSION}" --no-deps +pip install construct "flask>=2.0.0,<3.0.0" flask-restful flask-cors \ + "jsonschema>=3.2.0,<4.18.0" mnemonic "pillow>=8.0.0,<11.0.0" \ + pyelftools requests ledgered pygame altgraph packaging setuptools pyinstaller-hooks-contrib +rm -rf build dist +mkdir -p build dist +pyinstaller \ + --onefile \ + --name speculos \ + --distpath dist \ + --workpath build \ + --noupx \ + --collect-all speculos \ + "$(python3 -c "import speculos; print(speculos.__file__)")" +file dist/speculos | tee /tmp/speculos-file.txt +grep -Fq "${FILE_ARCH}" /tmp/speculos-file.txt +ARCHIVE="/out/speculos-v${SPECULOS_VERSION}-linux-${TARGET_ARCH}.tar.gz" +tar -czf "${ARCHIVE}" -C dist speculos +ls -lh "${ARCHIVE}" +' + else + docker run --rm \ + --platform "${platform}" \ + -v "${DIST_DIR}:/out" \ + -w /out \ + -e "SPECULOS_VERSION=${VERSION}" \ + -e "TARGET_ARCH=${arch}" \ + -e "FILE_ARCH=${file_arch}" \ + "${image}" \ + bash -lc ' +set -euo pipefail +pip install --upgrade pip +pip install pyinstaller "speculos==${SPECULOS_VERSION}" +rm -rf build dist +mkdir -p build dist +pyinstaller \ + --onefile \ + --name speculos \ + --distpath dist \ + --workpath build \ + --noupx \ + --collect-all speculos \ + "$(python -c "import speculos; print(speculos.__file__)")" +file dist/speculos | tee /tmp/speculos-file.txt +grep -Fq "${FILE_ARCH}" /tmp/speculos-file.txt +ARCHIVE="/out/speculos-v${SPECULOS_VERSION}-linux-${TARGET_ARCH}.tar.gz" +tar -czf "${ARCHIVE}" -C dist speculos +ls -lh "${ARCHIVE}" +' + fi + + echo "[build] Wrote ${DIST_DIR}/speculos-v${VERSION}-linux-${arch}.tar.gz" +} + +echo "[build] Output directory: ${DIST_DIR}" +for arch in "${ARCHS[@]}"; do + build_arch "${arch}" +done + +echo "[build] Done." diff --git a/packages/speculos-up/scripts/build-speculos.sh b/packages/speculos-up/scripts/build-speculos.sh new file mode 100755 index 000000000..5e5dae23b --- /dev/null +++ b/packages/speculos-up/scripts/build-speculos.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Build standalone speculos Linux binaries. +# +# On macOS (or any non-Linux host) this delegates to build-speculos-docker.sh +# so PyInstaller runs inside Linux containers for both amd64 and arm64. +# +# On Linux it builds natively for the host architecture only. +# +# Usage: ./scripts/build-speculos.sh [version] [arch...] +# +# version Speculos pip version (default: 0.25.13) +# arch amd64 and/or arm64 (macOS only; ignored on Linux) +# +# Produces in dist-build/: +# speculos-v-linux-.tar.gz + +VERSION="${1:-0.25.13}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +if [[ "$(uname -s)" != "Linux" ]]; then + echo "[build] Non-Linux host detected — delegating to Docker build" + exec "${SCRIPT_DIR}/build-speculos-docker.sh" "$@" +fi + +# --- Native Linux build (host architecture only) --- + +shift || true +if [[ $# -gt 0 ]]; then + echo "[build] Warning: arch arguments ignored on Linux (builds host arch only)" >&2 +fi + +DIST_DIR="${SCRIPT_DIR}/../dist-build" +HOST_ARCH="$(uname -m)" + +echo "[build] Building speculos v${VERSION} standalone binary (linux-${HOST_ARCH})" + +rm -rf "${DIST_DIR}" +mkdir -p "${DIST_DIR}" + +python3 -m venv "${DIST_DIR}/venv" +source "${DIST_DIR}/venv/bin/activate" +pip install --quiet "speculos==${VERSION}" pyinstaller + +pyinstaller \ + --onefile \ + --name speculos \ + --distpath "${DIST_DIR}" \ + --workpath "${DIST_DIR}/build" \ + --specpath "${DIST_DIR}" \ + --noupx \ + --collect-all speculos \ + "$(python3 -c 'import speculos; print(speculos.__file__)')" + +echo "[build] Binary created at: ${DIST_DIR}/speculos" +ls -lh "${DIST_DIR}/speculos" + +TAR_NAME="speculos-v${VERSION}-linux-${HOST_ARCH}.tar.gz" +tar -czf "${DIST_DIR}/${TAR_NAME}" -C "${DIST_DIR}" speculos + +echo "[build] Archive created at: ${DIST_DIR}/${TAR_NAME}" + +deactivate diff --git a/packages/speculos-up/src/cli.ts b/packages/speculos-up/src/cli.ts new file mode 100644 index 000000000..fab629fc1 --- /dev/null +++ b/packages/speculos-up/src/cli.ts @@ -0,0 +1,11 @@ +#!/usr/bin/env node + +import { downloadAndInstall } from '.'; +import { say } from './utils'; + +downloadAndInstall().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + say(`Error: ${message}`); + // eslint-disable-next-line no-restricted-globals + process.exit(1); +}); diff --git a/packages/speculos-up/src/download.ts b/packages/speculos-up/src/download.ts new file mode 100644 index 000000000..7d7631afc --- /dev/null +++ b/packages/speculos-up/src/download.ts @@ -0,0 +1,90 @@ +// eslint-disable-next-line import-x/no-nodejs-modules +import { request as httpRequest } from 'node:http'; +// eslint-disable-next-line import-x/no-nodejs-modules +import type { IncomingMessage } from 'node:http'; +// eslint-disable-next-line import-x/no-nodejs-modules +import { request as httpsRequest } from 'node:https'; +// eslint-disable-next-line import-x/no-nodejs-modules +import { Stream } from 'node:stream'; +// eslint-disable-next-line import-x/no-nodejs-modules +import { pipeline } from 'node:stream/promises'; + +import type { DownloadOptions } from './types'; + +/** + * A PassThrough stream that emits a 'response' event when the HTTP(S) response is available. + */ +class DownloadStream extends Stream.PassThrough { + /** + * Returns a promise that resolves with the HTTP(S) IncomingMessage response. + * + * @returns The HTTP(S) response stream. + */ + async response(): Promise { + return new Promise((resolve, reject) => { + this.once('response', resolve); + this.once('error', reject); + }); + } +} + +/** + * Start a download from the given URL. + * + * @param url - The URL to download from. + * @param options - Download options. + * @param redirects - Current redirect count. + * @returns A stream of the download. + */ +export function startDownload( + url: URL, + options: DownloadOptions = {}, + redirects: number = 0, +): DownloadStream { + const MAX_REDIRECTS = options.maxRedirects ?? 5; + const request = url.protocol === 'http:' ? httpRequest : httpsRequest; + const stream = new DownloadStream(); + request(url, options, (response) => { + stream.once('close', () => { + response.destroy(); + }); + + const { statusCode, statusMessage, headers } = response; + if ( + statusCode && + statusCode >= 300 && + statusCode < 400 && + headers.location + ) { + if (redirects >= MAX_REDIRECTS) { + stream.emit('error', new Error('Too many redirects')); + response.destroy(); + } else { + pipeline( + startDownload( + new URL(headers.location, url), + options, + redirects + 1, + ).once('response', stream.emit.bind(stream, 'response')), + stream, + ).catch(stream.emit.bind(stream, 'error')); + response.destroy(); + } + } else if (!statusCode || statusCode < 200 || statusCode >= 300) { + stream.emit( + 'error', + new Error( + `Request to ${url.toString()} failed. Status Code: ${statusCode} - ${statusMessage}`, + ), + ); + response.destroy(); + } else { + stream.emit('response', response); + response.once('error', stream.emit.bind(stream, 'error')); + pipeline(response, stream).catch(stream.emit.bind(stream, 'error')); + } + }) + .once('error', stream.emit.bind(stream, 'error')) + .end(); + return stream; +} diff --git a/packages/speculos-up/src/extract.ts b/packages/speculos-up/src/extract.ts new file mode 100644 index 000000000..a582a1bc7 --- /dev/null +++ b/packages/speculos-up/src/extract.ts @@ -0,0 +1,127 @@ +// eslint-disable-next-line import-x/no-nodejs-modules +import { createHash } from 'node:crypto'; +// eslint-disable-next-line import-x/no-nodejs-modules +import { createReadStream } from 'node:fs'; +// eslint-disable-next-line import-x/no-nodejs-modules +import { mkdir, readFile, rename, rm, stat } from 'node:fs/promises'; +// eslint-disable-next-line import-x/no-nodejs-modules +import { join } from 'node:path'; +// eslint-disable-next-line import-x/no-nodejs-modules +import { pipeline } from 'node:stream/promises'; +import { extract as extractTar } from 'tar'; + +import { startDownload } from './download'; +import type { Binary } from './types'; +import { say } from './utils'; + +/** + * Extract the speculos binary from a tar.gz archive at the given URL. + * + * @param url - The URL of the tar.gz archive. + * @param binaries - The binaries to extract. + * @param dir - The destination directory. + * @param checksums - Optional checksums for verification. + * @returns The list of extracted binary paths. + */ +export async function extractFrom( + url: URL, + binaries: Binary[], + dir: string, + checksums: { algorithm: string; binaries: Record } | null, +): Promise { + const tempDir = `${dir}.downloading`; + const rmOpts = { recursive: true, maxRetries: 3, force: true }; + try { + await rm(tempDir, rmOpts); + await mkdir(tempDir, { recursive: true }); + + say('downloading and extracting archive'); + await pipeline(startDownload(url), extractTar({ cwd: tempDir })); + + const paths: string[] = []; + for (const binary of binaries) { + const extractedPath = join(tempDir, binary); + if (checksums) { + say(`verifying checksum for ${binary}`); + const fileBuffer = await readFile(extractedPath); + const hash = createHash(checksums.algorithm) + .update(fileBuffer) + .digest('hex'); + const expected = checksums.binaries[binary]; + if (hash === expected) { + say(`checksum verified for ${binary}`); + } else { + throw new Error( + `checksum mismatch for ${binary}, expected ${expected}, got ${hash}`, + ); + } + } + paths.push(join(dir, binary)); + } + + await rm(dir, rmOpts); + await rename(tempDir, dir); + return paths; + } catch (error) { + const rmErrors = ( + await Promise.allSettled([rm(tempDir, rmOpts), rm(dir, rmOpts)]) + ) + .filter((result) => result.status === 'rejected') + .map((result) => (result as PromiseRejectedResult).reason); + + if (rmErrors.length) { + throw new Error( + `Extraction failed and cleanup also failed: ${rmErrors.map((reason) => (reason instanceof Error ? reason.message : String(reason))).join(', ')}`, + ); + } + throw error; + } +} + +/** + * Extract the speculos binary from a local tar.gz archive. + * + * @param archivePath - The absolute path to the tar.gz archive. + * @param binaries - The binaries to extract. + * @param dir - The destination directory. + * @returns The list of extracted binary paths. + */ +export async function extractFromLocal( + archivePath: string, + binaries: Binary[], + dir: string, +): Promise { + const tempDir = `${dir}.extracting`; + const rmOpts = { recursive: true, maxRetries: 3, force: true }; + try { + await rm(tempDir, rmOpts); + await mkdir(tempDir, { recursive: true }); + + say('extracting bundled archive'); + await pipeline(createReadStream(archivePath), extractTar({ cwd: tempDir })); + + const paths: string[] = []; + for (const binary of binaries) { + const extractedPath = join(tempDir, binary); + await stat(extractedPath); + paths.push(join(dir, binary)); + } + + await rm(dir, rmOpts); + await rename(tempDir, dir); + return paths; + } catch (error) { + const rmErrors = ( + await Promise.allSettled([rm(tempDir, rmOpts), rm(dir, rmOpts)]) + ) + .filter((result) => result.status === 'rejected') + .map((result) => (result as PromiseRejectedResult).reason); + + if (rmErrors.length) { + throw new Error( + `Extraction failed and cleanup also failed: ${rmErrors.map((reason) => (reason instanceof Error ? reason.message : String(reason))).join(', ')}`, + ); + } + throw error; + } +} diff --git a/packages/speculos-up/src/index.ts b/packages/speculos-up/src/index.ts new file mode 100644 index 000000000..a6f1e3674 --- /dev/null +++ b/packages/speculos-up/src/index.ts @@ -0,0 +1,308 @@ +// eslint-disable-next-line import-x/no-nodejs-modules +import { createHash } from 'node:crypto'; +// eslint-disable-next-line import-x/no-nodejs-modules +import type { Dir } from 'node:fs'; +// eslint-disable-next-line import-x/no-nodejs-modules +import { readFile } from 'node:fs/promises'; +// eslint-disable-next-line import-x/no-nodejs-modules +import { + copyFile, + mkdir, + opendir, + rm, + symlink, + unlink, +} from 'node:fs/promises'; +// eslint-disable-next-line import-x/no-nodejs-modules +import { dirname, join, relative } from 'node:path'; + +import { extractFrom, extractFromLocal } from './extract'; +import type { SpeculosupOptions } from './types'; +import { Binary, Platform } from './types'; +import { + getBinaryArchiveUrl, + getBinaryPath, + getBundledArchivePath, + getBundledChecksum, + getDefaultCacheDir, + getDefaultRepo, + getDefaultVersion, + getVersion, + isCodedError, + isInstalled, + noop, + normalizeSystemArchitecture, + say, +} from './utils'; + +let cachedPackageDir: string | undefined; + +/** + * Resolve the package root directory containing bundled/ and dist/. + * Works in both CJS and ESM contexts. + * + * @returns The package root directory, or undefined if not resolvable. + */ +function resolvePackageDir(): string | undefined { + if (cachedPackageDir) { + return cachedPackageDir; + } + try { + // eslint-disable-next-line no-restricted-globals + const utilsPath = require.resolve('./utils'); + cachedPackageDir = dirname(dirname(utilsPath)); + return cachedPackageDir; + } catch { + return undefined; + } +} + +/** + * Verify the SHA256 checksum of a bundled archive against checksums.json. + * + * @param archivePath - The path to the archive file. + * @param packageDir - The package root directory. + * @returns True if the checksum matches or no checksum is recorded. + */ +async function verifyBundledChecksum( + archivePath: string, + packageDir: string, +): Promise { + try { + const checksumsPath = join(packageDir, 'bundled', 'checksums.json'); + const checksums = JSON.parse( + await readFile(checksumsPath, 'utf8'), + ) as Record; + const expected = getBundledChecksum(archivePath, checksums); + if (!expected) { + say('no checksum on file, skipping verification'); + return true; + } + const fileBuffer = await readFile(archivePath); + const actual = createHash('sha256').update(fileBuffer).digest('hex'); + if (actual !== expected) { + say(`checksum mismatch: expected ${expected}, got ${actual}`); + return false; + } + say('bundled archive checksum verified'); + return true; + } catch { + return true; + } +} + +/** + * Resolve the installation directory for a given version and architecture. + * + * @param options - Installation options. + * @returns The cache path for this version+arch. + */ +export function getInstallDir(options: SpeculosupOptions = {}): string { + const cacheDir = options.cacheDir ?? getDefaultCacheDir(); + const version = options.version ?? getDefaultVersion(); + const resolvedArch = options.arch ?? normalizeSystemArchitecture(); + const platform = options.platform ?? Platform.Linux; + return join( + cacheDir, + `speculos-${version}-${String(platform)}-${resolvedArch}`, + ); +} + +/** + * Get the path to the installed speculos binary. + * + * @param options - Installation options. + * @returns The absolute path to the speculos binary, or `null` if not installed. + */ +export function getSpeculosBinaryPath( + options: SpeculosupOptions = {}, +): string | null { + const installDir = getInstallDir(options); + if (!isInstalled(installDir)) { + return null; + } + return getBinaryPath(installDir); +} + +/** + * Check if speculos is installed. + * + * @param options - Installation options. + * @returns True if the managed binary exists. + */ +export function isSpeculosInstalled(options: SpeculosupOptions = {}): boolean { + const installDir = getInstallDir(options); + return isInstalled(installDir); +} + +/** + * Check if binaries are already in the cache. If not, download and extract them. + * + * @param url - The URL to download from. + * @param cachePath - The cache directory. + * @param checksums - Optional checksums. + * @returns A directory handle for the cached binaries. + */ +export async function checkAndDownloadBinaries( + url: URL, + cachePath: string, + checksums?: { algorithm: string; binaries: Record } | null, +): Promise { + let downloadedBinaries: Dir; + try { + say('checking cache'); + downloadedBinaries = await opendir(cachePath); + say('found binaries in cache'); + } catch (cacheError: unknown) { + say('binaries not in cache'); + if ((cacheError as NodeJS.ErrnoException).code === 'ENOENT') { + say(`installing from ${url.toString()}`); + const platformChecksums = checksums ?? null; + await extractFrom(url, [Binary.Speculos], cachePath, platformChecksums); + downloadedBinaries = await opendir(cachePath); + } else { + throw cacheError; + } + } + return downloadedBinaries; +} + +/** + * Check if binaries are already in the cache. If not, extract from a local archive. + * + * @param archivePath - The absolute path to the local tar.gz archive. + * @param cachePath - The cache directory. + * @returns A directory handle for the cached binaries. + */ +export async function checkAndExtractLocalBinaries( + archivePath: string, + cachePath: string, +): Promise { + let extractedBinaries: Dir; + try { + say('checking cache'); + extractedBinaries = await opendir(cachePath); + say('found binaries in cache'); + } catch (cacheError: unknown) { + say('binaries not in cache'); + if ((cacheError as NodeJS.ErrnoException).code === 'ENOENT') { + say('extracting from bundled archive'); + await extractFromLocal(archivePath, [Binary.Speculos], cachePath); + extractedBinaries = await opendir(cachePath); + } else { + throw cacheError; + } + } + return extractedBinaries; +} + +/** + * Install the downloaded binaries by creating symlinks or copying files. + * + * @param downloadedBinaries - The directory containing the binaries. + * @param binDir - The target directory for installation. + * @param cachePath - The cache directory path. + */ +export async function installBinaries( + downloadedBinaries: Dir, + binDir: string, + cachePath: string, +): Promise { + for await (const file of downloadedBinaries) { + if (!file.isFile()) { + continue; + } + const target = join(file.parentPath, file.name); + const filePath = join(binDir, relative(cachePath, target)); + + const relativeTarget = relative(dirname(filePath), target); + + await mkdir(binDir, { recursive: true }); + + await unlink(filePath).catch(noop); + try { + await symlink(relativeTarget, filePath); + } catch (linkError) { + if ( + !( + isCodedError(linkError) && ['EPERM', 'EXDEV'].includes(linkError.code) + ) + ) { + throw linkError; + } + await copyFile(target, filePath); + } + say(`installed - ${getVersion(filePath).toString()}`); + } +} + +/** + * Download and install the Speculos binary. + * + * @param options - Installation options. + * @returns The path to the installed speculos binary. + */ +export async function downloadAndInstall( + options: SpeculosupOptions = {}, +): Promise { + const version = options.version ?? getDefaultVersion(); + const repo = options.repo ?? getDefaultRepo(); + const platform = options.platform ?? Platform.Linux; + const targetArch = options.arch ?? normalizeSystemArchitecture(); + const cacheDir = options.cacheDir ?? getDefaultCacheDir(); + + say(`fetching speculos v${version} for ${String(platform)} ${targetArch}`); + + const cacheKey = createHash('sha256') + .update(`speculos-v${version}-${String(platform)}-${targetArch}`) + .digest('hex'); + const cachePath = join(cacheDir, cacheKey); + + const binDir = join( + // eslint-disable-next-line no-restricted-globals + process.cwd(), + 'node_modules', + '.bin', + ); + + const packageDir = resolvePackageDir(); + const bundledArchive = packageDir + ? getBundledArchivePath(version, platform, targetArch, packageDir) + : null; + let downloadedBinaries: Dir; + + if ( + bundledArchive && + packageDir && + (await verifyBundledChecksum(bundledArchive, packageDir)) + ) { + say('using bundled binary'); + downloadedBinaries = await checkAndExtractLocalBinaries( + bundledArchive, + cachePath, + ); + } else { + const archiveUrl = getBinaryArchiveUrl(repo, version, platform, targetArch); + const url = new URL(archiveUrl); + downloadedBinaries = await checkAndDownloadBinaries(url, cachePath); + } + + await installBinaries(downloadedBinaries, binDir, cachePath); + + say('done!'); + + return getBinaryPath(cachePath); +} + +/** + * Remove a cached speculos installation. + * + * @param options - Installation options. + */ +export async function cleanCache( + options: SpeculosupOptions = {}, +): Promise { + const cacheDir = options.cacheDir ?? getDefaultCacheDir(); + await rm(cacheDir, { recursive: true, force: true }); + say('cache cleaned'); +} diff --git a/packages/speculos-up/src/speculos-up.test.ts b/packages/speculos-up/src/speculos-up.test.ts new file mode 100644 index 000000000..a81246eb4 --- /dev/null +++ b/packages/speculos-up/src/speculos-up.test.ts @@ -0,0 +1,142 @@ +import { join } from 'node:path'; + +import { getInstallDir, getSpeculosBinaryPath, isSpeculosInstalled } from '.'; +import { Architecture, Platform } from './types'; +import { + getDefaultCacheDir, + getDefaultVersion, + getDefaultRepo, + getBinaryArchiveUrl, + getBundledArchivePath, + getBundledChecksum, + normalizeSystemArchitecture, +} from './utils'; + +describe('speculos-up', () => { + describe('getDefaultVersion', () => { + it('returns a version string', () => { + const version = getDefaultVersion(); + expect(version).toMatch(/^\d+\.\d+\.\d+$/u); + }); + }); + + describe('getDefaultRepo', () => { + it('returns MetaMask/accounts', () => { + expect(getDefaultRepo()).toBe('MetaMask/accounts'); + }); + }); + + describe('normalizeSystemArchitecture', () => { + it('returns Arm64 for arm64 input', () => { + expect(normalizeSystemArchitecture('arm64')).toBe(Architecture.Arm64); + }); + + it('returns Amd64 for x64 input', () => { + expect(normalizeSystemArchitecture('x64')).toBe(Architecture.Amd64); + }); + }); + + describe('getDefaultCacheDir', () => { + it('returns a path containing speculos-up', () => { + expect(getDefaultCacheDir()).toContain('speculos-up'); + }); + }); + + describe('getBinaryArchiveUrl', () => { + it('generates the correct URL', () => { + const url = getBinaryArchiveUrl( + 'MetaMask/accounts', + '0.25.13', + Platform.Linux, + 'amd64', + ); + expect(url).toBe( + 'https://github.com/MetaMask/accounts/releases/download/speculos-v0.25.13/speculos-v0.25.13-linux-amd64.tar.gz', + ); + }); + }); + + describe('getBundledChecksum', () => { + it('looks up checksums by archive filename, not full path', () => { + const checksums = { + 'speculos-v0.25.13-linux-amd64.tar.gz': 'abc123', + }; + expect( + getBundledChecksum( + '/some/package/bundled/speculos-v0.25.13-linux-amd64.tar.gz', + checksums, + ), + ).toBe('abc123'); + }); + + it('returns undefined when no checksum exists for the archive', () => { + expect( + getBundledChecksum('/path/speculos-v99.tar.gz', {}), + ).toBeUndefined(); + }); + }); + + describe('getBundledArchivePath', () => { + it('returns a path for a bundled archive that exists', () => { + const packageDir = join(__dirname, '..'); + const result = getBundledArchivePath( + '0.25.13', + Platform.Linux, + Architecture.Amd64, + packageDir, + ); + expect(result).toMatch( + /bundled\/speculos-v0\.25\.13-linux-amd64\.tar\.gz$/u, + ); + }); + + it('returns null for a version that has no bundle', () => { + const packageDir = join(__dirname, '..'); + const result = getBundledArchivePath( + '99.99.99', + Platform.Linux, + Architecture.Amd64, + packageDir, + ); + expect(result).toBeNull(); + }); + }); + + describe('getInstallDir', () => { + it('includes version, platform, and arch', () => { + const dir = getInstallDir(); + expect(dir).toContain('speculos-0.25.13'); + expect(dir).toContain('linux'); + }); + + it('uses custom cache dir', () => { + const dir = getInstallDir({ + cacheDir: '/tmp/test', + arch: Architecture.Amd64, + }); + expect(dir).toBe('/tmp/test/speculos-0.25.13-linux-amd64'); + }); + + it('uses custom version and arch', () => { + const dir = getInstallDir({ version: '1.0.0', arch: Architecture.Arm64 }); + expect(dir).toContain('speculos-1.0.0-linux-arm64'); + }); + }); + + describe('getSpeculosBinaryPath', () => { + it('returns null when the managed binary is not installed', () => { + const options = { cacheDir: '/tmp/nonexistent-speculos-up-test' }; + expect(isSpeculosInstalled(options)).toBe(false); + expect(getSpeculosBinaryPath(options)).toBeNull(); + }); + }); + + describe('isSpeculosInstalled', () => { + it('returns false for non-existent path', () => { + const result = isSpeculosInstalled({ + cacheDir: '/tmp/nonexistent-speculos-up-test', + }); + expect(result).toBe(false); + }); + }); +}); diff --git a/packages/speculos-up/src/types.ts b/packages/speculos-up/src/types.ts new file mode 100644 index 000000000..c208272b3 --- /dev/null +++ b/packages/speculos-up/src/types.ts @@ -0,0 +1,31 @@ +export enum Architecture { + Amd64 = 'amd64', + Arm64 = 'arm64', +} + +export enum Platform { + Linux = 'linux', +} + +export enum Binary { + Speculos = 'speculos', +} + +export type DownloadOptions = { + method?: 'GET' | 'HEAD'; + headers?: Record; + maxRedirects?: number; +}; + +export type SpeculosupOptions = { + /** Speculos version to install. */ + version?: string; + /** GitHub repository hosting releases. */ + repo?: string; + /** Custom cache directory. */ + cacheDir?: string; + /** Target platform. */ + platform?: Platform; + /** Target architecture. */ + arch?: Architecture; +}; diff --git a/packages/speculos-up/src/utils.ts b/packages/speculos-up/src/utils.ts new file mode 100644 index 000000000..073e3494b --- /dev/null +++ b/packages/speculos-up/src/utils.ts @@ -0,0 +1,184 @@ +// eslint-disable-next-line import-x/no-nodejs-modules +import { execFileSync } from 'node:child_process'; +// eslint-disable-next-line import-x/no-nodejs-modules +import { existsSync } from 'node:fs'; +// eslint-disable-next-line import-x/no-nodejs-modules +import { arch } from 'node:os'; +// eslint-disable-next-line import-x/no-nodejs-modules +import { homedir } from 'node:os'; +// eslint-disable-next-line import-x/no-nodejs-modules +import { basename, join } from 'node:path'; + +import { Architecture } from './types'; +import type { Platform } from './types'; + +const DEFAULT_VERSION = '0.25.13'; +const DEFAULT_REPO = 'MetaMask/accounts'; + +/** + * No-op function. + * + * @returns undefined. + */ +export const noop = (): undefined => undefined; + +/** + * Get the default Speculos version. + * + * @returns The version string. + */ +export function getDefaultVersion(): string { + return DEFAULT_VERSION; +} + +/** + * Get the default GitHub repo hosting speculos releases. + * + * @returns The repo string. + */ +export function getDefaultRepo(): string { + return DEFAULT_REPO; +} + +/** + * Normalize the system architecture. + * + * @param architecture - The architecture string. + * @returns The normalized architecture. + */ +export function normalizeSystemArchitecture( + architecture: string = arch(), +): Architecture { + if (architecture.startsWith('arm')) { + return Architecture.Arm64; + } + return Architecture.Amd64; +} + +/** + * Get the default cache directory. + * + * @returns The cache directory path. + */ +export function getDefaultCacheDir(): string { + return join(homedir(), '.cache', 'metamask', 'speculos-up'); +} + +/** + * Log a message with the speculos-up prefix. + * + * @param message - The message to log. + */ +export function say(message: string): void { + console.log(`[speculos-up] ${message}`); +} + +/** + * Get the version of the binary at the given path. + * + * @param binPath - Path to the binary. + * @returns The version output. + */ +export function getVersion(binPath: string): Buffer { + try { + return execFileSync(binPath, ['--version']).subarray(0, -1); + } catch (error: unknown) { + const versionError = `Failed to get version for ${binPath}`; + if (error instanceof Error) { + error.message = `${versionError}\n\n${error.message}`; + throw error; + } + throw new Error(`${versionError}: ${String(error)}`); + } +} + +/** + * Check if an error has a code property. + * + * @param error - The error to check. + * @returns True if the error has a code property. + */ +export function isCodedError( + error: unknown, +): error is Error & { code: string } { + return ( + error instanceof Error && 'code' in error && typeof error.code === 'string' + ); +} + +/** + * Generate the URL for downloading the Speculos binary archive. + * + * @param repo - The GitHub repository. + * @param version - The version string. + * @param platform - The target platform. + * @param targetArch - The target architecture. + * @returns The download URL. + */ +export function getBinaryArchiveUrl( + repo: string, + version: string, + platform: Platform, + targetArch: string, +): string { + return `https://github.com/${repo}/releases/download/speculos-v${version}/speculos-v${version}-${String(platform)}-${targetArch}.tar.gz`; +} + +/** + * Get the path to the speculos binary in the given install directory. + * + * @param installDir - The directory where the binary was extracted. + * @returns The absolute path to the speculos binary. + */ +export function getBinaryPath(installDir: string): string { + return join(installDir, 'speculos'); +} + +/** + * Check if speculos is already installed at the given path. + * + * @param installDir - The install directory. + * @returns True if the binary exists. + */ +export function isInstalled(installDir: string): boolean { + return existsSync(getBinaryPath(installDir)); +} + +/** + * Get the path to a bundled speculos archive, if one exists for the given + * platform and architecture. + * + * Bundled archives are pre-packaged tar.gz files in the `bundled/` directory + * next to the compiled `dist/` output. They allow the binary to be used + * without a network download. + * + * @param version - The speculos version. + * @param platform - The target platform. + * @param targetArch - The target architecture. + * @param packageDir - The package root directory containing bundled/. + * @returns The absolute path to the bundled archive, or `null` if not found. + */ +export function getBundledArchivePath( + version: string, + platform: Platform, + targetArch: string, + packageDir: string, +): string | null { + const fileName = `speculos-v${version}-${String(platform)}-${targetArch}.tar.gz`; + const archivePath = join(packageDir, 'bundled', fileName); + return existsSync(archivePath) ? archivePath : null; +} + +/** + * Get the SHA256 checksum for a bundled archive. + * + * @param archivePath - The path to the archive. + * @param checksums - The checksums map. + * @returns The expected checksum, or undefined if not found. + */ +export function getBundledChecksum( + archivePath: string, + checksums: Record, +): string | undefined { + return checksums[basename(archivePath)]; +} diff --git a/packages/speculos-up/tsconfig.build.json b/packages/speculos-up/tsconfig.build.json new file mode 100644 index 000000000..4bf29628a --- /dev/null +++ b/packages/speculos-up/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "dist", + "rootDir": "src", + "skipLibCheck": true, + "paths": {} + }, + "include": ["./src/**/*.ts"], + "exclude": ["./src/**/*.test.ts", "./src/**/*.test-d.ts"] +} diff --git a/packages/speculos-up/tsconfig.json b/packages/speculos-up/tsconfig.json new file mode 100644 index 000000000..0842f4dd7 --- /dev/null +++ b/packages/speculos-up/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { "baseUrl": "./" }, + "include": ["./src"], + "exclude": ["./dist/**/*"] +} diff --git a/tsconfig.build.json b/tsconfig.build.json index 29029bd74..8e184ef82 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -15,7 +15,9 @@ { "path": "./packages/keyring-snap-client/tsconfig.build.json" }, { "path": "./packages/keyring-sdk/tsconfig.build.json" }, { "path": "./packages/keyring-snap-sdk/tsconfig.build.json" }, - { "path": "./packages/keyring-utils/tsconfig.build.json" } + { "path": "./packages/keyring-utils/tsconfig.build.json" }, + { "path": "./packages/speculos-up/tsconfig.build.json" }, + { "path": "./packages/hw-emulator/tsconfig.build.json" } ], "files": [], "include": [] diff --git a/tsconfig.json b/tsconfig.json index aa0a2a8db..7640f4c99 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,9 @@ { "path": "./packages/keyring-snap-client" }, { "path": "./packages/keyring-sdk" }, { "path": "./packages/keyring-snap-sdk" }, - { "path": "./packages/keyring-utils" } + { "path": "./packages/keyring-utils" }, + { "path": "./packages/speculos-up" }, + { "path": "./packages/hw-emulator" } ], "files": [], "include": [] diff --git a/yarn.lock b/yarn.lock index 321cdb678..90198b8e9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -918,6 +918,15 @@ __metadata: languageName: node linkType: hard +"@isaacs/fs-minipass@npm:^4.0.0": + version: 4.0.1 + resolution: "@isaacs/fs-minipass@npm:4.0.1" + dependencies: + minipass: "npm:^7.0.4" + checksum: 10/4412e9e6713c89c1e66d80bb0bb5a2a93192f10477623a27d08f228ba0316bb880affabc5bfe7f838f58a34d26c2c190da726e576cdfc18c49a72e89adabdcf5 + languageName: node + linkType: hard + "@istanbuljs/load-nyc-config@npm:^1.0.0": version: 1.1.0 resolution: "@istanbuljs/load-nyc-config@npm:1.1.0" @@ -1339,6 +1348,18 @@ __metadata: languageName: node linkType: hard +"@ledgerhq/devices@npm:^7.0.0": + version: 7.0.7 + resolution: "@ledgerhq/devices@npm:7.0.7" + dependencies: + "@ledgerhq/errors": "npm:^6.12.3" + "@ledgerhq/logs": "npm:^6.10.1" + rxjs: "npm:6" + semver: "npm:^7.3.5" + checksum: 10/b243a2d1dd41a6d3dba7427866bebb2be8351348df765c12354cb46f0b24ce278cae3724f5c591e96b3804c1bf2ed5512e75f07f0a210c9856e906b808e8b907 + languageName: node + linkType: hard + "@ledgerhq/devices@npm:^8.4.4": version: 8.4.4 resolution: "@ledgerhq/devices@npm:8.4.4" @@ -1366,10 +1387,10 @@ __metadata: languageName: node linkType: hard -"@ledgerhq/errors@npm:^6.19.1": - version: 6.19.1 - resolution: "@ledgerhq/errors@npm:6.19.1" - checksum: 10/8265c6d73c314a4aabbe060ec29e2feebb4e904fe811bf7a9c53cde08e713dcbceded9d927ebb2f0ffc47a7b16524379d4a7e9aa3d61945b8a832be7cd5cf69b +"@ledgerhq/errors@npm:^6.12.3, @ledgerhq/errors@npm:^6.19.1": + version: 6.35.0 + resolution: "@ledgerhq/errors@npm:6.35.0" + checksum: 10/1bbc4a314d22aca480a5433c9d4b5aded9907c86073ac8678c3f6bb50b6627973f12a5f93040993bf313aed8e8db72ce67758f5ae7b51b5302d8a05cf8055a15 languageName: node linkType: hard @@ -1442,10 +1463,10 @@ __metadata: languageName: node linkType: hard -"@ledgerhq/logs@npm:^6.12.0": - version: 6.12.0 - resolution: "@ledgerhq/logs@npm:6.12.0" - checksum: 10/a0a01f5d6edb0c14e7a42d24ab67ce362219517f6a50d0572c917f4f7988a1e2e9363e3d0fb170fe267f054e1e30a111564de44276e01c538b258d902c546421 +"@ledgerhq/logs@npm:^6.10.1, @ledgerhq/logs@npm:^6.12.0": + version: 6.17.0 + resolution: "@ledgerhq/logs@npm:6.17.0" + checksum: 10/81332b4db29e1e76ee24f8d741d30152b5c0676e58feedebfde93c7ef4bd641813596d9402d734af779fe3cb6e28599b91b6e6f57ac0017d1231f7d2dad49404 languageName: node linkType: hard @@ -2039,6 +2060,37 @@ __metadata: languageName: node linkType: hard +"@metamask/hw-emulator@workspace:packages/hw-emulator": + version: 0.0.0-use.local + resolution: "@metamask/hw-emulator@workspace:packages/hw-emulator" + dependencies: + "@lavamoat/allow-scripts": "npm:^3.2.1" + "@lavamoat/preinstall-always-fail": "npm:^2.1.0" + "@ledgerhq/devices": "npm:^7.0.0" + "@metamask/auto-changelog": "npm:^6.1.0" + "@ts-bridge/cli": "npm:^0.6.3" + "@types/jest": "npm:^29.5.12" + "@types/node": "npm:^20.12.12" + "@types/ws": "npm:^8.18.1" + deepmerge: "npm:^4.2.2" + depcheck: "npm:^1.4.7" + jest: "npm:^29.5.0" + jest-it-up: "npm:^3.1.0" + rimraf: "npm:^5.0.7" + ts-jest: "npm:^29.0.5" + ts-node: "npm:^10.9.2" + tsd: "npm:^0.31.0" + typedoc: "npm:^0.25.13" + typescript: "npm:~5.3.3" + ws: "npm:^8.18.0" + peerDependencies: + "@metamask/speculos-up": ^0.1.0 + peerDependenciesMeta: + "@metamask/speculos-up": + optional: true + languageName: unknown + linkType: soft + "@metamask/hw-wallet-sdk@npm:^0.8.0, @metamask/hw-wallet-sdk@workspace:packages/hw-wallet-sdk": version: 0.0.0-use.local resolution: "@metamask/hw-wallet-sdk@workspace:packages/hw-wallet-sdk" @@ -2592,6 +2644,32 @@ __metadata: languageName: node linkType: hard +"@metamask/speculos-up@workspace:packages/speculos-up": + version: 0.0.0-use.local + resolution: "@metamask/speculos-up@workspace:packages/speculos-up" + dependencies: + "@lavamoat/allow-scripts": "npm:^3.2.1" + "@lavamoat/preinstall-always-fail": "npm:^2.1.0" + "@metamask/auto-changelog": "npm:^6.1.0" + "@ts-bridge/cli": "npm:^0.6.3" + "@types/jest": "npm:^29.5.12" + "@types/node": "npm:^20.12.12" + deepmerge: "npm:^4.2.2" + depcheck: "npm:^1.4.7" + jest: "npm:^29.5.0" + jest-it-up: "npm:^3.1.0" + rimraf: "npm:^5.0.7" + tar: "npm:^7.4.3" + ts-jest: "npm:^29.0.5" + ts-node: "npm:^10.9.2" + tsd: "npm:^0.31.0" + typedoc: "npm:^0.25.13" + typescript: "npm:~5.3.3" + bin: + mm-speculos-up: ./dist/cli.mjs + languageName: unknown + linkType: soft + "@metamask/storage-service@npm:^1.0.1": version: 1.0.1 resolution: "@metamask/storage-service@npm:1.0.1" @@ -4651,6 +4729,15 @@ __metadata: languageName: node linkType: hard +"@types/ws@npm:^8.18.1": + version: 8.18.1 + resolution: "@types/ws@npm:8.18.1" + dependencies: + "@types/node": "npm:*" + checksum: 10/1ce05e3174dcacf28dae0e9b854ef1c9a12da44c7ed73617ab6897c5cbe4fccbb155a20be5508ae9a7dde2f83bd80f5cf3baa386b934fc4b40889ec963e94f3a + languageName: node + linkType: hard + "@types/yargs-parser@npm:*": version: 15.0.0 resolution: "@types/yargs-parser@npm:15.0.0" @@ -5161,16 +5248,6 @@ __metadata: languageName: node linkType: hard -"array-buffer-byte-length@npm:^1.0.1, array-buffer-byte-length@npm:^1.0.2": - version: 1.0.2 - resolution: "array-buffer-byte-length@npm:1.0.2" - dependencies: - call-bound: "npm:^1.0.3" - is-array-buffer: "npm:^3.0.5" - checksum: 10/0ae3786195c3211b423e5be8dd93357870e6fb66357d81da968c2c39ef43583ef6eece1f9cb1caccdae4806739c65dea832b44b8593414313cd76a89795fca63 - languageName: node - linkType: hard - "array-differ@npm:^3.0.0": version: 3.0.0 resolution: "array-differ@npm:3.0.0" @@ -5192,33 +5269,6 @@ __metadata: languageName: node linkType: hard -"array.prototype.flatmap@npm:^1.3.3": - version: 1.3.3 - resolution: "array.prototype.flatmap@npm:1.3.3" - dependencies: - call-bind: "npm:^1.0.8" - define-properties: "npm:^1.2.1" - es-abstract: "npm:^1.23.5" - es-shim-unscopables: "npm:^1.0.2" - checksum: 10/473534573aa4b37b1d80705d0ce642f5933cccf5617c9f3e8a56686e9815ba93d469138e86a1f25d2fe8af999c3d24f54d703ec1fc2db2e6778d46d0f4ac951e - languageName: node - linkType: hard - -"arraybuffer.prototype.slice@npm:^1.0.4": - version: 1.0.4 - resolution: "arraybuffer.prototype.slice@npm:1.0.4" - dependencies: - array-buffer-byte-length: "npm:^1.0.1" - call-bind: "npm:^1.0.8" - define-properties: "npm:^1.2.1" - es-abstract: "npm:^1.23.5" - es-errors: "npm:^1.3.0" - get-intrinsic: "npm:^1.2.6" - is-array-buffer: "npm:^3.0.4" - checksum: 10/4821ebdfe7d699f910c7f09bc9fa996f09b96b80bccb4f5dd4b59deae582f6ad6e505ecef6376f8beac1eda06df2dbc89b70e82835d104d6fcabd33c1aed1ae9 - languageName: node - linkType: hard - "arrify@npm:^1.0.1": version: 1.0.1 resolution: "arrify@npm:1.0.1" @@ -6012,6 +6062,13 @@ __metadata: languageName: node linkType: hard +"chownr@npm:^3.0.0": + version: 3.0.0 + resolution: "chownr@npm:3.0.0" + checksum: 10/b63cb1f73d171d140a2ed8154ee6566c8ab775d3196b0e03a2a94b5f6a0ce7777ee5685ca56849403c8d17bd457a6540672f9a60696a6137c7a409097495b82c + languageName: node + linkType: hard + "ci-info@npm:^2.0.0": version: 2.0.0 resolution: "ci-info@npm:2.0.0" @@ -6379,39 +6436,6 @@ __metadata: languageName: node linkType: hard -"data-view-buffer@npm:^1.0.2": - version: 1.0.2 - resolution: "data-view-buffer@npm:1.0.2" - dependencies: - call-bound: "npm:^1.0.3" - es-errors: "npm:^1.3.0" - is-data-view: "npm:^1.0.2" - checksum: 10/c10b155a4e93999d3a215d08c23eea95f865e1f510b2e7748fcae1882b776df1afe8c99f483ace7fc0e5a3193ab08da138abebc9829d12003746c5a338c4d644 - languageName: node - linkType: hard - -"data-view-byte-length@npm:^1.0.2": - version: 1.0.2 - resolution: "data-view-byte-length@npm:1.0.2" - dependencies: - call-bound: "npm:^1.0.3" - es-errors: "npm:^1.3.0" - is-data-view: "npm:^1.0.2" - checksum: 10/2a47055fcf1ab3ec41b00b6f738c6461a841391a643c9ed9befec1117c1765b4d492661d97fb7cc899200c328949dca6ff189d2c6537d96d60e8a02dfe3c95f7 - languageName: node - linkType: hard - -"data-view-byte-offset@npm:^1.0.1": - version: 1.0.1 - resolution: "data-view-byte-offset@npm:1.0.1" - dependencies: - call-bound: "npm:^1.0.2" - es-errors: "npm:^1.3.0" - is-data-view: "npm:^1.0.1" - checksum: 10/fa3bdfa0968bea6711ee50375094b39f561bce3f15f9e558df59de9c25f0bdd4cddc002d9c1d70ac7772ebd36854a7e22d1761e7302a934e6f1c2263bcf44aa2 - languageName: node - linkType: hard - "debug@npm:2.6.9": version: 2.6.9 resolution: "debug@npm:2.6.9" @@ -6717,7 +6741,7 @@ __metadata: languageName: node linkType: hard -"dunder-proto@npm:^1.0.0, dunder-proto@npm:^1.0.1": +"dunder-proto@npm:^1.0.1": version: 1.0.1 resolution: "dunder-proto@npm:1.0.1" dependencies: @@ -6861,68 +6885,6 @@ __metadata: languageName: node linkType: hard -"es-abstract@npm:^1.23.5, es-abstract@npm:^1.23.9": - version: 1.24.2 - resolution: "es-abstract@npm:1.24.2" - dependencies: - array-buffer-byte-length: "npm:^1.0.2" - arraybuffer.prototype.slice: "npm:^1.0.4" - available-typed-arrays: "npm:^1.0.7" - call-bind: "npm:^1.0.8" - call-bound: "npm:^1.0.4" - data-view-buffer: "npm:^1.0.2" - data-view-byte-length: "npm:^1.0.2" - data-view-byte-offset: "npm:^1.0.1" - es-define-property: "npm:^1.0.1" - es-errors: "npm:^1.3.0" - es-object-atoms: "npm:^1.1.1" - es-set-tostringtag: "npm:^2.1.0" - es-to-primitive: "npm:^1.3.0" - function.prototype.name: "npm:^1.1.8" - get-intrinsic: "npm:^1.3.0" - get-proto: "npm:^1.0.1" - get-symbol-description: "npm:^1.1.0" - globalthis: "npm:^1.0.4" - gopd: "npm:^1.2.0" - has-property-descriptors: "npm:^1.0.2" - has-proto: "npm:^1.2.0" - has-symbols: "npm:^1.1.0" - hasown: "npm:^2.0.2" - internal-slot: "npm:^1.1.0" - is-array-buffer: "npm:^3.0.5" - is-callable: "npm:^1.2.7" - is-data-view: "npm:^1.0.2" - is-negative-zero: "npm:^2.0.3" - is-regex: "npm:^1.2.1" - is-set: "npm:^2.0.3" - is-shared-array-buffer: "npm:^1.0.4" - is-string: "npm:^1.1.1" - is-typed-array: "npm:^1.1.15" - is-weakref: "npm:^1.1.1" - math-intrinsics: "npm:^1.1.0" - object-inspect: "npm:^1.13.4" - object-keys: "npm:^1.1.1" - object.assign: "npm:^4.1.7" - own-keys: "npm:^1.0.1" - regexp.prototype.flags: "npm:^1.5.4" - safe-array-concat: "npm:^1.1.3" - safe-push-apply: "npm:^1.0.0" - safe-regex-test: "npm:^1.1.0" - set-proto: "npm:^1.0.0" - stop-iteration-iterator: "npm:^1.1.0" - string.prototype.trim: "npm:^1.2.10" - string.prototype.trimend: "npm:^1.0.9" - string.prototype.trimstart: "npm:^1.0.8" - typed-array-buffer: "npm:^1.0.3" - typed-array-byte-length: "npm:^1.0.3" - typed-array-byte-offset: "npm:^1.0.4" - typed-array-length: "npm:^1.0.7" - unbox-primitive: "npm:^1.1.0" - which-typed-array: "npm:^1.1.19" - checksum: 10/e2c97263d87b7faf65102d887074af421db7e48cd92b8b3cd308216cdd2547b647e8f61bf51429bdb13adc463bb7f421989544cbfd2e7f7469ef7a69ae8a4205 - languageName: node - linkType: hard - "es-define-property@npm:^1.0.0, es-define-property@npm:^1.0.1": version: 1.0.1 resolution: "es-define-property@npm:1.0.1" @@ -6958,26 +6920,6 @@ __metadata: languageName: node linkType: hard -"es-shim-unscopables@npm:^1.0.2": - version: 1.1.0 - resolution: "es-shim-unscopables@npm:1.1.0" - dependencies: - hasown: "npm:^2.0.2" - checksum: 10/c351f586c30bbabc62355be49564b2435468b52c3532b8a1663672e3d10dc300197e69c247869dd173e56d86423ab95fc0c10b0939cdae597094e0fdca078cba - languageName: node - linkType: hard - -"es-to-primitive@npm:^1.3.0": - version: 1.3.0 - resolution: "es-to-primitive@npm:1.3.0" - dependencies: - is-callable: "npm:^1.2.7" - is-date-object: "npm:^1.0.5" - is-symbol: "npm:^1.0.4" - checksum: 10/17faf35c221aad59a16286cbf58ef6f080bf3c485dff202c490d074d8e74da07884e29b852c245d894eac84f73c58330ec956dfd6d02c0b449d75eb1012a3f9b - languageName: node - linkType: hard - "escalade@npm:^3.1.1, escalade@npm:^3.1.2": version: 3.1.2 resolution: "escalade@npm:3.1.2" @@ -7063,13 +7005,13 @@ __metadata: linkType: hard "eslint-import-resolver-node@npm:^0.3.9": - version: 0.3.10 - resolution: "eslint-import-resolver-node@npm:0.3.10" + version: 0.3.9 + resolution: "eslint-import-resolver-node@npm:0.3.9" dependencies: debug: "npm:^3.2.7" - is-core-module: "npm:^2.16.1" - resolve: "npm:^2.0.0-next.6" - checksum: 10/f0ad564d345fc53076b46f726b6f9ba96a40d1b7cb33d515ea89d41d1dba37db4ff9b864550608756c2ba061c9e243bf10b920d975848616d0c6c4474f4ea415 + is-core-module: "npm:^2.13.0" + resolve: "npm:^1.22.4" + checksum: 10/d52e08e1d96cf630957272e4f2644dcfb531e49dcfd1edd2e07e43369eb2ec7a7d4423d417beee613201206ff2efa4eb9a582b5825ee28802fc7c71fcd53ca83 languageName: node linkType: hard @@ -7944,7 +7886,7 @@ __metadata: languageName: node linkType: hard -"for-each@npm:^0.3.3, for-each@npm:^0.3.5": +"for-each@npm:^0.3.5": version: 0.3.5 resolution: "for-each@npm:0.3.5" dependencies: @@ -7964,13 +7906,15 @@ __metadata: linkType: hard "form-data@npm:^4.0.0": - version: 4.0.0 - resolution: "form-data@npm:4.0.0" + version: 4.0.5 + resolution: "form-data@npm:4.0.5" dependencies: asynckit: "npm:^0.4.0" combined-stream: "npm:^1.0.8" + es-set-tostringtag: "npm:^2.1.0" + hasown: "npm:^2.0.2" mime-types: "npm:^2.1.12" - checksum: 10/7264aa760a8cf09482816d8300f1b6e2423de1b02bba612a136857413fdc96d7178298ced106817655facc6b89036c6e12ae31c9eb5bdc16aabf502ae8a5d805 + checksum: 10/52ecd6e927c8c4e215e68a7ad5e0f7c1031397439672fd9741654b4a94722c4182e74cc815b225dcb5be3f4180f36428f67c6dd39eaa98af0dcfdd26c00c19cd languageName: node linkType: hard @@ -8039,27 +7983,6 @@ __metadata: languageName: node linkType: hard -"function.prototype.name@npm:^1.1.6, function.prototype.name@npm:^1.1.8": - version: 1.1.8 - resolution: "function.prototype.name@npm:1.1.8" - dependencies: - call-bind: "npm:^1.0.8" - call-bound: "npm:^1.0.3" - define-properties: "npm:^1.2.1" - functions-have-names: "npm:^1.2.3" - hasown: "npm:^2.0.2" - is-callable: "npm:^1.2.7" - checksum: 10/25b9e5bea936732a6f0c0c08db58cc0d609ac1ed458c6a07ead46b32e7b9bf3fe5887796c3f83d35994efbc4fdde81c08ac64135b2c399b8f2113968d44082bc - languageName: node - linkType: hard - -"functions-have-names@npm:^1.2.3": - version: 1.2.3 - resolution: "functions-have-names@npm:1.2.3" - checksum: 10/0ddfd3ed1066a55984aaecebf5419fbd9344a5c38dd120ffb0739fac4496758dcf371297440528b115e4367fc46e3abc86a2cc0ff44612181b175ae967a11a05 - languageName: node - linkType: hard - "gauge@npm:^4.0.3": version: 4.0.4 resolution: "gauge@npm:4.0.4" @@ -8097,7 +8020,7 @@ __metadata: languageName: node linkType: hard -"get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6, get-intrinsic@npm:^1.2.7, get-intrinsic@npm:^1.3.0": +"get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6, get-intrinsic@npm:^1.3.0": version: 1.3.1 resolution: "get-intrinsic@npm:1.3.1" dependencies: @@ -8163,17 +8086,6 @@ __metadata: languageName: node linkType: hard -"get-symbol-description@npm:^1.1.0": - version: 1.1.0 - resolution: "get-symbol-description@npm:1.1.0" - dependencies: - call-bound: "npm:^1.0.3" - es-errors: "npm:^1.3.0" - get-intrinsic: "npm:^1.2.6" - checksum: 10/a353e3a9595a74720b40fb5bae3ba4a4f826e186e83814d93375182384265676f59e49998b9cdfac4a2225ce95a3d32a68f502a2c5619303987f1c183ab80494 - languageName: node - linkType: hard - "get-tsconfig@npm:^4.7.3, get-tsconfig@npm:^4.7.5, get-tsconfig@npm:^4.8.1": version: 4.14.0 resolution: "get-tsconfig@npm:4.14.0" @@ -8296,16 +8208,6 @@ __metadata: languageName: node linkType: hard -"globalthis@npm:^1.0.4": - version: 1.0.4 - resolution: "globalthis@npm:1.0.4" - dependencies: - define-properties: "npm:^1.2.1" - gopd: "npm:^1.0.1" - checksum: 10/1f1fd078fb2f7296306ef9dd51019491044ccf17a59ed49d375b576ca108ff37e47f3d29aead7add40763574a992f16a5367dd1e2173b8634ef18556ab719ac4 - languageName: node - linkType: hard - "globby@npm:^11.0.1": version: 11.1.0 resolution: "globby@npm:11.1.0" @@ -8361,13 +8263,6 @@ __metadata: languageName: node linkType: hard -"has-bigints@npm:^1.0.2": - version: 1.1.0 - resolution: "has-bigints@npm:1.1.0" - checksum: 10/90fb1b24d40d2472bcd1c8bd9dd479037ec240215869bdbff97b2be83acef57d28f7e96bdd003a21bed218d058b49097f4acc8821c05b1629cc5d48dd7bfcccd - languageName: node - linkType: hard - "has-flag@npm:^4.0.0": version: 4.0.0 resolution: "has-flag@npm:4.0.0" @@ -8384,15 +8279,6 @@ __metadata: languageName: node linkType: hard -"has-proto@npm:^1.2.0": - version: 1.2.0 - resolution: "has-proto@npm:1.2.0" - dependencies: - dunder-proto: "npm:^1.0.0" - checksum: 10/7eaed07728eaa28b77fadccabce53f30de467ff186a766872669a833ac2e87d8922b76a22cc58339d7e0277aefe98d6d00762113b27a97cdf65adcf958970935 - languageName: node - linkType: hard - "has-symbols@npm:^1.0.3, has-symbols@npm:^1.1.0": version: 1.1.0 resolution: "has-symbols@npm:1.1.0" @@ -8739,17 +8625,6 @@ __metadata: languageName: node linkType: hard -"internal-slot@npm:^1.1.0": - version: 1.1.0 - resolution: "internal-slot@npm:1.1.0" - dependencies: - es-errors: "npm:^1.3.0" - hasown: "npm:^2.0.2" - side-channel: "npm:^1.1.0" - checksum: 10/1d5219273a3dab61b165eddf358815eefc463207db33c20fcfca54717da02e3f492003757721f972fd0bf21e4b426cab389c5427b99ceea4b8b670dc88ee6d4a - languageName: node - linkType: hard - "ip-address@npm:^9.0.5": version: 9.0.5 resolution: "ip-address@npm:9.0.5" @@ -8784,17 +8659,6 @@ __metadata: languageName: node linkType: hard -"is-array-buffer@npm:^3.0.4, is-array-buffer@npm:^3.0.5": - version: 3.0.5 - resolution: "is-array-buffer@npm:3.0.5" - dependencies: - call-bind: "npm:^1.0.8" - call-bound: "npm:^1.0.3" - get-intrinsic: "npm:^1.2.6" - checksum: 10/ef1095c55b963cd0dcf6f88a113e44a0aeca91e30d767c475e7d746d28d1195b10c5076b94491a7a0cd85020ca6a4923070021d74651d093dc909e9932cf689b - languageName: node - linkType: hard - "is-arrayish@npm:^0.2.1": version: 0.2.1 resolution: "is-arrayish@npm:0.2.1" @@ -8802,38 +8666,6 @@ __metadata: languageName: node linkType: hard -"is-async-function@npm:^2.0.0": - version: 2.1.1 - resolution: "is-async-function@npm:2.1.1" - dependencies: - async-function: "npm:^1.0.0" - call-bound: "npm:^1.0.3" - get-proto: "npm:^1.0.1" - has-tostringtag: "npm:^1.0.2" - safe-regex-test: "npm:^1.1.0" - checksum: 10/7c2ac7efdf671e03265e74a043bcb1c0a32e226bc2a42dfc5ec8644667df668bbe14b91c08e6c1414f392f8cf86cd1d489b3af97756e2c7a49dd1ba63fd40ca6 - languageName: node - linkType: hard - -"is-bigint@npm:^1.1.0": - version: 1.1.0 - resolution: "is-bigint@npm:1.1.0" - dependencies: - has-bigints: "npm:^1.0.2" - checksum: 10/10cf327310d712fe227cfaa32d8b11814c214392b6ac18c827f157e1e85363cf9c8e2a22df526689bd5d25e53b58cc110894787afb54e138e7c504174dba15fd - languageName: node - linkType: hard - -"is-boolean-object@npm:^1.2.1": - version: 1.2.2 - resolution: "is-boolean-object@npm:1.2.2" - dependencies: - call-bound: "npm:^1.0.3" - has-tostringtag: "npm:^1.0.2" - checksum: 10/051fa95fdb99d7fbf653165a7e6b2cba5d2eb62f7ffa81e793a790f3fb5366c91c1b7b6af6820aa2937dd86c73aa3ca9d9ca98f500988457b1c59692c52ba911 - languageName: node - linkType: hard - "is-bun-module@npm:^1.0.2": version: 1.3.0 resolution: "is-bun-module@npm:1.3.0" @@ -8861,7 +8693,7 @@ __metadata: languageName: node linkType: hard -"is-core-module@npm:^2.12.0, is-core-module@npm:^2.13.0, is-core-module@npm:^2.16.1, is-core-module@npm:^2.5.0": +"is-core-module@npm:^2.12.0, is-core-module@npm:^2.13.0, is-core-module@npm:^2.5.0": version: 2.16.1 resolution: "is-core-module@npm:2.16.1" dependencies: @@ -8870,27 +8702,6 @@ __metadata: languageName: node linkType: hard -"is-data-view@npm:^1.0.1, is-data-view@npm:^1.0.2": - version: 1.0.2 - resolution: "is-data-view@npm:1.0.2" - dependencies: - call-bound: "npm:^1.0.2" - get-intrinsic: "npm:^1.2.6" - is-typed-array: "npm:^1.1.13" - checksum: 10/357e9a48fa38f369fd6c4c3b632a3ab2b8adca14997db2e4b3fe94c4cd0a709af48e0fb61b02c64a90c0dd542fd489d49c2d03157b05ae6c07f5e4dec9e730a8 - languageName: node - linkType: hard - -"is-date-object@npm:^1.0.5, is-date-object@npm:^1.1.0": - version: 1.1.0 - resolution: "is-date-object@npm:1.1.0" - dependencies: - call-bound: "npm:^1.0.2" - has-tostringtag: "npm:^1.0.2" - checksum: 10/3a811b2c3176fb31abee1d23d3dc78b6c65fd9c07d591fcb67553cab9e7f272728c3dd077d2d738b53f9a2103255b0a6e8dfc9568a7805c56a78b2563e8d1dec - languageName: node - linkType: hard - "is-docker@npm:^3.0.0": version: 3.0.0 resolution: "is-docker@npm:3.0.0" @@ -8907,15 +8718,6 @@ __metadata: languageName: node linkType: hard -"is-finalizationregistry@npm:^1.1.0": - version: 1.1.1 - resolution: "is-finalizationregistry@npm:1.1.1" - dependencies: - call-bound: "npm:^1.0.3" - checksum: 10/0bfb145e9a1ba852ddde423b0926d2169ae5fe9e37882cde9e8f69031281a986308df4d982283e152396e88b86562ed2256cbaa5e6390fb840a4c25ab54b8a80 - languageName: node - linkType: hard - "is-fullwidth-code-point@npm:^3.0.0": version: 3.0.0 resolution: "is-fullwidth-code-point@npm:3.0.0" @@ -8930,16 +8732,12 @@ __metadata: languageName: node linkType: hard -"is-generator-function@npm:^1.0.10, is-generator-function@npm:^1.0.7": - version: 1.1.2 - resolution: "is-generator-function@npm:1.1.2" +"is-generator-function@npm:^1.0.7": + version: 1.0.10 + resolution: "is-generator-function@npm:1.0.10" dependencies: - call-bound: "npm:^1.0.4" - generator-function: "npm:^2.0.0" - get-proto: "npm:^1.0.1" - has-tostringtag: "npm:^1.0.2" - safe-regex-test: "npm:^1.1.0" - checksum: 10/cc50fa01034356bdfda26983c5457103240f201f4663c0de1257802714e40d36bcff7aee21091d37bbba4be962fa5c6475ce7ddbc0abfa86d6bef466e41e50a5 + has-tostringtag: "npm:^1.0.0" + checksum: 10/499a3ce6361064c3bd27fbff5c8000212d48506ebe1977842bbd7b3e708832d0deb1f4cc69186ece3640770e8c4f1287b24d99588a0b8058b2dbdd344bc1f47f languageName: node linkType: hard @@ -8977,13 +8775,6 @@ __metadata: languageName: node linkType: hard -"is-map@npm:^2.0.3": - version: 2.0.3 - resolution: "is-map@npm:2.0.3" - checksum: 10/8de7b41715b08bcb0e5edb0fb9384b80d2d5bcd10e142188f33247d19ff078abaf8e9b6f858e2302d8d05376a26a55cd23a3c9f8ab93292b02fcd2cc9e4e92bb - languageName: node - linkType: hard - "is-nan@npm:^1.3.2": version: 1.3.2 resolution: "is-nan@npm:1.3.2" @@ -8994,23 +8785,6 @@ __metadata: languageName: node linkType: hard -"is-negative-zero@npm:^2.0.3": - version: 2.0.3 - resolution: "is-negative-zero@npm:2.0.3" - checksum: 10/8fe5cffd8d4fb2ec7b49d657e1691889778d037494c6f40f4d1a524cadd658b4b53ad7b6b73a59bcb4b143ae9a3d15829af864b2c0f9d65ac1e678c4c80f17e5 - languageName: node - linkType: hard - -"is-number-object@npm:^1.1.1": - version: 1.1.1 - resolution: "is-number-object@npm:1.1.1" - dependencies: - call-bound: "npm:^1.0.3" - has-tostringtag: "npm:^1.0.2" - checksum: 10/a5922fb8779ab1ea3b8a9c144522b3d0bea5d9f8f23f7a72470e61e1e4df47714e28e0154ac011998b709cce260c3c9447ad3cd24a96c2f2a0abfdb2cbdc76c8 - languageName: node - linkType: hard - "is-number@npm:^7.0.0": version: 7.0.0 resolution: "is-number@npm:7.0.0" @@ -9039,18 +8813,6 @@ __metadata: languageName: node linkType: hard -"is-regex@npm:^1.2.1": - version: 1.2.1 - resolution: "is-regex@npm:1.2.1" - dependencies: - call-bound: "npm:^1.0.2" - gopd: "npm:^1.2.0" - has-tostringtag: "npm:^1.0.2" - hasown: "npm:^2.0.2" - checksum: 10/c42b7efc5868a5c9a4d8e6d3e9816e8815c611b09535c00fead18a1138455c5cb5e1887f0023a467ad3f9c419d62ba4dc3d9ba8bafe55053914d6d6454a945d2 - languageName: node - linkType: hard - "is-retry-allowed@npm:^3.0.0": version: 3.0.0 resolution: "is-retry-allowed@npm:3.0.0" @@ -9058,22 +8820,6 @@ __metadata: languageName: node linkType: hard -"is-set@npm:^2.0.3": - version: 2.0.3 - resolution: "is-set@npm:2.0.3" - checksum: 10/5685df33f0a4a6098a98c72d94d67cad81b2bc72f1fb2091f3d9283c4a1c582123cd709145b02a9745f0ce6b41e3e43f1c944496d1d74d4ea43358be61308669 - languageName: node - linkType: hard - -"is-shared-array-buffer@npm:^1.0.4": - version: 1.0.4 - resolution: "is-shared-array-buffer@npm:1.0.4" - dependencies: - call-bound: "npm:^1.0.3" - checksum: 10/0380d7c60cc692856871526ffcd38a8133818a2ee42d47bb8008248a0cd2121d8c8b5f66b6da3cac24bc5784553cacb6faaf678f66bc88c6615b42af2825230e - languageName: node - linkType: hard - "is-standalone-pwa@npm:^0.1.1": version: 0.1.1 resolution: "is-standalone-pwa@npm:0.1.1" @@ -9095,33 +8841,12 @@ __metadata: languageName: node linkType: hard -"is-string@npm:^1.1.1": - version: 1.1.1 - resolution: "is-string@npm:1.1.1" - dependencies: - call-bound: "npm:^1.0.3" - has-tostringtag: "npm:^1.0.2" - checksum: 10/5277cb9e225a7cc8a368a72623b44a99f2cfa139659c6b203553540681ad4276bfc078420767aad0e73eef5f0bd07d4abf39a35d37ec216917879d11cebc1f8b - languageName: node - linkType: hard - -"is-symbol@npm:^1.0.4, is-symbol@npm:^1.1.1": - version: 1.1.1 - resolution: "is-symbol@npm:1.1.1" - dependencies: - call-bound: "npm:^1.0.2" - has-symbols: "npm:^1.1.0" - safe-regex-test: "npm:^1.1.0" - checksum: 10/db495c0d8cd0a7a66b4f4ef7fccee3ab5bd954cb63396e8ac4d32efe0e9b12fdfceb851d6c501216a71f4f21e5ff20fc2ee845a3d52d455e021c466ac5eb2db2 - languageName: node - linkType: hard - -"is-typed-array@npm:^1.1.13, is-typed-array@npm:^1.1.14, is-typed-array@npm:^1.1.15, is-typed-array@npm:^1.1.3": - version: 1.1.15 - resolution: "is-typed-array@npm:1.1.15" +"is-typed-array@npm:^1.1.3": + version: 1.1.13 + resolution: "is-typed-array@npm:1.1.13" dependencies: - which-typed-array: "npm:^1.1.16" - checksum: 10/e8cf60b9ea85667097a6ad68c209c9722cfe8c8edf04d6218366469e51944c5cc25bae45ffb845c23f811d262e4314d3b0168748eb16711aa34d12724cdf0735 + which-typed-array: "npm:^1.1.14" + checksum: 10/f850ba08286358b9a11aee6d93d371a45e3c59b5953549ee1c1a9a55ba5c1dd1bd9952488ae194ad8f32a9cf5e79c8fa5f0cc4d78c00720aa0bbcf238b38062d languageName: node linkType: hard @@ -9132,32 +8857,6 @@ __metadata: languageName: node linkType: hard -"is-weakmap@npm:^2.0.2": - version: 2.0.2 - resolution: "is-weakmap@npm:2.0.2" - checksum: 10/a7b7e23206c542dcf2fa0abc483142731788771527e90e7e24f658c0833a0d91948a4f7b30d78f7a65255a48512e41a0288b778ba7fc396137515c12e201fd11 - languageName: node - linkType: hard - -"is-weakref@npm:^1.0.2, is-weakref@npm:^1.1.1": - version: 1.1.1 - resolution: "is-weakref@npm:1.1.1" - dependencies: - call-bound: "npm:^1.0.3" - checksum: 10/543506fd8259038b371bb083aac25b16cb4fd8b12fc58053aa3d45ac28dfd001cd5c6dffbba7aeea4213c74732d46b6cb2cfb5b412eed11f2db524f3f97d09a0 - languageName: node - linkType: hard - -"is-weakset@npm:^2.0.3": - version: 2.0.4 - resolution: "is-weakset@npm:2.0.4" - dependencies: - call-bound: "npm:^1.0.3" - get-intrinsic: "npm:^1.2.6" - checksum: 10/1d5e1d0179beeed3661125a6faa2e59bfb48afda06fc70db807f178aa0ebebc3758fb6358d76b3d528090d5ef85148c345dcfbf90839592fe293e3e5e82f2134 - languageName: node - linkType: hard - "is-windows@npm:^1.0.1": version: 1.0.2 resolution: "is-windows@npm:1.0.2" @@ -9174,13 +8873,6 @@ __metadata: languageName: node linkType: hard -"isarray@npm:^2.0.5": - version: 2.0.5 - resolution: "isarray@npm:2.0.5" - checksum: 10/1d8bc7911e13bb9f105b1b3e0b396c787a9e63046af0b8fe0ab1414488ab06b2b099b87a2d8a9e31d21c9a6fad773c7fc8b257c4880f2d957274479d28ca3414 - languageName: node - linkType: hard - "isexe@npm:^2.0.0": version: 2.0.0 resolution: "isexe@npm:2.0.0" @@ -10555,10 +10247,10 @@ __metadata: languageName: node linkType: hard -"minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.2, minipass@npm:^7.0.3, minipass@npm:^7.1.2": - version: 7.1.2 - resolution: "minipass@npm:7.1.2" - checksum: 10/c25f0ee8196d8e6036661104bacd743785b2599a21de5c516b32b3fa2b83113ac89a2358465bc04956baab37ffb956ae43be679b2262bf7be15fce467ccd7950 +"minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.2, minipass@npm:^7.0.3, minipass@npm:^7.0.4, minipass@npm:^7.1.2": + version: 7.1.3 + resolution: "minipass@npm:7.1.3" + checksum: 10/175e4d5e20980c3cd316ae82d2c031c42f6c746467d8b1905b51060a0ba4461441a0c25bb67c025fd9617f9a3873e152c7b543c6b5ac83a1846be8ade80dffd6 languageName: node linkType: hard @@ -10572,6 +10264,15 @@ __metadata: languageName: node linkType: hard +"minizlib@npm:^3.1.0": + version: 3.1.0 + resolution: "minizlib@npm:3.1.0" + dependencies: + minipass: "npm:^7.1.2" + checksum: 10/f47365cc2cb7f078cbe7e046eb52655e2e7e97f8c0a9a674f4da60d94fb0624edfcec9b5db32e8ba5a99a5f036f595680ae6fe02a262beaa73026e505cc52f99 + languageName: node + linkType: hard + "mkdirp@npm:^1.0.3, mkdirp@npm:^1.0.4": version: 1.0.4 resolution: "mkdirp@npm:1.0.4" @@ -10680,18 +10381,6 @@ __metadata: languageName: node linkType: hard -"node-exports-info@npm:^1.6.0": - version: 1.6.0 - resolution: "node-exports-info@npm:1.6.0" - dependencies: - array.prototype.flatmap: "npm:^1.3.3" - es-errors: "npm:^1.3.0" - object.entries: "npm:^1.1.9" - semver: "npm:^6.3.1" - checksum: 10/0a1667d535f499ac1fe6c6d22f8146bc8b68abc76fa355856219202f6cf5f386027e0ff054e66a22d08be02acbc63fcdc9f98d0fbc97993f5eabc66408fdadad - languageName: node - linkType: hard - "node-fetch@npm:^2.6.12, node-fetch@npm:^2.7.0": version: 2.7.0 resolution: "node-fetch@npm:2.7.0" @@ -10912,7 +10601,7 @@ __metadata: languageName: node linkType: hard -"object-inspect@npm:^1.13.3, object-inspect@npm:^1.13.4": +"object-inspect@npm:^1.13.3": version: 1.13.4 resolution: "object-inspect@npm:1.13.4" checksum: 10/aa13b1190ad3e366f6c83ad8a16ed37a19ed57d267385aa4bfdccda833d7b90465c057ff6c55d035a6b2e52c1a2295582b294217a0a3a1ae7abdd6877ef781fb @@ -10936,7 +10625,7 @@ __metadata: languageName: node linkType: hard -"object.assign@npm:^4.1.4, object.assign@npm:^4.1.7": +"object.assign@npm:^4.1.4": version: 4.1.7 resolution: "object.assign@npm:4.1.7" dependencies: @@ -10950,18 +10639,6 @@ __metadata: languageName: node linkType: hard -"object.entries@npm:^1.1.9": - version: 1.1.9 - resolution: "object.entries@npm:1.1.9" - dependencies: - call-bind: "npm:^1.0.8" - call-bound: "npm:^1.0.4" - define-properties: "npm:^1.2.1" - es-object-atoms: "npm:^1.1.1" - checksum: 10/24163ab1e1e013796693fc5f5d349e8b3ac0b6a34a7edb6c17d3dd45c6a8854145780c57d302a82512c1582f63720f4b4779d6c1cfba12cbb1420b978802d8a3 - languageName: node - linkType: hard - "on-finished@npm:~2.4.1": version: 2.4.1 resolution: "on-finished@npm:2.4.1" @@ -11024,17 +10701,6 @@ __metadata: languageName: node linkType: hard -"own-keys@npm:^1.0.1": - version: 1.0.1 - resolution: "own-keys@npm:1.0.1" - dependencies: - get-intrinsic: "npm:^1.2.6" - object-keys: "npm:^1.1.1" - safe-push-apply: "npm:^1.0.0" - checksum: 10/ab4bb3b8636908554fc19bf899e225444195092864cb61503a0d048fdaf662b04be2605b636a4ffeaf6e8811f6fcfa8cbb210ec964c0eb1a41eb853e1d5d2f41 - languageName: node - linkType: hard - "oxfmt@npm:^0.45.0": version: 0.45.0 resolution: "oxfmt@npm:0.45.0" @@ -11730,36 +11396,6 @@ __metadata: languageName: node linkType: hard -"reflect.getprototypeof@npm:^1.0.6, reflect.getprototypeof@npm:^1.0.9": - version: 1.0.10 - resolution: "reflect.getprototypeof@npm:1.0.10" - dependencies: - call-bind: "npm:^1.0.8" - define-properties: "npm:^1.2.1" - es-abstract: "npm:^1.23.9" - es-errors: "npm:^1.3.0" - es-object-atoms: "npm:^1.0.0" - get-intrinsic: "npm:^1.2.7" - get-proto: "npm:^1.0.1" - which-builtin-type: "npm:^1.2.1" - checksum: 10/80a4e2be716f4fe46a89a08ccad0863b47e8ce0f49616cab2d65dab0fbd53c6fdba0f52935fd41d37a2e4e22355c272004f920d63070de849f66eea7aeb4a081 - languageName: node - linkType: hard - -"regexp.prototype.flags@npm:^1.5.4": - version: 1.5.4 - resolution: "regexp.prototype.flags@npm:1.5.4" - dependencies: - call-bind: "npm:^1.0.8" - define-properties: "npm:^1.2.1" - es-errors: "npm:^1.3.0" - get-proto: "npm:^1.0.1" - gopd: "npm:^1.2.0" - set-function-name: "npm:^2.0.2" - checksum: 10/8ab897ca445968e0b96f6237641510f3243e59c180ee2ee8d83889c52ff735dd1bf3657fcd36db053e35e1d823dd53f2565d0b8021ea282c9fe62401c6c3bd6d - languageName: node - linkType: hard - "require-addon@npm:^1.1.0": version: 1.1.0 resolution: "require-addon@npm:1.1.0" @@ -11838,7 +11474,7 @@ __metadata: languageName: node linkType: hard -"resolve@npm:1.22.8, resolve@npm:^1.10.0, resolve@npm:^1.20.0, resolve@npm:^1.22.3": +"resolve@npm:1.22.8, resolve@npm:^1.10.0, resolve@npm:^1.20.0, resolve@npm:^1.22.3, resolve@npm:^1.22.4": version: 1.22.8 resolution: "resolve@npm:1.22.8" dependencies: @@ -11851,23 +11487,7 @@ __metadata: languageName: node linkType: hard -"resolve@npm:^2.0.0-next.6": - version: 2.0.0-next.6 - resolution: "resolve@npm:2.0.0-next.6" - dependencies: - es-errors: "npm:^1.3.0" - is-core-module: "npm:^2.16.1" - node-exports-info: "npm:^1.6.0" - object-keys: "npm:^1.1.1" - path-parse: "npm:^1.0.7" - supports-preserve-symlinks-flag: "npm:^1.0.0" - bin: - resolve: bin/resolve - checksum: 10/c95cb98b8d3f9e2a979e6eb8b7e0b0e13f08da62607a45207275f151d640152244568a9a9cd01662a21e3746792177cbf9be1dacb88f7355edf4db49d9ee27e5 - languageName: node - linkType: hard - -"resolve@patch:resolve@npm%3A1.22.8#optional!builtin, resolve@patch:resolve@npm%3A^1.10.0#optional!builtin, resolve@patch:resolve@npm%3A^1.20.0#optional!builtin, resolve@patch:resolve@npm%3A^1.22.3#optional!builtin": +"resolve@patch:resolve@npm%3A1.22.8#optional!builtin, resolve@patch:resolve@npm%3A^1.10.0#optional!builtin, resolve@patch:resolve@npm%3A^1.20.0#optional!builtin, resolve@patch:resolve@npm%3A^1.22.3#optional!builtin, resolve@patch:resolve@npm%3A^1.22.4#optional!builtin": version: 1.22.8 resolution: "resolve@patch:resolve@npm%3A1.22.8#optional!builtin::version=1.22.8&hash=c3c19d" dependencies: @@ -11880,22 +11500,6 @@ __metadata: languageName: node linkType: hard -"resolve@patch:resolve@npm%3A^2.0.0-next.6#optional!builtin": - version: 2.0.0-next.6 - resolution: "resolve@patch:resolve@npm%3A2.0.0-next.6#optional!builtin::version=2.0.0-next.6&hash=c3c19d" - dependencies: - es-errors: "npm:^1.3.0" - is-core-module: "npm:^2.16.1" - node-exports-info: "npm:^1.6.0" - object-keys: "npm:^1.1.1" - path-parse: "npm:^1.0.7" - supports-preserve-symlinks-flag: "npm:^1.0.0" - bin: - resolve: bin/resolve - checksum: 10/1b26738af76c80b341075e6bf4b202ef85f85f4a2cbf2934246c3b5f20c682cf352833fc6e32579c6967419226d3ab63e8d321328da052c87a31eaad91e3571a - languageName: node - linkType: hard - "retry@npm:^0.12.0": version: 0.12.0 resolution: "retry@npm:0.12.0" @@ -12017,25 +11621,21 @@ __metadata: languageName: node linkType: hard -"rxjs@npm:^7.8.1": - version: 7.8.1 - resolution: "rxjs@npm:7.8.1" +"rxjs@npm:6": + version: 6.6.7 + resolution: "rxjs@npm:6.6.7" dependencies: - tslib: "npm:^2.1.0" - checksum: 10/b10cac1a5258f885e9dd1b70d23c34daeb21b61222ee735d2ec40a8685bdca40429000703a44f0e638c27a684ac139e1c37e835d2a0dc16f6fc061a138ae3abb + tslib: "npm:^1.9.0" + checksum: 10/c8263ebb20da80dd7a91c452b9e96a178331f402344bbb40bc772b56340fcd48d13d1f545a1e3d8e464893008c5e306cc42a1552afe0d562b1a6d4e1e6262b03 languageName: node linkType: hard -"safe-array-concat@npm:^1.1.3": - version: 1.1.3 - resolution: "safe-array-concat@npm:1.1.3" +"rxjs@npm:^7.8.1": + version: 7.8.2 + resolution: "rxjs@npm:7.8.2" dependencies: - call-bind: "npm:^1.0.8" - call-bound: "npm:^1.0.2" - get-intrinsic: "npm:^1.2.6" - has-symbols: "npm:^1.1.0" - isarray: "npm:^2.0.5" - checksum: 10/fac4f40f20a3f7da024b54792fcc61059e814566dcbb04586bfefef4d3b942b2408933f25b7b3dd024affd3f2a6bbc916bef04807855e4f192413941369db864 + tslib: "npm:^2.1.0" + checksum: 10/03dff09191356b2b87d94fbc1e97c4e9eb3c09d4452399dddd451b09c2f1ba8d56925a40af114282d7bc0c6fe7514a2236ca09f903cf70e4bbf156650dddb49d languageName: node linkType: hard @@ -12053,27 +11653,6 @@ __metadata: languageName: node linkType: hard -"safe-push-apply@npm:^1.0.0": - version: 1.0.0 - resolution: "safe-push-apply@npm:1.0.0" - dependencies: - es-errors: "npm:^1.3.0" - isarray: "npm:^2.0.5" - checksum: 10/2bd4e53b6694f7134b9cf93631480e7fafc8637165f0ee91d5a4af5e7f33d37de9562d1af5021178dd4217d0230cde8d6530fa28cfa1ebff9a431bf8fff124b4 - languageName: node - linkType: hard - -"safe-regex-test@npm:^1.1.0": - version: 1.1.0 - resolution: "safe-regex-test@npm:1.1.0" - dependencies: - call-bound: "npm:^1.0.2" - es-errors: "npm:^1.3.0" - is-regex: "npm:^1.2.1" - checksum: 10/ebdb61f305bf4756a5b023ad86067df5a11b26898573afe9e52a548a63c3bd594825d9b0e2dde2eb3c94e57e0e04ac9929d4107c394f7b8e56a4613bed46c69a - languageName: node - linkType: hard - "safer-buffer@npm:>= 2.1.2 < 3, safer-buffer@npm:>= 2.1.2 < 3.0.0": version: 2.1.2 resolution: "safer-buffer@npm:2.1.2" @@ -12217,29 +11796,6 @@ __metadata: languageName: node linkType: hard -"set-function-name@npm:^2.0.2": - version: 2.0.2 - resolution: "set-function-name@npm:2.0.2" - dependencies: - define-data-property: "npm:^1.1.4" - es-errors: "npm:^1.3.0" - functions-have-names: "npm:^1.2.3" - has-property-descriptors: "npm:^1.0.2" - checksum: 10/c7614154a53ebf8c0428a6c40a3b0b47dac30587c1a19703d1b75f003803f73cdfa6a93474a9ba678fa565ef5fbddc2fae79bca03b7d22ab5fd5163dbe571a74 - languageName: node - linkType: hard - -"set-proto@npm:^1.0.0": - version: 1.0.0 - resolution: "set-proto@npm:1.0.0" - dependencies: - dunder-proto: "npm:^1.0.1" - es-errors: "npm:^1.3.0" - es-object-atoms: "npm:^1.0.0" - checksum: 10/b87f8187bca595ddc3c0721ece4635015fd9d7cb294e6dd2e394ce5186a71bbfa4dc8a35010958c65e43ad83cde09642660e61a952883c24fd6b45ead15f045c - languageName: node - linkType: hard - "setimmediate@npm:^1.0.5": version: 1.0.5 resolution: "setimmediate@npm:1.0.5" @@ -12302,12 +11858,12 @@ __metadata: linkType: hard "side-channel-list@npm:^1.0.0": - version: 1.0.1 - resolution: "side-channel-list@npm:1.0.1" + version: 1.0.0 + resolution: "side-channel-list@npm:1.0.0" dependencies: es-errors: "npm:^1.3.0" - object-inspect: "npm:^1.13.4" - checksum: 10/3499671cd52adaee739eac1e14d07530b8e3530192741aeb05e7fe4ad1b51d1368ceea2cd3c21b0f62b05410a5c70a7c4d997ba4b143303ef73d0c65dfd1c252 + object-inspect: "npm:^1.13.3" + checksum: 10/603b928997abd21c5a5f02ae6b9cc36b72e3176ad6827fab0417ead74580cc4fb4d5c7d0a8a2ff4ead34d0f9e35701ed7a41853dac8a6d1a664fcce1a044f86f languageName: node linkType: hard @@ -12594,16 +12150,6 @@ __metadata: languageName: node linkType: hard -"stop-iteration-iterator@npm:^1.1.0": - version: 1.1.0 - resolution: "stop-iteration-iterator@npm:1.1.0" - dependencies: - es-errors: "npm:^1.3.0" - internal-slot: "npm:^1.1.0" - checksum: 10/ff36c4db171ee76c936ccfe9541946b77017f12703d4c446652017356816862d3aa029a64e7d4c4ceb484e00ed4a81789333896390d808458638f3a216aa1f41 - languageName: node - linkType: hard - "stream-browserify@npm:^3.0.0": version: 3.0.0 resolution: "stream-browserify@npm:3.0.0" @@ -12661,44 +12207,6 @@ __metadata: languageName: node linkType: hard -"string.prototype.trim@npm:^1.2.10": - version: 1.2.10 - resolution: "string.prototype.trim@npm:1.2.10" - dependencies: - call-bind: "npm:^1.0.8" - call-bound: "npm:^1.0.2" - define-data-property: "npm:^1.1.4" - define-properties: "npm:^1.2.1" - es-abstract: "npm:^1.23.5" - es-object-atoms: "npm:^1.0.0" - has-property-descriptors: "npm:^1.0.2" - checksum: 10/47bb63cd2470a64bc5e2da1e570d369c016ccaa85c918c3a8bb4ab5965120f35e66d1f85ea544496fac84b9207a6b722adf007e6c548acd0813e5f8a82f9712a - languageName: node - linkType: hard - -"string.prototype.trimend@npm:^1.0.9": - version: 1.0.9 - resolution: "string.prototype.trimend@npm:1.0.9" - dependencies: - call-bind: "npm:^1.0.8" - call-bound: "npm:^1.0.2" - define-properties: "npm:^1.2.1" - es-object-atoms: "npm:^1.0.0" - checksum: 10/140c73899b6747de9e499c7c2e7a83d549c47a26fa06045b69492be9cfb9e2a95187499a373983a08a115ecff8bc3bd7b0fb09b8ff72fb2172abe766849272ef - languageName: node - linkType: hard - -"string.prototype.trimstart@npm:^1.0.8": - version: 1.0.8 - resolution: "string.prototype.trimstart@npm:1.0.8" - dependencies: - call-bind: "npm:^1.0.7" - define-properties: "npm:^1.2.1" - es-object-atoms: "npm:^1.0.0" - checksum: 10/160167dfbd68e6f7cb9f51a16074eebfce1571656fc31d40c3738ca9e30e35496f2c046fe57b6ad49f65f238a152be8c86fd9a2dd58682b5eba39dad995b3674 - languageName: node - linkType: hard - "string_decoder@npm:^1.1.1, string_decoder@npm:^1.3.0": version: 1.3.0 resolution: "string_decoder@npm:1.3.0" @@ -12872,6 +12380,19 @@ __metadata: languageName: node linkType: hard +"tar@npm:^7.4.3": + version: 7.5.15 + resolution: "tar@npm:7.5.15" + dependencies: + "@isaacs/fs-minipass": "npm:^4.0.0" + chownr: "npm:^3.0.0" + minipass: "npm:^7.1.2" + minizlib: "npm:^3.1.0" + yallist: "npm:^5.0.0" + checksum: 10/b4cb6acd822159867f81ebda8d765c6941ec8292f1cf2f870d3713f4933c14bf0ed7bf4a92338143c31e8815ca0a1fdd62aa03ddb48a42ae187f7ef696583ffe + languageName: node + linkType: hard + "test-exclude@npm:^6.0.0": version: 6.0.0 resolution: "test-exclude@npm:6.0.0" @@ -13107,6 +12628,13 @@ __metadata: languageName: node linkType: hard +"tslib@npm:^1.9.0": + version: 1.14.1 + resolution: "tslib@npm:1.14.1" + checksum: 10/7dbf34e6f55c6492637adb81b555af5e3b4f9cc6b998fb440dac82d3b42bdc91560a35a5fb75e20e24a076c651438234da6743d139e4feabf0783f3cdfe1dddb + languageName: node + linkType: hard + "tslib@npm:^2.1.0, tslib@npm:^2.3.0, tslib@npm:^2.4.0, tslib@npm:^2.6.2, tslib@npm:^2.6.3": version: 2.8.1 resolution: "tslib@npm:2.8.1" @@ -13189,59 +12717,6 @@ __metadata: languageName: node linkType: hard -"typed-array-buffer@npm:^1.0.3": - version: 1.0.3 - resolution: "typed-array-buffer@npm:1.0.3" - dependencies: - call-bound: "npm:^1.0.3" - es-errors: "npm:^1.3.0" - is-typed-array: "npm:^1.1.14" - checksum: 10/3fb91f0735fb413b2bbaaca9fabe7b8fc14a3fa5a5a7546bab8a57e755be0e3788d893195ad9c2b842620592de0e68d4c077d4c2c41f04ec25b8b5bb82fa9a80 - languageName: node - linkType: hard - -"typed-array-byte-length@npm:^1.0.3": - version: 1.0.3 - resolution: "typed-array-byte-length@npm:1.0.3" - dependencies: - call-bind: "npm:^1.0.8" - for-each: "npm:^0.3.3" - gopd: "npm:^1.2.0" - has-proto: "npm:^1.2.0" - is-typed-array: "npm:^1.1.14" - checksum: 10/269dad101dda73e3110117a9b84db86f0b5c07dad3a9418116fd38d580cab7fc628a4fc167e29b6d7c39da2f53374b78e7cb578b3c5ec7a556689d985d193519 - languageName: node - linkType: hard - -"typed-array-byte-offset@npm:^1.0.4": - version: 1.0.4 - resolution: "typed-array-byte-offset@npm:1.0.4" - dependencies: - available-typed-arrays: "npm:^1.0.7" - call-bind: "npm:^1.0.8" - for-each: "npm:^0.3.3" - gopd: "npm:^1.2.0" - has-proto: "npm:^1.2.0" - is-typed-array: "npm:^1.1.15" - reflect.getprototypeof: "npm:^1.0.9" - checksum: 10/c2869aa584cdae24ecfd282f20a0f556b13a49a9d5bca1713370bb3c89dff0ccbc5ceb45cb5b784c98f4579e5e3e2a07e438c3a5b8294583e2bd4abbd5104fb5 - languageName: node - linkType: hard - -"typed-array-length@npm:^1.0.7": - version: 1.0.7 - resolution: "typed-array-length@npm:1.0.7" - dependencies: - call-bind: "npm:^1.0.7" - for-each: "npm:^0.3.3" - gopd: "npm:^1.0.1" - is-typed-array: "npm:^1.1.13" - possible-typed-array-names: "npm:^1.0.0" - reflect.getprototypeof: "npm:^1.0.6" - checksum: 10/d6b2f0e81161682d2726eb92b1dc2b0890890f9930f33f9bcf6fc7272895ce66bc368066d273e6677776de167608adc53fcf81f1be39a146d64b630edbf2081c - languageName: node - linkType: hard - "typedarray@npm:^0.0.6": version: 0.0.6 resolution: "typedarray@npm:0.0.6" @@ -13336,18 +12811,6 @@ __metadata: languageName: node linkType: hard -"unbox-primitive@npm:^1.1.0": - version: 1.1.0 - resolution: "unbox-primitive@npm:1.1.0" - dependencies: - call-bound: "npm:^1.0.3" - has-bigints: "npm:^1.0.2" - has-symbols: "npm:^1.1.0" - which-boxed-primitive: "npm:^1.1.1" - checksum: 10/fadb347020f66b2c8aeacf8b9a79826fa34cc5e5457af4eb0bbc4e79bd87fed0fa795949825df534320f7c13f199259516ad30abc55a6e7b91d8d996ca069e50 - languageName: node - linkType: hard - "undici-types@npm:^7.9.0": version: 7.10.0 resolution: "undici-types@npm:7.10.0" @@ -13677,53 +13140,7 @@ __metadata: languageName: node linkType: hard -"which-boxed-primitive@npm:^1.1.0, which-boxed-primitive@npm:^1.1.1": - version: 1.1.1 - resolution: "which-boxed-primitive@npm:1.1.1" - dependencies: - is-bigint: "npm:^1.1.0" - is-boolean-object: "npm:^1.2.1" - is-number-object: "npm:^1.1.1" - is-string: "npm:^1.1.1" - is-symbol: "npm:^1.1.1" - checksum: 10/a877c0667bc089518c83ad4d845cf8296b03efe3565c1de1940c646e00a2a1ae9ed8a185bcfa27cbf352de7906f0616d83b9d2f19ca500ee02a551fb5cf40740 - languageName: node - linkType: hard - -"which-builtin-type@npm:^1.2.1": - version: 1.2.1 - resolution: "which-builtin-type@npm:1.2.1" - dependencies: - call-bound: "npm:^1.0.2" - function.prototype.name: "npm:^1.1.6" - has-tostringtag: "npm:^1.0.2" - is-async-function: "npm:^2.0.0" - is-date-object: "npm:^1.1.0" - is-finalizationregistry: "npm:^1.1.0" - is-generator-function: "npm:^1.0.10" - is-regex: "npm:^1.2.1" - is-weakref: "npm:^1.0.2" - isarray: "npm:^2.0.5" - which-boxed-primitive: "npm:^1.1.0" - which-collection: "npm:^1.0.2" - which-typed-array: "npm:^1.1.16" - checksum: 10/22c81c5cb7a896c5171742cd30c90d992ff13fb1ea7693e6cf80af077791613fb3f89aa9b4b7f890bd47b6ce09c6322c409932359580a2a2a54057f7b52d1cbe - languageName: node - linkType: hard - -"which-collection@npm:^1.0.2": - version: 1.0.2 - resolution: "which-collection@npm:1.0.2" - dependencies: - is-map: "npm:^2.0.3" - is-set: "npm:^2.0.3" - is-weakmap: "npm:^2.0.2" - is-weakset: "npm:^2.0.3" - checksum: 10/674bf659b9bcfe4055f08634b48a8588e879161b9fefed57e9ec4ff5601e4d50a05ccd76cf10f698ef5873784e5df3223336d56c7ce88e13bcf52ebe582fc8d7 - languageName: node - linkType: hard - -"which-typed-array@npm:^1.1.16, which-typed-array@npm:^1.1.19, which-typed-array@npm:^1.1.2": +"which-typed-array@npm:^1.1.14, which-typed-array@npm:^1.1.2": version: 1.1.20 resolution: "which-typed-array@npm:1.1.20" dependencies: @@ -13932,6 +13349,13 @@ __metadata: languageName: node linkType: hard +"yallist@npm:^5.0.0": + version: 5.0.0 + resolution: "yallist@npm:5.0.0" + checksum: 10/1884d272d485845ad04759a255c71775db0fac56308764b4c77ea56a20d56679fad340213054c8c9c9c26fcfd4c4b2a90df993b7e0aaf3cdb73c618d1d1a802a + languageName: node + linkType: hard + "yaml@npm:^1.10.0": version: 1.10.2 resolution: "yaml@npm:1.10.2"