diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 842001d62a..82a6a607bc 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -28,6 +28,9 @@ /packages/transaction-controller @MetaMask/confirmations /packages/user-operation-controller @MetaMask/confirmations +## Delegation Team +/packages/gator-permissions-controller @MetaMask/delegation + ## Earn Team /packages/earn-controller @MetaMask/earn ## Notifications Team @@ -115,6 +118,8 @@ /packages/ens-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform /packages/gas-fee-controller/package.json @MetaMask/confirmations @MetaMask/core-platform /packages/gas-fee-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform +/packages/gator-permissions-controller/package.json @MetaMask/delegation @MetaMask/core-platform +/packages/gator-permissions-controller/CHANGELOG.md @MetaMask/delegation @MetaMask/core-platform /packages/keyring-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform /packages/keyring-controller/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform /packages/logging-controller/package.json @MetaMask/confirmations @MetaMask/core-platform diff --git a/README.md b/README.md index 2576bc2d4c..2000bf4983 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ Each package in this repository has its own README where you can find installati - [`@metamask/eth-json-rpc-provider`](packages/eth-json-rpc-provider) - [`@metamask/foundryup`](packages/foundryup) - [`@metamask/gas-fee-controller`](packages/gas-fee-controller) +- [`@metamask/gator-permissions-controller`](packages/gator-permissions-controller) - [`@metamask/json-rpc-engine`](packages/json-rpc-engine) - [`@metamask/json-rpc-middleware-stream`](packages/json-rpc-middleware-stream) - [`@metamask/keyring-controller`](packages/keyring-controller) @@ -103,6 +104,7 @@ linkStyle default opacity:0.5 eth_json_rpc_provider(["@metamask/eth-json-rpc-provider"]); foundryup(["@metamask/foundryup"]); gas_fee_controller(["@metamask/gas-fee-controller"]); + gator_permissions_controller(["@metamask/gator-permissions-controller"]); json_rpc_engine(["@metamask/json-rpc-engine"]); json_rpc_middleware_stream(["@metamask/json-rpc-middleware-stream"]); keyring_controller(["@metamask/keyring-controller"]); @@ -201,6 +203,7 @@ linkStyle default opacity:0.5 gas_fee_controller --> controller_utils; gas_fee_controller --> polling_controller; gas_fee_controller --> network_controller; + gator_permissions_controller --> base_controller; json_rpc_middleware_stream --> json_rpc_engine; keyring_controller --> base_controller; logging_controller --> base_controller; diff --git a/packages/gator-permissions-controller/CHANGELOG.md b/packages/gator-permissions-controller/CHANGELOG.md new file mode 100644 index 0000000000..eff38c93eb --- /dev/null +++ b/packages/gator-permissions-controller/CHANGELOG.md @@ -0,0 +1,14 @@ +# 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 ([#6033](https://github.com/MetaMask/core/pull/6033)) + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/gator-permissions-controller/LICENSE b/packages/gator-permissions-controller/LICENSE new file mode 100644 index 0000000000..7d002dced3 --- /dev/null +++ b/packages/gator-permissions-controller/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2025 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 diff --git a/packages/gator-permissions-controller/README.md b/packages/gator-permissions-controller/README.md new file mode 100644 index 0000000000..104a52043e --- /dev/null +++ b/packages/gator-permissions-controller/README.md @@ -0,0 +1,38 @@ +# `@metamask/gator-permissions-controller` + +A dedicated controller for reading gator permissions from profile sync storage. This controller fetches data from the encrypted user storage database and caches it locally, providing fast access to permissions across devices while maintaining privacy through client-side encryption. + +## Installation + +`yarn add @metamask/gator-permissions-controller` + +or + +`npm install @metamask/gator-permissions-controller` + +## Usage + +### Basic Setup + +```typescript +import { GatorPermissionsController } from '@metamask/gator-permissions-controller'; + +// Create the controller +const gatorPermissionsController = new GatorPermissionsController({ + messenger: yourMessenger, +}); + +// Enable the feature (requires authentication) +gatorPermissionsController.enableGatorPermissions(); +``` + +### Fetch from Profile Sync + +```typescript +const permissions = + await gatorPermissionsController.fetchAndUpdateGatorPermissions(); +``` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/gator-permissions-controller/jest.config.js b/packages/gator-permissions-controller/jest.config.js new file mode 100644 index 0000000000..ca08413339 --- /dev/null +++ b/packages/gator-permissions-controller/jest.config.js @@ -0,0 +1,26 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + // The display name when running multiple projects + displayName, + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/gator-permissions-controller/package.json b/packages/gator-permissions-controller/package.json new file mode 100644 index 0000000000..bf5d031be8 --- /dev/null +++ b/packages/gator-permissions-controller/package.json @@ -0,0 +1,79 @@ +{ + "name": "@metamask/gator-permissions-controller", + "version": "0.0.0", + "description": "Controller for managing gator permissions with profile sync integration", + "keywords": [ + "MetaMask", + "Ethereum" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/gator-permissions-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "license": "MIT", + "sideEffects": false, + "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" + }, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "files": [ + "dist/" + ], + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/gator-permissions-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/gator-permissions-controller", + "publish:preview": "yarn npm publish --tag preview", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "dependencies": { + "@metamask/base-controller": "^8.1.0", + "@metamask/snaps-sdk": "^9.0.0", + "@metamask/snaps-utils": "^11.0.0", + "@metamask/utils": "^11.4.2" + }, + "devDependencies": { + "@lavamoat/allow-scripts": "^3.0.4", + "@lavamoat/preinstall-always-fail": "^2.1.0", + "@metamask/auto-changelog": "^3.4.4", + "@metamask/snaps-controllers": "^14.0.1", + "@ts-bridge/cli": "^0.6.1", + "@types/jest": "^27.4.1", + "deepmerge": "^4.2.2", + "jest": "^27.5.1", + "ts-jest": "^27.1.4", + "typedoc": "^0.24.8", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.2.2" + }, + "peerDependencies": { + "@metamask/snaps-controllers": "^14.0.1" + }, + "engines": { + "node": "^18.18 || >=20" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/gator-permissions-controller/src/GatorPermissionContoller.test.ts b/packages/gator-permissions-controller/src/GatorPermissionContoller.test.ts new file mode 100644 index 0000000000..4730e5490f --- /dev/null +++ b/packages/gator-permissions-controller/src/GatorPermissionContoller.test.ts @@ -0,0 +1,450 @@ +import { Messenger } from '@metamask/base-controller'; +import type { HandleSnapRequest, HasSnap } from '@metamask/snaps-controllers'; +import type { SnapId } from '@metamask/snaps-sdk'; +import type { Hex } from '@metamask/utils'; + +import type { GatorPermissionsControllerMessenger } from './GatorPermissionsController'; +import GatorPermissionsController from './GatorPermissionsController'; +import { + mockCustomPermissionStorageEntry, + mockErc20TokenPeriodicStorageEntry, + mockErc20TokenStreamStorageEntry, + mockGatorPermissionsStorageEntriesFactory, + mockNativeTokenPeriodicStorageEntry, + mockNativeTokenStreamStorageEntry, +} from './test/mocks'; +import type { + AccountSigner, + GatorPermissionsMap, + StoredGatorPermission, + PermissionTypes, +} from './types'; +import type { + ExtractAvailableAction, + ExtractAvailableEvent, +} from '../../base-controller/tests/helpers'; + +const MOCK_CHAIN_ID_1: Hex = '0xaa36a7'; +const MOCK_CHAIN_ID_2: Hex = '0x1'; +const MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID = + 'local:http://localhost:8082' as SnapId; +const MOCK_GATOR_PERMISSIONS_STORAGE_ENTRIES: StoredGatorPermission< + AccountSigner, + PermissionTypes +>[] = mockGatorPermissionsStorageEntriesFactory({ + [MOCK_CHAIN_ID_1]: { + nativeTokenStream: 5, + nativeTokenPeriodic: 5, + erc20TokenStream: 5, + erc20TokenPeriodic: 5, + custom: { + count: 2, + data: [ + { + customData: 'customData-0', + }, + { + customData: 'customData-1', + }, + ], + }, + }, + [MOCK_CHAIN_ID_2]: { + nativeTokenStream: 5, + nativeTokenPeriodic: 5, + erc20TokenStream: 5, + erc20TokenPeriodic: 5, + custom: { + count: 2, + data: [ + { + customData: 'customData-0', + }, + { + customData: 'customData-1', + }, + ], + }, + }, +}); + +describe('GatorPermissionsController', () => { + describe('constructor', () => { + it('creates GatorPermissionsController with default state', () => { + const controller = new GatorPermissionsController({ + messenger: getMessenger(), + }); + + expect(controller.state.isGatorPermissionsEnabled).toBe(false); + expect(controller.state.gatorPermissionsMapSerialized).toStrictEqual( + JSON.stringify({ + 'native-token-stream': {}, + 'native-token-periodic': {}, + 'erc20-token-stream': {}, + 'erc20-token-periodic': {}, + other: {}, + }), + ); + expect(controller.state.isFetchingGatorPermissions).toBe(false); + }); + + it('creates GatorPermissionsController with custom state', () => { + const customState = { + isGatorPermissionsEnabled: true, + gatorPermissionsMapSerialized: JSON.stringify({ + 'native-token-stream': {}, + 'native-token-periodic': {}, + 'erc20-token-stream': {}, + 'erc20-token-periodic': {}, + other: {}, + }), + gatorPermissionsProviderSnapId: MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + }; + + const controller = new GatorPermissionsController({ + messenger: getMessenger(), + state: customState, + }); + + expect(controller.state.gatorPermissionsProviderSnapId).toBe( + MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + ); + expect(controller.state.isGatorPermissionsEnabled).toBe(true); + expect(controller.state.gatorPermissionsMapSerialized).toBe( + customState.gatorPermissionsMapSerialized, + ); + }); + + it('creates GatorPermissionsController with default config', () => { + const controller = new GatorPermissionsController({ + messenger: getMessenger(), + }); + + expect(controller.permissionsProviderSnapId).toBe( + '@metamask/gator-permissions-snap' as SnapId, + ); + expect(controller.state.isGatorPermissionsEnabled).toBe(false); + expect(controller.state.isFetchingGatorPermissions).toBe(false); + }); + + it('isFetchingGatorPermissions is false on initialization', () => { + const controller = new GatorPermissionsController({ + messenger: getMessenger(), + state: { + isFetchingGatorPermissions: true, + }, + }); + + expect(controller.state.isFetchingGatorPermissions).toBe(false); + }); + }); + + describe('disableGatorPermissions', () => { + it('disables gator permissions successfully', async () => { + const controller = new GatorPermissionsController({ + messenger: getMessenger(), + }); + + await controller.enableGatorPermissions(); + expect(controller.state.isGatorPermissionsEnabled).toBe(true); + + await controller.disableGatorPermissions(); + + expect(controller.state.isGatorPermissionsEnabled).toBe(false); + expect(controller.state.gatorPermissionsMapSerialized).toBe( + JSON.stringify({ + 'native-token-stream': {}, + 'native-token-periodic': {}, + 'erc20-token-stream': {}, + 'erc20-token-periodic': {}, + other: {}, + }), + ); + }); + }); + + describe('fetchAndUpdateGatorPermissions', () => { + it('fetches and updates gator permissions successfully', async () => { + const controller = new GatorPermissionsController({ + messenger: getMessenger(), + }); + + await controller.enableGatorPermissions(); + + const result = await controller.fetchAndUpdateGatorPermissions(); + + expect(result).toStrictEqual({ + 'native-token-stream': expect.any(Object), + 'native-token-periodic': expect.any(Object), + 'erc20-token-stream': expect.any(Object), + 'erc20-token-periodic': expect.any(Object), + other: expect.any(Object), + }); + + // Check that each permission type has the expected chainId + expect(result['native-token-stream'][MOCK_CHAIN_ID_1]).toHaveLength(5); + expect(result['native-token-periodic'][MOCK_CHAIN_ID_1]).toHaveLength(5); + expect(result['erc20-token-stream'][MOCK_CHAIN_ID_1]).toHaveLength(5); + expect(result['native-token-stream'][MOCK_CHAIN_ID_2]).toHaveLength(5); + expect(result['native-token-periodic'][MOCK_CHAIN_ID_2]).toHaveLength(5); + expect(result['erc20-token-stream'][MOCK_CHAIN_ID_2]).toHaveLength(5); + expect(result.other[MOCK_CHAIN_ID_1]).toHaveLength(2); + expect(result.other[MOCK_CHAIN_ID_2]).toHaveLength(2); + expect(controller.state.isFetchingGatorPermissions).toBe(false); + + // check that the gator permissions map is sanitized + const sanitizedCheck = (permissionType: keyof GatorPermissionsMap) => { + const flattenedStoredGatorPermissions = Object.values( + result[permissionType], + ).flat(); + flattenedStoredGatorPermissions.forEach((permission) => { + expect( + permission.permissionResponse.isAdjustmentAllowed, + ).toBeUndefined(); + expect(permission.permissionResponse.accountMeta).toBeUndefined(); + expect(permission.permissionResponse.signer).toBeUndefined(); + }); + }; + + sanitizedCheck('native-token-stream'); + sanitizedCheck('native-token-periodic'); + sanitizedCheck('erc20-token-stream'); + sanitizedCheck('erc20-token-periodic'); + sanitizedCheck('other'); + }); + + it('throws error when gator permissions are not enabled', async () => { + const controller = new GatorPermissionsController({ + messenger: getMessenger(), + }); + + await controller.disableGatorPermissions(); + + await expect(controller.fetchAndUpdateGatorPermissions()).rejects.toThrow( + 'Failed to fetch gator permissions', + ); + }); + + it('handles null permissions data', async () => { + const rootMessenger = getRootMessenger({ + snapControllerHandleRequestActionHandler: async () => null, + }); + + const controller = new GatorPermissionsController({ + messenger: getMessenger(rootMessenger), + }); + + await controller.enableGatorPermissions(); + + const result = await controller.fetchAndUpdateGatorPermissions(); + + expect(result).toStrictEqual({ + 'native-token-stream': {}, + 'native-token-periodic': {}, + 'erc20-token-stream': {}, + 'erc20-token-periodic': {}, + other: {}, + }); + }); + + it('handles empty permissions data', async () => { + const rootMessenger = getRootMessenger({ + snapControllerHandleRequestActionHandler: async () => [], + }); + + const controller = new GatorPermissionsController({ + messenger: getMessenger(rootMessenger), + }); + + await controller.enableGatorPermissions(); + + const result = await controller.fetchAndUpdateGatorPermissions(); + + expect(result).toStrictEqual({ + 'native-token-stream': {}, + 'native-token-periodic': {}, + 'erc20-token-stream': {}, + 'erc20-token-periodic': {}, + other: {}, + }); + }); + + it('handles error during fetch and update', async () => { + const rootMessenger = getRootMessenger({ + snapControllerHandleRequestActionHandler: async () => { + throw new Error('Storage error'); + }, + }); + + const controller = new GatorPermissionsController({ + messenger: getMessenger(rootMessenger), + }); + + await controller.enableGatorPermissions(); + + await expect(controller.fetchAndUpdateGatorPermissions()).rejects.toThrow( + 'Failed to fetch gator permissions', + ); + + expect(controller.state.isFetchingGatorPermissions).toBe(false); + }); + }); + + describe('gatorPermissionsMap getter tests', () => { + it('returns parsed gator permissions map', () => { + const controller = new GatorPermissionsController({ + messenger: getMessenger(), + }); + + const { gatorPermissionsMap } = controller; + + expect(gatorPermissionsMap).toStrictEqual({ + 'native-token-stream': {}, + 'native-token-periodic': {}, + 'erc20-token-stream': {}, + 'erc20-token-periodic': {}, + other: {}, + }); + }); + + it('returns parsed gator permissions map with data when state is provided', () => { + const mockState = { + 'native-token-stream': { + '0x1': [mockNativeTokenStreamStorageEntry('0x1')], + }, + 'native-token-periodic': { + '0x2': [mockNativeTokenPeriodicStorageEntry('0x2')], + }, + 'erc20-token-stream': { + '0x3': [mockErc20TokenStreamStorageEntry('0x3')], + }, + 'erc20-token-periodic': { + '0x4': [mockErc20TokenPeriodicStorageEntry('0x4')], + }, + other: { + '0x5': [ + mockCustomPermissionStorageEntry('0x5', { + customData: 'customData-0', + }), + ], + }, + }; + + const controller = new GatorPermissionsController({ + messenger: getMessenger(), + state: { + gatorPermissionsMapSerialized: JSON.stringify(mockState), + }, + }); + + const { gatorPermissionsMap } = controller; + + expect(gatorPermissionsMap).toStrictEqual(mockState); + }); + }); + + describe('message handlers tests', () => { + it('registers all message handlers', () => { + const messenger = getMessenger(); + const mockRegisterActionHandler = jest.spyOn( + messenger, + 'registerActionHandler', + ); + + new GatorPermissionsController({ + messenger, + }); + + expect(mockRegisterActionHandler).toHaveBeenCalledWith( + 'GatorPermissionsController:fetchAndUpdateGatorPermissions', + expect.any(Function), + ); + expect(mockRegisterActionHandler).toHaveBeenCalledWith( + 'GatorPermissionsController:enableGatorPermissions', + expect.any(Function), + ); + expect(mockRegisterActionHandler).toHaveBeenCalledWith( + 'GatorPermissionsController:disableGatorPermissions', + expect.any(Function), + ); + }); + }); + + describe('enableGatorPermissions', () => { + it('enables gator permissions successfully', async () => { + const controller = new GatorPermissionsController({ + messenger: getMessenger(), + }); + + await controller.enableGatorPermissions(); + + expect(controller.state.isGatorPermissionsEnabled).toBe(true); + }); + }); +}); + +/** + * The union of actions that the root messenger allows. + */ +type RootAction = ExtractAvailableAction; + +/** + * The union of events that the root messenger allows. + */ +type RootEvent = ExtractAvailableEvent; + +/** + * Constructs the unrestricted messenger. This can be used to call actions and + * publish events within the tests for this controller. + * + * @param args - The arguments to this function. + * `GatorPermissionsController:getState` action on the messenger. + * @param args.snapControllerHandleRequestActionHandler - Used to mock the + * `SnapController:handleRequest` action on the messenger. + * @param args.snapControllerHasActionHandler - Used to mock the + * `SnapController:has` action on the messenger. + * @returns The unrestricted messenger suited for GatorPermissionsController. + */ +function getRootMessenger({ + snapControllerHandleRequestActionHandler = jest + .fn< + ReturnType, + Parameters + >() + .mockResolvedValue(MOCK_GATOR_PERMISSIONS_STORAGE_ENTRIES), + snapControllerHasActionHandler = jest + .fn, Parameters>() + .mockResolvedValue(true as never), +}: { + snapControllerHandleRequestActionHandler?: HandleSnapRequest['handler']; + snapControllerHasActionHandler?: HasSnap['handler']; +} = {}): Messenger { + const rootMessenger = new Messenger(); + + rootMessenger.registerActionHandler( + 'SnapController:handleRequest', + snapControllerHandleRequestActionHandler, + ); + rootMessenger.registerActionHandler( + 'SnapController:has', + snapControllerHasActionHandler, + ); + return rootMessenger; +} + +/** + * Constructs the messenger which is restricted to relevant SampleGasPricesController + * actions and events. + * + * @param rootMessenger - The root messenger to restrict. + * @returns The restricted messenger. + */ +function getMessenger( + rootMessenger = getRootMessenger(), +): GatorPermissionsControllerMessenger { + return rootMessenger.getRestricted({ + name: 'GatorPermissionsController', + allowedActions: ['SnapController:handleRequest', 'SnapController:has'], + allowedEvents: [], + }); +} diff --git a/packages/gator-permissions-controller/src/GatorPermissionsController.ts b/packages/gator-permissions-controller/src/GatorPermissionsController.ts new file mode 100644 index 0000000000..9b5b02864d --- /dev/null +++ b/packages/gator-permissions-controller/src/GatorPermissionsController.ts @@ -0,0 +1,479 @@ +import type { + RestrictedMessenger, + ControllerGetStateAction, + ControllerStateChangeEvent, + StateMetadata, +} from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import type { HandleSnapRequest, HasSnap } from '@metamask/snaps-controllers'; +import type { SnapId } from '@metamask/snaps-sdk'; +import { HandlerType } from '@metamask/snaps-utils'; + +import { + GatorPermissionsFetchError, + GatorPermissionsNotEnabledError, + GatorPermissionsProviderError, +} from './errors'; +import { controllerLog } from './logger'; +import type { StoredGatorPermissionSanitized } from './types'; +import { + GatorPermissionsSnapRpcMethod, + type GatorPermissionsMap, + type PermissionTypes, + type SignerParam, + type StoredGatorPermission, +} from './types'; +import { + deserializeGatorPermissionsMap, + serializeGatorPermissionsMap, +} from './utils'; + +// === GENERAL === + +// Unique name for the controller +const controllerName = 'GatorPermissionsController'; + +// Default value for the gator permissions provider snap id +const defaultGatorPermissionsProviderSnapId = + '@metamask/gator-permissions-snap' as SnapId; + +const defaultGatorPermissionsMap: GatorPermissionsMap = { + 'native-token-stream': {}, + 'native-token-periodic': {}, + 'erc20-token-stream': {}, + 'erc20-token-periodic': {}, + other: {}, +}; + +// === STATE === + +/** + * State shape for GatorPermissionsController + */ +export type GatorPermissionsControllerState = { + /** + * Flag that indicates if the gator permissions feature is enabled + */ + isGatorPermissionsEnabled: boolean; + + /** + * JSON serialized object containing gator permissions fetched from profile sync + */ + gatorPermissionsMapSerialized: string; + + /** + * Flag that indicates that fetching permissions is in progress + * This is used to show a loading spinner in the UI + */ + isFetchingGatorPermissions: boolean; + + /** + * The ID of the Snap of the gator permissions provider snap + * Default value is `@metamask/gator-permissions-snap` + */ + gatorPermissionsProviderSnapId: SnapId; +}; + +const gatorPermissionsControllerMetadata = { + isGatorPermissionsEnabled: { + persist: true, + anonymous: false, + }, + gatorPermissionsMapSerialized: { + persist: true, + anonymous: false, + }, + isFetchingGatorPermissions: { + persist: false, + anonymous: false, + }, + gatorPermissionsProviderSnapId: { + persist: false, + anonymous: false, + }, +} satisfies StateMetadata; + +/** + * Constructs the default {@link GatorPermissionsController} state. This allows + * consumers to provide a partial state object when initializing the controller + * and also helps in constructing complete state objects for this controller in + * tests. + * + * @returns The default {@link GatorPermissionsController} state. + */ +export function getDefaultGatorPermissionsControllerState(): GatorPermissionsControllerState { + return { + isGatorPermissionsEnabled: false, + gatorPermissionsMapSerialized: serializeGatorPermissionsMap( + defaultGatorPermissionsMap, + ), + isFetchingGatorPermissions: false, + gatorPermissionsProviderSnapId: defaultGatorPermissionsProviderSnapId, + }; +} + +// === MESSENGER === + +/** + * The action which can be used to retrieve the state of the + * {@link GatorPermissionsController}. + */ +export type GatorPermissionsControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + GatorPermissionsControllerState +>; + +/** + * The action which can be used to fetch and update gator permissions. + */ +export type GatorPermissionsControllerFetchAndUpdateGatorPermissionsAction = { + type: `${typeof controllerName}:fetchAndUpdateGatorPermissions`; + handler: GatorPermissionsController['fetchAndUpdateGatorPermissions']; +}; + +/** + * The action which can be used to enable gator permissions. + */ +export type GatorPermissionsControllerEnableGatorPermissionsAction = { + type: `${typeof controllerName}:enableGatorPermissions`; + handler: GatorPermissionsController['enableGatorPermissions']; +}; + +/** + * The action which can be used to disable gator permissions. + */ +export type GatorPermissionsControllerDisableGatorPermissionsAction = { + type: `${typeof controllerName}:disableGatorPermissions`; + handler: GatorPermissionsController['disableGatorPermissions']; +}; + +/** + * All actions that {@link GatorPermissionsController} registers, to be called + * externally. + */ +export type GatorPermissionsControllerActions = + | GatorPermissionsControllerGetStateAction + | GatorPermissionsControllerFetchAndUpdateGatorPermissionsAction + | GatorPermissionsControllerEnableGatorPermissionsAction + | GatorPermissionsControllerDisableGatorPermissionsAction; + +/** + * All actions that {@link GatorPermissionsController} calls internally. + * + * SnapsController:handleRequest and SnapsController:has are allowed to be called + * internally because they are used to fetch gator permissions from the Snap. + */ +type AllowedActions = HandleSnapRequest | HasSnap; + +/** + * The event that {@link GatorPermissionsController} publishes when updating state. + */ +export type GatorPermissionsControllerStateChangeEvent = + ControllerStateChangeEvent< + typeof controllerName, + GatorPermissionsControllerState + >; + +/** + * All events that {@link GatorPermissionsController} publishes, to be subscribed to + * externally. + */ +export type GatorPermissionsControllerEvents = + GatorPermissionsControllerStateChangeEvent; + +/** + * Events that {@link GatorPermissionsController} is allowed to subscribe to internally. + */ +type AllowedEvents = GatorPermissionsControllerStateChangeEvent; + +/** + * Messenger type for the GatorPermissionsController. + */ +export type GatorPermissionsControllerMessenger = RestrictedMessenger< + typeof controllerName, + GatorPermissionsControllerActions | AllowedActions, + GatorPermissionsControllerEvents | AllowedEvents, + AllowedActions['type'], + AllowedEvents['type'] +>; + +/** + * Controller that manages gator permissions by reading from profile sync + */ +export default class GatorPermissionsController extends BaseController< + typeof controllerName, + GatorPermissionsControllerState, + GatorPermissionsControllerMessenger +> { + /** + * Creates a GatorPermissionsController instance. + * + * @param args - The arguments to this function. + * @param args.messenger - Messenger used to communicate with BaseV2 controller. + * @param args.state - Initial state to set on this controller. + */ + constructor({ + messenger, + state, + }: { + messenger: GatorPermissionsControllerMessenger; + state?: Partial; + }) { + super({ + name: controllerName, + metadata: gatorPermissionsControllerMetadata, + messenger, + state: { + ...getDefaultGatorPermissionsControllerState(), + ...state, + isFetchingGatorPermissions: false, + }, + }); + + this.#registerMessageHandlers(); + } + + #setIsFetchingGatorPermissions(isFetchingGatorPermissions: boolean) { + this.update((state) => { + state.isFetchingGatorPermissions = isFetchingGatorPermissions; + }); + } + + #setIsGatorPermissionsEnabled(isGatorPermissionsEnabled: boolean) { + this.update((state) => { + state.isGatorPermissionsEnabled = isGatorPermissionsEnabled; + }); + } + + #registerMessageHandlers(): void { + this.messagingSystem.registerActionHandler( + `${controllerName}:fetchAndUpdateGatorPermissions`, + this.fetchAndUpdateGatorPermissions.bind(this), + ); + + this.messagingSystem.registerActionHandler( + `${controllerName}:enableGatorPermissions`, + this.enableGatorPermissions.bind(this), + ); + + this.messagingSystem.registerActionHandler( + `${controllerName}:disableGatorPermissions`, + this.disableGatorPermissions.bind(this), + ); + } + + /** + * Asserts that the gator permissions are enabled. + * + * @throws {GatorPermissionsNotEnabledError} If the gator permissions are not enabled. + */ + #assertGatorPermissionsEnabled() { + if (!this.state.isGatorPermissionsEnabled) { + throw new GatorPermissionsNotEnabledError(); + } + } + + /** + * Forwards a Snap request to the SnapController. + * + * @param args - The request parameters. + * @param args.snapId - The ID of the Snap of the gator permissions provider snap. + * @returns A promise that resolves with the gator permissions. + */ + async #handleSnapRequestToGatorPermissionsProvider({ + snapId, + }: { + snapId: SnapId; + }): Promise[] | null> { + try { + const response = (await this.messagingSystem.call( + 'SnapController:handleRequest', + { + snapId, + origin: 'metamask', + handler: HandlerType.OnRpcRequest, + request: { + jsonrpc: '2.0', + method: + GatorPermissionsSnapRpcMethod.PermissionProviderGetGrantedPermissions, + }, + }, + )) as StoredGatorPermission[] | null; + + return response; + } catch (error) { + controllerLog( + 'Failed to handle snap request to gator permissions provider', + error, + ); + throw new GatorPermissionsProviderError({ + method: + GatorPermissionsSnapRpcMethod.PermissionProviderGetGrantedPermissions, + cause: error as Error, + }); + } + } + + /** + * Sanitizes a stored gator permission by removing the fields that are not expose to MetaMask client. + * + * @param storedGatorPermission - The stored gator permission to sanitize. + * @returns The sanitized stored gator permission. + */ + #sanitizeStoredGatorPermission( + storedGatorPermission: StoredGatorPermission, + ): StoredGatorPermissionSanitized { + const { permissionResponse } = storedGatorPermission; + const { isAdjustmentAllowed, accountMeta, signer, ...rest } = + permissionResponse; + return { + ...storedGatorPermission, + permissionResponse: { ...rest }, + }; + } + + /** + * Categorizes stored gator permissions by type and chainId. + * + * @param storedGatorPermissions - An array of stored gator permissions. + * @returns The gator permissions map. + */ + #categorizePermissionsDataByTypeAndChainId( + storedGatorPermissions: + | StoredGatorPermission[] + | null, + ): GatorPermissionsMap { + if (!storedGatorPermissions) { + return defaultGatorPermissionsMap; + } + + return storedGatorPermissions.reduce( + (gatorPermissionsMap, storedGatorPermission) => { + const { permissionResponse } = storedGatorPermission; + const permissionType = permissionResponse.permission.type; + const { chainId } = permissionResponse; + + const sanitizedStoredGatorPermission = + this.#sanitizeStoredGatorPermission(storedGatorPermission); + + switch (permissionType) { + case 'native-token-stream': + case 'native-token-periodic': + case 'erc20-token-stream': + case 'erc20-token-periodic': + if (!gatorPermissionsMap[permissionType][chainId]) { + gatorPermissionsMap[permissionType][chainId] = []; + } + + ( + gatorPermissionsMap[permissionType][ + chainId + ] as StoredGatorPermissionSanitized< + SignerParam, + PermissionTypes + >[] + ).push(sanitizedStoredGatorPermission); + break; + default: + if (!gatorPermissionsMap.other[chainId]) { + gatorPermissionsMap.other[chainId] = []; + } + + ( + gatorPermissionsMap.other[ + chainId + ] as StoredGatorPermissionSanitized< + SignerParam, + PermissionTypes + >[] + ).push(sanitizedStoredGatorPermission); + break; + } + + return gatorPermissionsMap; + }, + { + 'native-token-stream': {}, + 'native-token-periodic': {}, + 'erc20-token-stream': {}, + 'erc20-token-periodic': {}, + other: {}, + } as GatorPermissionsMap, + ); + } + + /** + * Gets the gator permissions map from the state. + * + * @returns The gator permissions map. + */ + get gatorPermissionsMap(): GatorPermissionsMap { + return deserializeGatorPermissionsMap( + this.state.gatorPermissionsMapSerialized, + ); + } + + /** + * Gets the gator permissions provider snap id that is used to fetch gator permissions. + * + * @returns The gator permissions provider snap id. + */ + get permissionsProviderSnapId(): SnapId { + return this.state.gatorPermissionsProviderSnapId; + } + + /** + * Enables gator permissions for the user. + */ + public async enableGatorPermissions() { + this.#setIsGatorPermissionsEnabled(true); + } + + /** + * Clears the gator permissions map and disables the feature. + */ + public async disableGatorPermissions() { + this.update((state) => { + state.isGatorPermissionsEnabled = false; + state.gatorPermissionsMapSerialized = serializeGatorPermissionsMap( + defaultGatorPermissionsMap, + ); + }); + } + + /** + * Fetches the gator permissions from profile sync and updates the state. + * + * @returns A promise that resolves to the gator permissions map. + * @throws {GatorPermissionsFetchError} If the gator permissions fetch fails. + */ + public async fetchAndUpdateGatorPermissions(): Promise { + try { + this.#setIsFetchingGatorPermissions(true); + this.#assertGatorPermissionsEnabled(); + + const permissionsData = + await this.#handleSnapRequestToGatorPermissionsProvider({ + snapId: this.state.gatorPermissionsProviderSnapId, + }); + + const gatorPermissionsMap = + this.#categorizePermissionsDataByTypeAndChainId(permissionsData); + + this.update((state) => { + state.gatorPermissionsMapSerialized = + serializeGatorPermissionsMap(gatorPermissionsMap); + }); + + return gatorPermissionsMap; + } catch (error) { + controllerLog('Failed to fetch gator permissions', error); + throw new GatorPermissionsFetchError({ + message: 'Failed to fetch gator permissions', + cause: error as Error, + }); + } finally { + this.#setIsFetchingGatorPermissions(false); + } + } +} diff --git a/packages/gator-permissions-controller/src/errors.ts b/packages/gator-permissions-controller/src/errors.ts new file mode 100644 index 0000000000..2deff1c4db --- /dev/null +++ b/packages/gator-permissions-controller/src/errors.ts @@ -0,0 +1,82 @@ +import type { GatorPermissionsSnapRpcMethod } from './types'; +import { GatorPermissionsControllerErrorCode } from './types'; + +/** + * Represents a base gator permissions error. + */ +type GatorPermissionsErrorParams = { + code: GatorPermissionsControllerErrorCode; + cause: Error; + message: string; +}; + +export class GatorPermissionsControllerError extends Error { + code: GatorPermissionsControllerErrorCode; + + cause: Error; + + constructor({ cause, message, code }: GatorPermissionsErrorParams) { + super(message); + + this.cause = cause; + this.code = code; + } +} + +export class GatorPermissionsFetchError extends GatorPermissionsControllerError { + constructor({ cause, message }: { cause: Error; message: string }) { + super({ + cause, + message, + code: GatorPermissionsControllerErrorCode.GatorPermissionsFetchError, + }); + } +} + +export class GatorPermissionsMapSerializationError extends GatorPermissionsControllerError { + data: unknown; + + constructor({ + cause, + message, + data, + }: { + cause: Error; + message: string; + data?: unknown; + }) { + super({ + cause, + message, + code: GatorPermissionsControllerErrorCode.GatorPermissionsMapSerializationError, + }); + + this.data = data; + } +} + +export class GatorPermissionsNotEnabledError extends GatorPermissionsControllerError { + constructor() { + super({ + cause: new Error('Gator permissions are not enabled'), + message: 'Gator permissions are not enabled', + code: GatorPermissionsControllerErrorCode.GatorPermissionsNotEnabled, + }); + } +} + +export class GatorPermissionsProviderError extends GatorPermissionsControllerError { + constructor({ + cause, + method, + }: { + cause: Error; + method: GatorPermissionsSnapRpcMethod; + }) { + super({ + cause, + message: `Failed to handle snap request to gator permissions provider for method ${method}`, + code: GatorPermissionsControllerErrorCode.GatorPermissionsProviderError, + }); + } +} diff --git a/packages/gator-permissions-controller/src/index.ts b/packages/gator-permissions-controller/src/index.ts new file mode 100644 index 0000000000..8be1c11217 --- /dev/null +++ b/packages/gator-permissions-controller/src/index.ts @@ -0,0 +1,39 @@ +export { default as GatorPermissionsController } from './GatorPermissionsController'; +export { + serializeGatorPermissionsMap, + deserializeGatorPermissionsMap, +} from './utils'; +export type { + GatorPermissionsControllerState, + GatorPermissionsControllerMessenger, + GatorPermissionsControllerGetStateAction, + GatorPermissionsControllerFetchAndUpdateGatorPermissionsAction, + GatorPermissionsControllerEnableGatorPermissionsAction, + GatorPermissionsControllerDisableGatorPermissionsAction, + GatorPermissionsControllerActions, + GatorPermissionsControllerEvents, + GatorPermissionsControllerStateChangeEvent, +} from './GatorPermissionsController'; +export type { + GatorPermissionsControllerErrorCode, + GatorPermissionsSnapRpcMethod, + MetaMaskBasePermissionData, + NativeTokenStreamPermission, + NativeTokenPeriodicPermission, + Erc20TokenStreamPermission, + Erc20TokenPeriodicPermission, + CustomPermission, + PermissionTypes, + AccountSigner, + WalletSigner, + SignerParam, + PermissionRequest, + PermissionResponse, + PermissionResponseSanitized, + StoredGatorPermission, + StoredGatorPermissionSanitized, + GatorPermissionsMap, + SupportedGatorPermissionType, + GatorPermissionsMapByPermissionType, + GatorPermissionsListByPermissionTypeAndChainId, +} from './types'; diff --git a/packages/gator-permissions-controller/src/logger.ts b/packages/gator-permissions-controller/src/logger.ts new file mode 100644 index 0000000000..03445d678e --- /dev/null +++ b/packages/gator-permissions-controller/src/logger.ts @@ -0,0 +1,16 @@ +/* istanbul ignore file */ + +import { createProjectLogger, createModuleLogger } from '@metamask/utils'; + +export const projectLogger = createProjectLogger( + 'gator-permissions-controller', +); + +export const controllerLog = createModuleLogger( + projectLogger, + 'GatorPermissionsController', +); + +export const utilsLog = createModuleLogger(projectLogger, 'utils'); + +export { createModuleLogger }; diff --git a/packages/gator-permissions-controller/src/test/mock.test.ts b/packages/gator-permissions-controller/src/test/mock.test.ts new file mode 100644 index 0000000000..eb0ff3ca6b --- /dev/null +++ b/packages/gator-permissions-controller/src/test/mock.test.ts @@ -0,0 +1,371 @@ +import { + mockGatorPermissionsStorageEntriesFactory, + type MockGatorPermissionsStorageEntriesConfig, +} from './mocks'; + +describe('mockGatorPermissionsStorageEntriesFactory', () => { + it('should create mock storage entries for all permission types', () => { + const config: MockGatorPermissionsStorageEntriesConfig = { + '0x1': { + nativeTokenStream: 2, + nativeTokenPeriodic: 1, + erc20TokenStream: 3, + erc20TokenPeriodic: 1, + custom: { + count: 2, + data: [ + { customField1: 'value1', customField2: 123 }, + { customField3: 'value3', customField4: true }, + ], + }, + }, + '0x5': { + nativeTokenStream: 1, + nativeTokenPeriodic: 2, + erc20TokenStream: 1, + erc20TokenPeriodic: 2, + custom: { + count: 1, + data: [{ customField5: 'value5' }], + }, + }, + }; + + const result = mockGatorPermissionsStorageEntriesFactory(config); + + expect(result).toHaveLength(16); + + // Check that entries have different expiry times + const expiryTimes = result.map((entry) => entry.permissionResponse.expiry); + const uniqueExpiryTimes = new Set(expiryTimes); + expect(uniqueExpiryTimes.size).toBe(16); + + // Check that all entries have the correct chainId + const chainIds = result.map((entry) => entry.permissionResponse.chainId); + expect(chainIds).toContain('0x1'); + expect(chainIds).toContain('0x5'); + }); + + it('should create entries with correct permission types', () => { + const config: MockGatorPermissionsStorageEntriesConfig = { + '0x1': { + nativeTokenStream: 1, + nativeTokenPeriodic: 1, + erc20TokenStream: 1, + erc20TokenPeriodic: 1, + custom: { + count: 1, + data: [{ testField: 'testValue' }], + }, + }, + }; + + const result = mockGatorPermissionsStorageEntriesFactory(config); + + expect(result).toHaveLength(5); + + // Check native-token-stream permission + const nativeTokenStreamEntry = result.find( + (entry) => + entry.permissionResponse.permission.type === 'native-token-stream', + ); + expect(nativeTokenStreamEntry).toBeDefined(); + expect( + nativeTokenStreamEntry?.permissionResponse.permission.data, + ).toMatchObject({ + maxAmount: '0x22b1c8c1227a0000', + initialAmount: '0x6f05b59d3b20000', + amountPerSecond: '0x6f05b59d3b20000', + startTime: 1747699200, + justification: + 'This is a very important request for streaming allowance for some very important thing', + }); + + // Check native-token-periodic permission + const nativeTokenPeriodicEntry = result.find( + (entry) => + entry.permissionResponse.permission.type === 'native-token-periodic', + ); + expect(nativeTokenPeriodicEntry).toBeDefined(); + expect( + nativeTokenPeriodicEntry?.permissionResponse.permission.data, + ).toMatchObject({ + periodAmount: '0x22b1c8c1227a0000', + periodDuration: 1747699200, + startTime: 1747699200, + justification: + 'This is a very important request for streaming allowance for some very important thing', + }); + + // Check erc20-token-stream permission + const erc20TokenStreamEntry = result.find( + (entry) => + entry.permissionResponse.permission.type === 'erc20-token-stream', + ); + expect(erc20TokenStreamEntry).toBeDefined(); + expect( + erc20TokenStreamEntry?.permissionResponse.permission.data, + ).toMatchObject({ + initialAmount: '0x22b1c8c1227a0000', + maxAmount: '0x6f05b59d3b20000', + amountPerSecond: '0x6f05b59d3b20000', + startTime: 1747699200, + tokenAddress: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + justification: + 'This is a very important request for streaming allowance for some very important thing', + }); + + // Check erc20-token-periodic permission + const erc20TokenPeriodicEntry = result.find( + (entry) => + entry.permissionResponse.permission.type === 'erc20-token-periodic', + ); + expect(erc20TokenPeriodicEntry).toBeDefined(); + expect( + erc20TokenPeriodicEntry?.permissionResponse.permission.data, + ).toMatchObject({ + periodAmount: '0x22b1c8c1227a0000', + periodDuration: 1747699200, + startTime: 1747699200, + tokenAddress: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + justification: + 'This is a very important request for streaming allowance for some very important thing', + }); + + // Check custom permission + const customEntry = result.find( + (entry) => entry.permissionResponse.permission.type === 'custom', + ); + expect(customEntry).toBeDefined(); + expect(customEntry?.permissionResponse.permission.data).toMatchObject({ + justification: + 'This is a very important request for streaming allowance for some very important thing', + testField: 'testValue', + }); + }); + + it('should handle empty counts for all permission types', () => { + const config: MockGatorPermissionsStorageEntriesConfig = { + '0x1': { + nativeTokenStream: 0, + nativeTokenPeriodic: 0, + erc20TokenStream: 0, + erc20TokenPeriodic: 0, + custom: { + count: 0, + data: [], + }, + }, + }; + + const result = mockGatorPermissionsStorageEntriesFactory(config); + + expect(result).toHaveLength(0); + }); + + it('should handle multiple chain IDs', () => { + const config: MockGatorPermissionsStorageEntriesConfig = { + '0x1': { + nativeTokenStream: 1, + nativeTokenPeriodic: 0, + erc20TokenStream: 0, + erc20TokenPeriodic: 0, + custom: { + count: 0, + data: [], + }, + }, + '0x5': { + nativeTokenStream: 0, + nativeTokenPeriodic: 1, + erc20TokenStream: 0, + erc20TokenPeriodic: 0, + custom: { + count: 0, + data: [], + }, + }, + '0xa': { + nativeTokenStream: 0, + nativeTokenPeriodic: 0, + erc20TokenStream: 1, + erc20TokenPeriodic: 0, + custom: { + count: 0, + data: [], + }, + }, + }; + + const result = mockGatorPermissionsStorageEntriesFactory(config); + + expect(result).toHaveLength(3); + + // Check that each chain ID is represented + const chainIds = result.map((entry) => entry.permissionResponse.chainId); + expect(chainIds).toContain('0x1'); + expect(chainIds).toContain('0x5'); + expect(chainIds).toContain('0xa'); + + // Check that each entry has the correct permission type for its chain + const chain0x1Entry = result.find( + (entry) => entry.permissionResponse.chainId === '0x1', + ); + expect(chain0x1Entry?.permissionResponse.permission.type).toBe( + 'native-token-stream', + ); + + const chain0x5Entry = result.find( + (entry) => entry.permissionResponse.chainId === '0x5', + ); + expect(chain0x5Entry?.permissionResponse.permission.type).toBe( + 'native-token-periodic', + ); + + const chain0xaEntry = result.find( + (entry) => entry.permissionResponse.chainId === '0xa', + ); + expect(chain0xaEntry?.permissionResponse.permission.type).toBe( + 'erc20-token-stream', + ); + }); + + it('should handle custom permissions with different data', () => { + const config: MockGatorPermissionsStorageEntriesConfig = { + '0x1': { + nativeTokenStream: 0, + nativeTokenPeriodic: 0, + erc20TokenStream: 0, + erc20TokenPeriodic: 0, + custom: { + count: 3, + data: [ + { field1: 'value1', number1: 123 }, + { field2: 'value2', boolean1: true }, + { field3: 'value3', object1: { nested: 'value' } }, + ], + }, + }, + }; + + const result = mockGatorPermissionsStorageEntriesFactory(config); + + expect(result).toHaveLength(3); + + // Check that all entries are custom permissions + const permissionTypes = result.map( + (entry) => entry.permissionResponse.permission.type, + ); + expect(permissionTypes.every((type) => type === 'custom')).toBe(true); + + // Check that each entry has the correct custom data + const customData = result.map( + (entry) => entry.permissionResponse.permission.data, + ); + expect(customData[0]).toMatchObject({ + justification: + 'This is a very important request for streaming allowance for some very important thing', + field1: 'value1', + number1: 123, + }); + expect(customData[1]).toMatchObject({ + justification: + 'This is a very important request for streaming allowance for some very important thing', + field2: 'value2', + boolean1: true, + }); + expect(customData[2]).toMatchObject({ + justification: + 'This is a very important request for streaming allowance for some very important thing', + field3: 'value3', + object1: { nested: 'value' }, + }); + }); + + it('should throw error when custom count and data length mismatch', () => { + const config: MockGatorPermissionsStorageEntriesConfig = { + '0x1': { + nativeTokenStream: 0, + nativeTokenPeriodic: 0, + erc20TokenStream: 0, + erc20TokenPeriodic: 0, + custom: { + count: 2, + data: [{ field1: 'value1' }], + }, + }, + }; + + expect(() => mockGatorPermissionsStorageEntriesFactory(config)).toThrow( + 'Custom permission count and data length mismatch', + ); + }); + + it('should handle complex configuration with multiple chain IDs and permission types', () => { + const config: MockGatorPermissionsStorageEntriesConfig = { + '0x1': { + nativeTokenStream: 2, + nativeTokenPeriodic: 1, + erc20TokenStream: 1, + erc20TokenPeriodic: 2, + custom: { + count: 1, + data: [{ complexField: { nested: { deep: 'value' } } }], + }, + }, + '0x5': { + nativeTokenStream: 1, + nativeTokenPeriodic: 3, + erc20TokenStream: 2, + erc20TokenPeriodic: 1, + custom: { + count: 2, + data: [{ arrayField: [1, 2, 3] }, { nullField: null }], + }, + }, + }; + + const result = mockGatorPermissionsStorageEntriesFactory(config); + + // Total expected entries + expect(result).toHaveLength(16); + + // Verify all entries have unique expiry times + const expiryTimes = result.map((entry) => entry.permissionResponse.expiry); + const uniqueExpiryTimes = new Set(expiryTimes); + expect(uniqueExpiryTimes.size).toBe(16); + + // Verify chain IDs are correct + const chainIds = result.map((entry) => entry.permissionResponse.chainId); + const chain0x1Count = chainIds.filter((id) => id === '0x1').length; + const chain0x5Count = chainIds.filter((id) => id === '0x5').length; + expect(chain0x1Count).toBe(7); + expect(chain0x5Count).toBe(9); + + // Verify permission types are distributed correctly + const permissionTypes = result.map( + (entry) => entry.permissionResponse.permission.type, + ); + const nativeTokenStreamCount = permissionTypes.filter( + (type) => type === 'native-token-stream', + ).length; + const nativeTokenPeriodicCount = permissionTypes.filter( + (type) => type === 'native-token-periodic', + ).length; + const erc20TokenStreamCount = permissionTypes.filter( + (type) => type === 'erc20-token-stream', + ).length; + const erc20TokenPeriodicCount = permissionTypes.filter( + (type) => type === 'erc20-token-periodic', + ).length; + const customCount = permissionTypes.filter( + (type) => type === 'custom', + ).length; + + expect(nativeTokenStreamCount).toBe(3); + expect(nativeTokenPeriodicCount).toBe(4); + expect(erc20TokenStreamCount).toBe(3); + expect(erc20TokenPeriodicCount).toBe(3); + expect(customCount).toBe(3); + }); +}); diff --git a/packages/gator-permissions-controller/src/test/mocks.ts b/packages/gator-permissions-controller/src/test/mocks.ts new file mode 100644 index 0000000000..e64a7be832 --- /dev/null +++ b/packages/gator-permissions-controller/src/test/mocks.ts @@ -0,0 +1,285 @@ +import type { Hex } from '@metamask/utils'; + +import type { + AccountSigner, + CustomPermission, + Erc20TokenPeriodicPermission, + Erc20TokenStreamPermission, + NativeTokenPeriodicPermission, + NativeTokenStreamPermission, + PermissionTypes, + StoredGatorPermission, +} from '../types'; + +export const mockNativeTokenStreamStorageEntry = ( + chainId: Hex, +): StoredGatorPermission => ({ + permissionResponse: { + chainId: chainId as Hex, + address: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + expiry: 1750291201, + isAdjustmentAllowed: true, + signer: { + type: 'account', + data: { address: '0x4f71DA06987BfeDE90aF0b33E1e3e4ffDCEE7a63' }, + }, + permission: { + type: 'native-token-stream', + data: { + maxAmount: '0x22b1c8c1227a0000', + initialAmount: '0x6f05b59d3b20000', + amountPerSecond: '0x6f05b59d3b20000', + startTime: 1747699200, + justification: + 'This is a very important request for streaming allowance for some very important thing', + }, + rules: {}, + }, + context: '0x00000000', + accountMeta: [ + { + factory: '0x69Aa2f9fe1572F1B640E1bbc512f5c3a734fc77c', + factoryData: '0x0000000', + }, + ], + signerMeta: { + delegationManager: '0xdb9B1e94B5b69Df7e401DDbedE43491141047dB3', + }, + }, + siteOrigin: 'http://localhost:8000', +}); + +export const mockNativeTokenPeriodicStorageEntry = ( + chainId: Hex, +): StoredGatorPermission => ({ + permissionResponse: { + chainId: chainId as Hex, + address: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + expiry: 1850291200, + isAdjustmentAllowed: true, + signer: { + type: 'account', + data: { address: '0x4f71DA06987BfeDE90aF0b33E1e3e4ffDCEE7a63' }, + }, + permission: { + type: 'native-token-periodic', + data: { + periodAmount: '0x22b1c8c1227a0000', + periodDuration: 1747699200, + startTime: 1747699200, + justification: + 'This is a very important request for streaming allowance for some very important thing', + }, + rules: {}, + }, + context: '0x00000000', + accountMeta: [ + { + factory: '0x69Aa2f9fe1572F1B640E1bbc512f5c3a734fc77c', + factoryData: '0x0000000', + }, + ], + signerMeta: { + delegationManager: '0xdb9B1e94B5b69Df7e401DDbedE43491141047dB3', + }, + }, + siteOrigin: 'http://localhost:8000', +}); + +export const mockErc20TokenStreamStorageEntry = ( + chainId: Hex, +): StoredGatorPermission => ({ + permissionResponse: { + chainId: chainId as Hex, + address: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + expiry: 1750298200, + isAdjustmentAllowed: true, + signer: { + type: 'account', + data: { address: '0x4f71DA06987BfeDE90aF0b33E1e3e4ffDCEE7a63' }, + }, + permission: { + type: 'erc20-token-stream', + data: { + initialAmount: '0x22b1c8c1227a0000', + maxAmount: '0x6f05b59d3b20000', + amountPerSecond: '0x6f05b59d3b20000', + startTime: 1747699200, + tokenAddress: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + justification: + 'This is a very important request for streaming allowance for some very important thing', + }, + rules: {}, + }, + context: '0x00000000', + accountMeta: [ + { + factory: '0x69Aa2f9fe1572F1B640E1bbc512f5c3a734fc77c', + factoryData: '0x0000000', + }, + ], + signerMeta: { + delegationManager: '0xdb9B1e94B5b69Df7e401DDbedE43491141047dB3', + }, + }, + siteOrigin: 'http://localhost:8000', +}); + +export const mockErc20TokenPeriodicStorageEntry = ( + chainId: Hex, +): StoredGatorPermission => ({ + permissionResponse: { + chainId: chainId as Hex, + address: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + expiry: 1750291600, + isAdjustmentAllowed: true, + signer: { + type: 'account', + data: { address: '0x4f71DA06987BfeDE90aF0b33E1e3e4ffDCEE7a63' }, + }, + permission: { + type: 'erc20-token-periodic', + data: { + periodAmount: '0x22b1c8c1227a0000', + periodDuration: 1747699200, + startTime: 1747699200, + tokenAddress: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + justification: + 'This is a very important request for streaming allowance for some very important thing', + }, + rules: {}, + }, + context: '0x00000000', + accountMeta: [ + { + factory: '0x69Aa2f9fe1572F1B640E1bbc512f5c3a734fc77c', + factoryData: '0x0000000', + }, + ], + signerMeta: { + delegationManager: '0xdb9B1e94B5b69Df7e401DDbedE43491141047dB3', + }, + }, + siteOrigin: 'http://localhost:8000', +}); + +export const mockCustomPermissionStorageEntry = ( + chainId: Hex, + data: Record, +): StoredGatorPermission => ({ + permissionResponse: { + chainId: chainId as Hex, + address: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + expiry: 1750291200, + isAdjustmentAllowed: true, + signer: { + type: 'account', + data: { address: '0x4f71DA06987BfeDE90aF0b33E1e3e4ffDCEE7a63' }, + }, + permission: { + type: 'custom', + data: { + justification: + 'This is a very important request for streaming allowance for some very important thing', + ...data, + }, + rules: {}, + }, + context: '0x00000000', + accountMeta: [ + { + factory: '0x69Aa2f9fe1572F1B640E1bbc512f5c3a734fc77c', + factoryData: '0x0000000', + }, + ], + signerMeta: { + delegationManager: '0xdb9B1e94B5b69Df7e401DDbedE43491141047dB3', + }, + }, + siteOrigin: 'http://localhost:8000', +}); + +export type MockGatorPermissionsStorageEntriesConfig = { + [chainId: string]: { + nativeTokenStream: number; + nativeTokenPeriodic: number; + erc20TokenStream: number; + erc20TokenPeriodic: number; + custom: { + count: number; + data: Record[]; + }; + }; +}; + +/** + * Creates a mock gator permissions storage entry + * + * @param config - The config for the mock gator permissions storage entries. + * @returns Mock gator permissions storage entry + */ +/** + * Creates mock gator permissions storage entries with unique expiry times + * + * @param config - The config for the mock gator permissions storage entries. + * @returns Mock gator permissions storage entries + */ +export function mockGatorPermissionsStorageEntriesFactory( + config: MockGatorPermissionsStorageEntriesConfig, +): StoredGatorPermission[] { + const result: StoredGatorPermission[] = []; + let globalIndex = 0; + + Object.entries(config).forEach(([chainId, counts]) => { + if (counts.custom.count !== counts.custom.data.length) { + throw new Error('Custom permission count and data length mismatch'); + } + + /** + * Creates a number of entries with unique expiry times + * + * @param count - The number of entries to create. + * @param createEntry - The function to create an entry. + */ + const createEntries = ( + count: number, + createEntry: () => StoredGatorPermission, + ) => { + for (let i = 0; i < count; i++) { + const entry = createEntry(); + entry.permissionResponse.expiry += globalIndex; + result.push(entry); + globalIndex += 1; + } + }; + + createEntries(counts.nativeTokenStream, () => + mockNativeTokenStreamStorageEntry(chainId as Hex), + ); + + createEntries(counts.nativeTokenPeriodic, () => + mockNativeTokenPeriodicStorageEntry(chainId as Hex), + ); + + createEntries(counts.erc20TokenStream, () => + mockErc20TokenStreamStorageEntry(chainId as Hex), + ); + + createEntries(counts.erc20TokenPeriodic, () => + mockErc20TokenPeriodicStorageEntry(chainId as Hex), + ); + + // Create custom entries + for (let i = 0; i < counts.custom.count; i++) { + const entry = mockCustomPermissionStorageEntry( + chainId as Hex, + counts.custom.data[i], + ); + entry.permissionResponse.expiry += globalIndex; + result.push(entry); + globalIndex += 1; + } + }); + + return result; +} diff --git a/packages/gator-permissions-controller/src/types.ts b/packages/gator-permissions-controller/src/types.ts new file mode 100644 index 0000000000..66d3052e42 --- /dev/null +++ b/packages/gator-permissions-controller/src/types.ts @@ -0,0 +1,295 @@ +import type { Hex } from '@metamask/utils'; + +/** + * Enum for the error codes of the gator permissions controller. + */ +export enum GatorPermissionsControllerErrorCode { + GatorPermissionsFetchError = 'gator-permissions-fetch-error', + GatorPermissionsNotEnabled = 'gator-permissions-not-enabled', + GatorPermissionsProviderError = 'gator-permissions-provider-error', + GatorPermissionsMapSerializationError = 'gator-permissions-map-serialization-error', +} + +/** + * Enum for the RPC methods of the gator permissions provider snap. + */ +export enum GatorPermissionsSnapRpcMethod { + /** + * This method is used by the metamask to request a permissions provider to get granted permissions for all sites. + */ + PermissionProviderGetGrantedPermissions = 'permissionsProvider_getGrantedPermissions', +} + +type BasePermission = { + type: string; + + /** + * Data structure varies by permission type. + */ + data: Record; + + /** + * set of restrictions or conditions that a signer must abide by when redeeming a Permission. + */ + rules?: Record; +}; + +export type MetaMaskBasePermissionData = { + /** + * A human-readable explanation of why the permission is being requested. + */ + justification: string; +}; + +export type NativeTokenStreamPermission = BasePermission & { + type: 'native-token-stream'; + data: MetaMaskBasePermissionData & { + initialAmount?: Hex; + maxAmount?: Hex; + amountPerSecond: Hex; + startTime: number; + }; +}; + +export type NativeTokenPeriodicPermission = BasePermission & { + type: 'native-token-periodic'; + data: MetaMaskBasePermissionData & { + periodAmount: Hex; + periodDuration: number; + startTime: number; + }; +}; + +export type Erc20TokenStreamPermission = BasePermission & { + type: 'erc20-token-stream'; + data: MetaMaskBasePermissionData & { + initialAmount?: Hex; + maxAmount?: Hex; + amountPerSecond: Hex; + startTime: number; + tokenAddress: Hex; + }; +}; + +export type Erc20TokenPeriodicPermission = BasePermission & { + type: 'erc20-token-periodic'; + data: MetaMaskBasePermissionData & { + periodAmount: Hex; + periodDuration: number; + startTime: number; + tokenAddress: Hex; + }; +}; + +export type CustomPermission = BasePermission & { + type: 'custom'; + data: MetaMaskBasePermissionData & Record; +}; + +/** + * Represents the type of the ERC-7715 permissions that can be granted. + */ +export type PermissionTypes = + | NativeTokenStreamPermission + | NativeTokenPeriodicPermission + | Erc20TokenStreamPermission + | Erc20TokenPeriodicPermission + | CustomPermission; + +/** + * Represents an ERC-7715 account signer type. + */ +export type AccountSigner = { + type: 'account'; + data: { + address: Hex; + }; +}; + +/** + * Represents an ERC-7715 wallet signer type. + * + */ +export type WalletSigner = { + type: 'wallet'; + data: Record; +}; + +export type SignerParam = AccountSigner | WalletSigner; + +/** + * Represents a ERC-7715 permission request. + * + * @template Signer - The type of the signer provided, either an AccountSigner or WalletSigner. + * @template Permission - The type of the permission provided. + */ +export type PermissionRequest< + TSigner extends SignerParam, + TPermission extends PermissionTypes, +> = { + /** + * hex-encoding of uint256 defined the chain with EIP-155 + */ + chainId: Hex; + + /** + * + * The account being targeted for this permission request. + * It is optional to let the user choose which account to grant permission from. + */ + address?: Hex; + + /** + * unix timestamp in seconds + */ + expiry: number; + + /** + * Boolean value that allows DApp to define whether the permission can be attenuated–adjusted to meet the user’s terms. + */ + isAdjustmentAllowed: boolean; + + /** + * An account that is associated with the recipient of the granted 7715 permission or alternatively the wallet will manage the session. + */ + signer: TSigner; + + /** + * Defines the allowed behavior the signer can do on behalf of the account. + */ + permission: TPermission; +}; + +/** + * Represents a ERC-7715 permission response. + * + * @template Signer - The type of the signer provided, either an AccountSigner or WalletSigner. + * @template Permission - The type of the permission provided. + */ +export type PermissionResponse< + TSigner extends SignerParam, + TPermission extends PermissionTypes, +> = PermissionRequest & { + /** + * Is a catch-all to identify a permission for revoking permissions or submitting + * Defined in ERC-7710. + */ + context: Hex; + + /** + * The accountMeta field is required and contains information needed to deploy accounts. + * Each entry specifies a factory contract and its associated deployment data. + * If no account deployment is needed when redeeming the permission, this array must be empty. + * When non-empty, DApps MUST deploy the accounts by calling the factory contract with factoryData as the calldata. + * Defined in ERC-4337. + */ + accountMeta: { + factory: Hex; + factoryData: Hex; + }[]; + + /** + * If the signer type is account then delegationManager is required as defined in ERC-7710. + */ + signerMeta: { + delegationManager: Hex; + }; +}; + +/** + * Represents a sanitized version of the PermissionResponse type. + * Some fields have been removed but the fields are still present in profile sync. + * + * @template Signer - The type of the signer provided, either an AccountSigner or WalletSigner. + * @template Permission - The type of the permission provided. + */ +export type PermissionResponseSanitized< + TSigner extends SignerParam, + TPermission extends PermissionTypes, +> = Omit< + PermissionResponse, + 'isAdjustmentAllowed' | 'accountMeta' | 'signer' +>; + +/** + * Represents a gator ERC-7715 granted(ie. signed by an user account) permission entry that is stored in profile sync. + * + * @template Signer - The type of the signer provided, either an AccountSigner or WalletSigner. + * @template Permission - The type of the permission provided + */ +export type StoredGatorPermission< + TSigner extends SignerParam, + TPermission extends PermissionTypes, +> = { + permissionResponse: PermissionResponse; + siteOrigin: string; +}; + +/** + * Represents a sanitized version of the StoredGatorPermission type. Some fields have been removed but the fields are still present in profile sync. + * + * @template Signer - The type of the signer provided, either an AccountSigner or WalletSigner. + * @template Permission - The type of the permission provided. + */ +export type StoredGatorPermissionSanitized< + TSigner extends SignerParam, + TPermission extends PermissionTypes, +> = { + permissionResponse: PermissionResponseSanitized; + siteOrigin: string; +}; + +/** + * Represents a map of gator permissions by chainId and permission type. + */ +export type GatorPermissionsMap = { + 'native-token-stream': { + [chainId: Hex]: StoredGatorPermissionSanitized< + SignerParam, + NativeTokenStreamPermission + >[]; + }; + 'native-token-periodic': { + [chainId: Hex]: StoredGatorPermissionSanitized< + SignerParam, + NativeTokenPeriodicPermission + >[]; + }; + 'erc20-token-stream': { + [chainId: Hex]: StoredGatorPermissionSanitized< + SignerParam, + Erc20TokenStreamPermission + >[]; + }; + 'erc20-token-periodic': { + [chainId: Hex]: StoredGatorPermissionSanitized< + SignerParam, + Erc20TokenPeriodicPermission + >[]; + }; + other: { + [chainId: Hex]: StoredGatorPermissionSanitized< + SignerParam, + CustomPermission + >[]; + }; +}; + +/** + * Represents the supported permission type(e.g. 'native-token-stream', 'native-token-periodic', 'erc20-token-stream', 'erc20-token-periodic') of the gator permissions map. + */ +export type SupportedGatorPermissionType = keyof GatorPermissionsMap; + +/** + * Represents a map of gator permissions for a given permission type with key of chainId. The value being an array of gator permissions for that chainId. + */ +export type GatorPermissionsMapByPermissionType< + TPermissionType extends SupportedGatorPermissionType, +> = GatorPermissionsMap[TPermissionType]; + +/** + * Represents an array of gator permissions for a given permission type and chainId. + */ +export type GatorPermissionsListByPermissionTypeAndChainId< + TPermissionType extends SupportedGatorPermissionType, +> = GatorPermissionsMap[TPermissionType][Hex]; diff --git a/packages/gator-permissions-controller/src/utils.test.ts b/packages/gator-permissions-controller/src/utils.test.ts new file mode 100644 index 0000000000..81a5f73259 --- /dev/null +++ b/packages/gator-permissions-controller/src/utils.test.ts @@ -0,0 +1,68 @@ +import type { GatorPermissionsMap } from './types'; +import { + deserializeGatorPermissionsMap, + serializeGatorPermissionsMap, +} from './utils'; + +const defaultGatorPermissionsMap: GatorPermissionsMap = { + 'native-token-stream': {}, + 'native-token-periodic': {}, + 'erc20-token-stream': {}, + 'erc20-token-periodic': {}, + other: {}, +}; + +describe('utils - serializeGatorPermissionsMap() tests', () => { + it('serializes a gator permissions list to a string', () => { + const serializedGatorPermissionsMap = serializeGatorPermissionsMap( + defaultGatorPermissionsMap, + ); + + expect(serializedGatorPermissionsMap).toStrictEqual( + JSON.stringify(defaultGatorPermissionsMap), + ); + }); + + it('throws an error when serialization fails', () => { + // Create a valid GatorPermissionsMap structure but with circular reference + const gatorPermissionsMap = { + 'native-token-stream': {}, + 'native-token-periodic': {}, + 'erc20-token-stream': {}, + 'erc20-token-periodic': {}, + other: {}, + }; + + // Add circular reference to cause JSON.stringify to fail + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (gatorPermissionsMap as any).circular = gatorPermissionsMap; + + expect(() => { + serializeGatorPermissionsMap(gatorPermissionsMap); + }).toThrow('Failed to serialize gator permissions map'); + }); +}); + +describe('utils - deserializeGatorPermissionsMap() tests', () => { + it('deserializes a gator permissions list from a string', () => { + const serializedGatorPermissionsMap = serializeGatorPermissionsMap( + defaultGatorPermissionsMap, + ); + + const deserializedGatorPermissionsMap = deserializeGatorPermissionsMap( + serializedGatorPermissionsMap, + ); + + expect(deserializedGatorPermissionsMap).toStrictEqual( + defaultGatorPermissionsMap, + ); + }); + + it('throws an error when deserialization fails', () => { + const invalidJson = '{"invalid": json}'; + + expect(() => { + deserializeGatorPermissionsMap(invalidJson); + }).toThrow('Failed to deserialize gator permissions map'); + }); +}); diff --git a/packages/gator-permissions-controller/src/utils.ts b/packages/gator-permissions-controller/src/utils.ts new file mode 100644 index 0000000000..50ee6851f4 --- /dev/null +++ b/packages/gator-permissions-controller/src/utils.ts @@ -0,0 +1,45 @@ +import { GatorPermissionsMapSerializationError } from './errors'; +import { utilsLog } from './logger'; +import type { GatorPermissionsMap } from './types'; + +/** + * Serializes a gator permissions map to a string. + * + * @param gatorPermissionsMap - The gator permissions map to serialize. + * @returns The serialized gator permissions map. + */ +export function serializeGatorPermissionsMap( + gatorPermissionsMap: GatorPermissionsMap, +): string { + try { + return JSON.stringify(gatorPermissionsMap); + } catch (error) { + utilsLog('Failed to serialize gator permissions map', error); + throw new GatorPermissionsMapSerializationError({ + cause: error as Error, + message: 'Failed to serialize gator permissions map', + data: gatorPermissionsMap, + }); + } +} + +/** + * Deserializes a gator permissions map from a string. + * + * @param gatorPermissionsMap - The gator permissions map to deserialize. + * @returns The deserialized gator permissions map. + */ +export function deserializeGatorPermissionsMap( + gatorPermissionsMap: string, +): GatorPermissionsMap { + try { + return JSON.parse(gatorPermissionsMap); + } catch (error) { + utilsLog('Failed to deserialize gator permissions map', error); + throw new GatorPermissionsMapSerializationError({ + cause: error as Error, + message: 'Failed to deserialize gator permissions map', + data: gatorPermissionsMap, + }); + } +} diff --git a/packages/gator-permissions-controller/tsconfig.build.json b/packages/gator-permissions-controller/tsconfig.build.json new file mode 100644 index 0000000000..e5fd7422b9 --- /dev/null +++ b/packages/gator-permissions-controller/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [{ "path": "../base-controller/tsconfig.build.json" }], + "include": ["../../types", "./src"] +} diff --git a/packages/gator-permissions-controller/tsconfig.json b/packages/gator-permissions-controller/tsconfig.json new file mode 100644 index 0000000000..34354c4b09 --- /dev/null +++ b/packages/gator-permissions-controller/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [{ "path": "../base-controller" }], + "include": ["../../types", "./src"] +} diff --git a/packages/gator-permissions-controller/typedoc.json b/packages/gator-permissions-controller/typedoc.json new file mode 100644 index 0000000000..c9da015dbf --- /dev/null +++ b/packages/gator-permissions-controller/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/teams.json b/teams.json index 9ddb4cca90..cecfab5edc 100644 --- a/teams.json +++ b/teams.json @@ -18,6 +18,7 @@ "metamask/ens-controller": "team-confirmations", "metamask/eth-json-rpc-provider": "team-wallet-api-platform,team-wallet-framework", "metamask/gas-fee-controller": "team-confirmations", + "metamask/gator-permissions-controller": "team-delegation", "metamask/json-rpc-engine": "team-wallet-api-platform,team-wallet-framework", "metamask/json-rpc-middleware-stream": "team-wallet-api-platform,team-wallet-framework", "metamask/keyring-controller": "team-accounts", diff --git a/tsconfig.build.json b/tsconfig.build.json index 7043053d15..425db34ed1 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -22,6 +22,7 @@ { "path": "./packages/eth-json-rpc-provider/tsconfig.build.json" }, { "path": "./packages/foundryup/tsconfig.build.json" }, { "path": "./packages/gas-fee-controller/tsconfig.build.json" }, + { "path": "./packages/gator-permissions-controller/tsconfig.build.json" }, { "path": "./packages/json-rpc-engine/tsconfig.build.json" }, { "path": "./packages/json-rpc-middleware-stream/tsconfig.build.json" }, { "path": "./packages/keyring-controller/tsconfig.build.json" }, diff --git a/tsconfig.json b/tsconfig.json index 26142f9b96..43fb59c213 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -28,6 +28,7 @@ { "path": "./packages/eth-json-rpc-provider" }, { "path": "./packages/foundryup" }, { "path": "./packages/gas-fee-controller" }, + { "path": "./packages/gator-permissions-controller" }, { "path": "./packages/json-rpc-engine" }, { "path": "./packages/json-rpc-middleware-stream" }, { "path": "./packages/keyring-controller" }, diff --git a/yarn.lock b/yarn.lock index d4d451bc95..ff77585a79 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3536,6 +3536,31 @@ __metadata: languageName: unknown linkType: soft +"@metamask/gator-permissions-controller@workspace:packages/gator-permissions-controller": + version: 0.0.0-use.local + resolution: "@metamask/gator-permissions-controller@workspace:packages/gator-permissions-controller" + dependencies: + "@lavamoat/allow-scripts": "npm:^3.0.4" + "@lavamoat/preinstall-always-fail": "npm:^2.1.0" + "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/base-controller": "npm:^8.1.0" + "@metamask/snaps-controllers": "npm:^14.0.1" + "@metamask/snaps-sdk": "npm:^9.0.0" + "@metamask/snaps-utils": "npm:^11.0.0" + "@metamask/utils": "npm:^11.4.2" + "@ts-bridge/cli": "npm:^0.6.1" + "@types/jest": "npm:^27.4.1" + deepmerge: "npm:^4.2.2" + jest: "npm:^27.5.1" + ts-jest: "npm:^27.1.4" + typedoc: "npm:^0.24.8" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.2.2" + peerDependencies: + "@metamask/snaps-controllers": ^14.0.1 + languageName: unknown + linkType: soft + "@metamask/json-rpc-engine@npm:^10.0.0, @metamask/json-rpc-engine@npm:^10.0.2, @metamask/json-rpc-engine@npm:^10.0.3, @metamask/json-rpc-engine@workspace:packages/json-rpc-engine": version: 0.0.0-use.local resolution: "@metamask/json-rpc-engine@workspace:packages/json-rpc-engine"