From eb12e031a3d9eed158eca298bacae7679a3a5f53 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Tue, 14 Apr 2026 14:59:12 +1200 Subject: [PATCH 1/2] After loading gator permissions, resolve status for each permission - if revocationMetadata exists, or revoked onchain - 'Revoked' - if expiration after onchain block timestamp 'Expired' - otherwise 'Active' --- .../gator-permissions-controller/package.json | 2 + .../src/GatorPermissionsController.test.ts | 207 +++++- .../src/GatorPermissionsController.ts | 78 ++- .../gator-permissions-controller/src/index.ts | 1 + .../src/permissionOnChainStatus.test.ts | 637 ++++++++++++++++++ .../src/permissionOnChainStatus.ts | 201 ++++++ .../gator-permissions-controller/src/types.ts | 9 + yarn.lock | 2 + 8 files changed, 1131 insertions(+), 6 deletions(-) create mode 100644 packages/gator-permissions-controller/src/permissionOnChainStatus.test.ts create mode 100644 packages/gator-permissions-controller/src/permissionOnChainStatus.ts diff --git a/packages/gator-permissions-controller/package.json b/packages/gator-permissions-controller/package.json index 6c6ac0260f6..d5cb4105038 100644 --- a/packages/gator-permissions-controller/package.json +++ b/packages/gator-permissions-controller/package.json @@ -54,10 +54,12 @@ }, "dependencies": { "@metamask/7715-permission-types": "^0.5.0", + "@metamask/abi-utils": "^2.0.3", "@metamask/base-controller": "^9.1.0", "@metamask/delegation-core": "^0.2.0", "@metamask/delegation-deployments": "^0.12.0", "@metamask/messenger": "^1.1.1", + "@metamask/network-controller": "^30.0.1", "@metamask/snaps-controllers": "^19.0.0", "@metamask/snaps-sdk": "^11.0.0", "@metamask/snaps-utils": "^12.1.2", diff --git a/packages/gator-permissions-controller/src/GatorPermissionsController.test.ts b/packages/gator-permissions-controller/src/GatorPermissionsController.test.ts index 69c102fdb1e..c78761ce1e3 100644 --- a/packages/gator-permissions-controller/src/GatorPermissionsController.test.ts +++ b/packages/gator-permissions-controller/src/GatorPermissionsController.test.ts @@ -2,6 +2,7 @@ import { deriveStateFromMetadata } from '@metamask/base-controller'; import { createTimestampTerms, createNativeTokenStreamingTerms, + encodeDelegations, ROOT_AUTHORITY, } from '@metamask/delegation-core'; import { @@ -35,11 +36,50 @@ import type { GatorPermissionsControllerMessenger } from './GatorPermissionsCont import { GatorPermissionsController } from './GatorPermissionsController'; import type { PermissionInfoWithMetadata, + GatorPermissionStatus, StoredGatorPermission, RevocationParams, SupportedPermissionType, } from './types'; +const PERMISSION_STATUSES: GatorPermissionStatus[] = [ + 'Active', + 'Revoked', + 'Expired', +]; + +/** + * Default JSON-RPC behavior for permission status sync tests (disabled = false, + * latest block far in the future). + * + * @returns A Jest mock function suitable as a provider `request` implementation. + */ +function createDefaultPermissionStatusProviderRequest(): jest.MockedFunction< + (args: { method: string; params?: unknown[] }) => Promise +> { + return jest.fn(async (req) => { + if (req.method === 'eth_call') { + return '0x0000000000000000000000000000000000000000000000000000000000000000'; + } + if (req.method === 'eth_getBlockByNumber') { + return { timestamp: '0x77359400' }; + } + throw new Error(`Unexpected RPC method in tests: ${req.method}`); + }); +} + +/** + * Handlers last wired by {@link getRootMessenger} for NetworkController actions + * (assertable when sync must resolve on-chain permission status). + */ +let lastNetworkControllerTestMocks: { + findNetworkClientIdByChainId: jest.Mock; + getNetworkClientById: jest.Mock; + permissionStatusProviderRequest: jest.MockedFunction< + (args: { method: string; params?: unknown[] }) => Promise + >; +} | null = null; + const MOCK_CHAIN_ID_1: Hex = '0xaa36a7'; const MOCK_CHAIN_ID_2: Hex = '0x1'; const MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID = @@ -217,6 +257,7 @@ describe('GatorPermissionsController', () => { grantedPermissions.forEach((entry) => { expect(entry.permissionResponse).toBeDefined(); expect(entry.siteOrigin).toBeDefined(); + expect(PERMISSION_STATUSES).toContain(entry.status); // Sanitized response omits internal fields (to, dependencies) expect( (entry.permissionResponse as Record).to, @@ -245,6 +286,142 @@ describe('GatorPermissionsController', () => { ]); }); + it('calls NetworkController to resolve on-chain status when snap returns a single-delegation context', async () => { + const frameworkContracts = + DELEGATOR_CONTRACTS[DELEGATION_FRAMEWORK_VERSION][CHAIN_ID.sepolia]; + const { NativeTokenStreamingEnforcer, DelegationManager } = + frameworkContracts; + + const delegation = { + delegate: '0x4f71DA06987BfeDE90aF0b33E1e3e4ffDCEE7a63', + delegator: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + authority: ROOT_AUTHORITY, + caveats: [ + { + enforcer: NativeTokenStreamingEnforcer, + terms: createNativeTokenStreamingTerms({ + initialAmount: hexToBigInt('0x6f05b59d3b20000'), + maxAmount: hexToBigInt('0x22b1c8c1227a0000'), + amountPerSecond: hexToBigInt('0x6f05b59d3b20000'), + startTime: 1747699200, + }), + args: '0x', + }, + ], + salt: 0n, + signature: '0x' as Hex, + }; + const encodedContext = encodeDelegations([delegation]); + const base = mockNativeTokenStreamStorageEntry(MOCK_CHAIN_ID_1); + const storedEntry = { + ...base, + permissionResponse: { + ...base.permissionResponse, + context: encodedContext, + delegationManager: DelegationManager, + }, + }; + + const rootMessenger = getRootMessenger({ + snapControllerHandleRequestActionHandler: jest + .fn() + .mockResolvedValue([storedEntry]), + }); + + const controller = new GatorPermissionsController({ + messenger: getGatorPermissionsControllerMessenger(rootMessenger), + config: DEFAULT_TEST_CONFIG, + }); + + await rootMessenger.call( + 'GatorPermissionsController:fetchAndUpdateGatorPermissions', + ); + + expect(lastNetworkControllerTestMocks).not.toBeNull(); + expect( + lastNetworkControllerTestMocks?.findNetworkClientIdByChainId, + ).toHaveBeenCalledWith(MOCK_CHAIN_ID_1); + expect( + lastNetworkControllerTestMocks?.getNetworkClientById, + ).toHaveBeenCalledWith('test-network-client-id'); + expect( + lastNetworkControllerTestMocks?.permissionStatusProviderRequest, + ).toHaveBeenCalled(); + + expect(controller.state.grantedPermissions).toHaveLength(1); + expect(controller.state.grantedPermissions[0].status).toBe('Active'); + }); + + it('defaults merged status to Active when persisted row omits status for the same context', async () => { + const frameworkContracts = + DELEGATOR_CONTRACTS[DELEGATION_FRAMEWORK_VERSION][CHAIN_ID.sepolia]; + const { NativeTokenStreamingEnforcer, DelegationManager } = + frameworkContracts; + + const delegation = { + delegate: '0x4f71DA06987BfeDE90aF0b33E1e3e4ffDCEE7a63', + delegator: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + authority: ROOT_AUTHORITY, + caveats: [ + { + enforcer: NativeTokenStreamingEnforcer, + terms: createNativeTokenStreamingTerms({ + initialAmount: hexToBigInt('0x6f05b59d3b20000'), + maxAmount: hexToBigInt('0x22b1c8c1227a0000'), + amountPerSecond: hexToBigInt('0x6f05b59d3b20000'), + startTime: 1747699200, + }), + args: '0x', + }, + ], + salt: 0n, + signature: '0x' as Hex, + }; + const encodedContext = encodeDelegations([delegation]); + const base = mockNativeTokenStreamStorageEntry(MOCK_CHAIN_ID_1); + const storedEntry = { + ...base, + permissionResponse: { + ...base.permissionResponse, + context: encodedContext, + delegationManager: DelegationManager, + }, + }; + + const persistedRowMissingStatus = { + permissionResponse: { + chainId: storedEntry.permissionResponse.chainId, + from: storedEntry.permissionResponse.from, + permission: storedEntry.permissionResponse.permission, + context: encodedContext, + delegationManager: DelegationManager, + }, + siteOrigin: storedEntry.siteOrigin, + } as PermissionInfoWithMetadata; + + const rootMessenger = getRootMessenger({ + snapControllerHandleRequestActionHandler: jest + .fn() + .mockResolvedValue([storedEntry]), + }); + + const controller = new GatorPermissionsController({ + messenger: getGatorPermissionsControllerMessenger(rootMessenger), + config: DEFAULT_TEST_CONFIG, + state: { + grantedPermissions: [persistedRowMissingStatus], + lastSyncedTimestamp: 1, + }, + }); + + await rootMessenger.call( + 'GatorPermissionsController:fetchAndUpdateGatorPermissions', + ); + + expect(controller.state.grantedPermissions).toHaveLength(1); + expect(controller.state.grantedPermissions[0].status).toBe('Active'); + }); + it('categorizes erc20-token-revocation permissions into its own bucket', async () => { const chainId = '0x1' as Hex; // Create a minimal revocation permission entry and cast to satisfy types @@ -285,6 +462,7 @@ describe('GatorPermissionsController', () => { 'erc20-token-revocation', ); expect(grantedPermissions[0].permissionResponse.chainId).toBe(chainId); + expect(PERMISSION_STATUSES).toContain(grantedPermissions[0].status); }); it('handles null permissions data', async () => { @@ -1902,6 +2080,20 @@ function getRootMessenger({ namespace: MOCK_ANY_NAMESPACE, }); + const permissionStatusProviderRequest = + createDefaultPermissionStatusProviderRequest(); + const findNetworkClientIdByChainId = jest + .fn() + .mockReturnValue('test-network-client-id'); + const getNetworkClientById = jest.fn().mockReturnValue({ + provider: { request: permissionStatusProviderRequest }, + }); + lastNetworkControllerTestMocks = { + findNetworkClientIdByChainId, + getNetworkClientById, + permissionStatusProviderRequest, + }; + rootMessenger.registerActionHandler( 'SnapController:handleRequest', snapControllerHandleRequestActionHandler, @@ -1910,6 +2102,14 @@ function getRootMessenger({ 'SnapController:hasSnap', snapControllerHasActionHandler, ); + rootMessenger.registerActionHandler( + 'NetworkController:findNetworkClientIdByChainId', + findNetworkClientIdByChainId, + ); + rootMessenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + getNetworkClientById, + ); return rootMessenger; } @@ -1934,7 +2134,12 @@ function getGatorPermissionsControllerMessenger( }); rootMessenger.delegate({ messenger: gatorPermissionsControllerMessenger, - actions: ['SnapController:handleRequest', 'SnapController:hasSnap'], + actions: [ + 'SnapController:handleRequest', + 'SnapController:hasSnap', + 'NetworkController:findNetworkClientIdByChainId', + 'NetworkController:getNetworkClientById', + ], events: [ 'TransactionController:transactionApproved', 'TransactionController:transactionRejected', diff --git a/packages/gator-permissions-controller/src/GatorPermissionsController.ts b/packages/gator-permissions-controller/src/GatorPermissionsController.ts index 0dba88eaf6f..05f59263093 100644 --- a/packages/gator-permissions-controller/src/GatorPermissionsController.ts +++ b/packages/gator-permissions-controller/src/GatorPermissionsController.ts @@ -6,6 +6,11 @@ import type { import { BaseController } from '@metamask/base-controller'; import { DELEGATOR_CONTRACTS } from '@metamask/delegation-deployments'; import type { Messenger } from '@metamask/messenger'; +import type { + NetworkControllerFindNetworkClientIdByChainIdAction, + NetworkControllerGetNetworkClientByIdAction, + NetworkClientId, +} from '@metamask/network-controller'; import type { SnapControllerHandleRequestAction, SnapControllerHasSnapAction, @@ -37,10 +42,13 @@ import { } from './errors'; import type { GatorPermissionsControllerMethodActions } from './GatorPermissionsController-method-action-types'; import { controllerLog } from './logger'; +import { updateGrantedPermissionsStatus } from './permissionOnChainStatus'; +import type { PermissionStatusEip1193Provider } from './permissionOnChainStatus'; import { GatorPermissionsSnapRpcMethod } from './types'; import type { StoredGatorPermission, PermissionInfoWithMetadata, + GatorPermissionStatus, SupportedPermissionType, DelegationDetails, RevocationParams, @@ -105,7 +113,7 @@ export type GatorPermissionsControllerConfig = { */ export type GatorPermissionsControllerState = { /** - * List of granted permissions with metadata (siteOrigin, revocationMetadata). + * List of granted permissions with metadata (siteOrigin, status, revocationMetadata). */ grantedPermissions: PermissionInfoWithMetadata[]; @@ -206,7 +214,9 @@ export type GatorPermissionsControllerActions = */ type AllowedActions = | SnapControllerHandleRequestAction - | SnapControllerHasSnapAction; + | SnapControllerHasSnapAction + | NetworkControllerFindNetworkClientIdByChainIdAction + | NetworkControllerGetNetworkClientByIdAction; /** * The event that {@link GatorPermissionsController} publishes when updating state. @@ -356,15 +366,57 @@ export class GatorPermissionsController extends BaseController< }); } + /** + * Maps permission `context` (lowercase hex) to the last known {@link PermissionStatus} + * from the current controller state (used when merging after a snap sync). + * + * @returns Map from lowercase `permissionResponse.context` to the prior {@link PermissionStatus}. + */ + #buildPreviousStatusByContext(): Map { + const map = new Map(); + for (const prev of this.state.grantedPermissions) { + map.set( + prev.permissionResponse.context.toLowerCase(), + prev.status ?? 'Active', + ); + } + return map; + } + + async #getProviderForChainId( + chainId: Hex, + ): Promise { + const networkClientId: NetworkClientId = this.messenger.call( + 'NetworkController:findNetworkClientIdByChainId', + chainId, + ); + const { provider } = this.messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ); + return provider as PermissionStatusEip1193Provider; + } + + async #updateGrantedPermissionsStatus( + grantedPermissions: PermissionInfoWithMetadata[], + ): Promise { + return updateGrantedPermissionsStatus(grantedPermissions, { + getProviderForChainId: (chainId) => this.#getProviderForChainId(chainId), + contractsByChainId, + }); + } + /** * Converts a stored gator permission to permission info with metadata. * Strips internal fields (dependencies, to) from the permission response. * * @param storedGatorPermission - The stored gator permission from the Snap. + * @param status - The status for this permission. * @returns Permission info with metadata for state/UI. */ #storedPermissionToPermissionInfo( storedGatorPermission: StoredGatorPermission, + status: GatorPermissionStatus, ): PermissionInfoWithMetadata { const { permissionResponse: fullPermissionResponse } = storedGatorPermission; @@ -377,6 +429,7 @@ export class GatorPermissionsController extends BaseController< return { ...storedGatorPermission, permissionResponse, + status, }; } @@ -393,9 +446,17 @@ export class GatorPermissionsController extends BaseController< return []; } - return storedGatorPermissions.map((storedPermission) => - this.#storedPermissionToPermissionInfo(storedPermission), - ); + const previousStatusByContext = this.#buildPreviousStatusByContext(); + + return storedGatorPermissions.map((storedPermission) => { + const previousStatus = previousStatusByContext.get( + storedPermission.permissionResponse.context.toLowerCase(), + ); + return this.#storedPermissionToPermissionInfo( + storedPermission, + previousStatus ?? 'Active', + ); + }); } /** @@ -437,6 +498,13 @@ export class GatorPermissionsController extends BaseController< state.grantedPermissions = grantedPermissions; state.lastSyncedTimestamp = Date.now(); }); + + const grantedPermissionsWithStatus = + await this.#updateGrantedPermissionsStatus(grantedPermissions); + + this.update((state) => { + state.grantedPermissions = grantedPermissionsWithStatus; + }); } catch (error) { controllerLog('Failed to fetch gator permissions', error); throw new GatorPermissionsFetchError({ diff --git a/packages/gator-permissions-controller/src/index.ts b/packages/gator-permissions-controller/src/index.ts index ee20c408423..dd34e049f9b 100644 --- a/packages/gator-permissions-controller/src/index.ts +++ b/packages/gator-permissions-controller/src/index.ts @@ -27,6 +27,7 @@ export type { PermissionInfo, StoredGatorPermission, PermissionInfoWithMetadata, + GatorPermissionStatus, DelegationDetails, RevocationParams, RevocationMetadata, diff --git a/packages/gator-permissions-controller/src/permissionOnChainStatus.test.ts b/packages/gator-permissions-controller/src/permissionOnChainStatus.test.ts new file mode 100644 index 00000000000..ad1932a30ff --- /dev/null +++ b/packages/gator-permissions-controller/src/permissionOnChainStatus.test.ts @@ -0,0 +1,637 @@ +import { + createNativeTokenStreamingTerms, + createTimestampTerms, + Delegation, + encodeDelegations, + ROOT_AUTHORITY, +} from '@metamask/delegation-core'; +import { + CHAIN_ID, + DELEGATOR_CONTRACTS, +} from '@metamask/delegation-deployments'; +import { hexToBigInt, numberToHex } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +import { DELEGATION_FRAMEWORK_VERSION } from './constants'; +import { + encodeDisabledDelegationsCalldata, + getExpiryFromDelegation, + readDelegationDisabledOnChain, + readLatestBlockTimestampSeconds, + resolveGrantedPermissionOnChainStatus, + updateGrantedPermissionsStatus, +} from './permissionOnChainStatus'; +import type { PermissionInfoWithMetadata } from './types'; + +const contracts = + DELEGATOR_CONTRACTS[DELEGATION_FRAMEWORK_VERSION][CHAIN_ID.sepolia]; + +const { TimestampEnforcer, NativeTokenStreamingEnforcer } = contracts; + +describe('permissionOnChainStatus', () => { + describe('encodeDisabledDelegationsCalldata', () => { + it('prefixes calldata with disabledDelegations(bytes32) selector', () => { + const hash = + '0x1111111111111111111111111111111111111111111111111111111111111111'; + const data = encodeDisabledDelegationsCalldata(hash); + expect(data.startsWith('0x2d40d052')).toBe(true); + expect(data).toHaveLength(2 + 8 + 64); + }); + }); + + describe('getExpiryFromDelegation', () => { + it('returns expiry from TimestampEnforcer caveat terms', () => { + const expirySeconds = 1893456000; + const terms = createTimestampTerms({ + timestampAfterThreshold: 0, + timestampBeforeThreshold: expirySeconds, + }); + const delegation: Delegation = { + delegate: '0x4f71DA06987BfeDE90aF0b33E1e3e4ffDCEE7a63', + delegator: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + authority: ROOT_AUTHORITY, + caveats: [ + { + enforcer: NativeTokenStreamingEnforcer, + terms: createNativeTokenStreamingTerms({ + initialAmount: hexToBigInt('0x6f05b59d3b20000'), + maxAmount: hexToBigInt('0x22b1c8c1227a0000'), + amountPerSecond: hexToBigInt('0x6f05b59d3b20000'), + startTime: 1747699200, + }), + args: '0x', + }, + { + enforcer: TimestampEnforcer, + terms, + args: '0x', + }, + ], + salt: 0n, + signature: '0x' as const, + }; + const result = getExpiryFromDelegation(delegation, contracts); + expect(result).toBe(expirySeconds); + }); + + it('returns null when no timestamp caveat matches', () => { + const delegation: Delegation = { + delegate: '0x4f71DA06987BfeDE90aF0b33E1e3e4ffDCEE7a63', + delegator: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + authority: ROOT_AUTHORITY, + caveats: [ + { + enforcer: NativeTokenStreamingEnforcer, + terms: createNativeTokenStreamingTerms({ + initialAmount: hexToBigInt('0x6f05b59d3b20000'), + maxAmount: hexToBigInt('0x22b1c8c1227a0000'), + amountPerSecond: hexToBigInt('0x6f05b59d3b20000'), + startTime: 1747699200, + }), + args: '0x', + }, + ], + salt: 0n, + signature: '0x' as const, + }; + expect(getExpiryFromDelegation(delegation, contracts)).toBeNull(); + }); + + it('returns null when TimestampEnforcer terms fail to decode', () => { + const invalidTerms: Hex = `0x${'01'.repeat(32)}`; + const delegation: Delegation = { + delegate: '0x4f71DA06987BfeDE90aF0b33E1e3e4ffDCEE7a63', + delegator: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + authority: ROOT_AUTHORITY, + caveats: [ + { + enforcer: TimestampEnforcer, + terms: invalidTerms, + args: '0x', + }, + ], + salt: 0n, + signature: '0x' as const, + }; + expect(getExpiryFromDelegation(delegation, contracts)).toBeNull(); + }); + }); + + describe('readDelegationDisabledOnChain', () => { + it('returns true when eth_call result is bool true', async () => { + const provider = { + request: jest + .fn() + .mockResolvedValue( + '0x0000000000000000000000000000000000000000000000000000000000000001', + ), + }; + expect( + await readDelegationDisabledOnChain({ + provider, + delegationManager: contracts.DelegationManager, + delegationHash: + '0x1111111111111111111111111111111111111111111111111111111111111111', + }), + ).toBe(true); + }); + }); + + describe('readLatestBlockTimestampSeconds', () => { + it('returns the block timestamp in seconds', async () => { + const timestamp = 1_700_000_000; + const provider = { + request: jest.fn().mockResolvedValue({ + timestamp: numberToHex(timestamp), + }), + }; + expect(await readLatestBlockTimestampSeconds(provider)).toBe(timestamp); + }); + + it('throws when the block payload has no timestamp', async () => { + const provider = { + request: jest.fn().mockResolvedValue({}), + }; + await expect(readLatestBlockTimestampSeconds(provider)).rejects.toThrow( + 'Latest block missing timestamp', + ); + }); + }); + + describe('resolveGrantedPermissionOnChainStatus', () => { + const contractsByChainId = + DELEGATOR_CONTRACTS[DELEGATION_FRAMEWORK_VERSION]; + + it('returns Revoked when revocationMetadata is present without calling the network', async () => { + const getProviderForChainId = jest.fn(); + const entry: PermissionInfoWithMetadata = { + permissionResponse: { + chainId: numberToHex(CHAIN_ID.sepolia), + from: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + permission: { + type: 'native-token-stream', + isAdjustmentAllowed: true, + data: { + maxAmount: '0x1', + initialAmount: '0x1', + amountPerSecond: '0x1', + startTime: 1, + justification: 'j', + }, + }, + context: '0x00000000', + delegationManager: contracts.DelegationManager, + }, + siteOrigin: 'https://example.org', + status: 'Active', + revocationMetadata: { recordedAt: 1 }, + }; + + const result = await resolveGrantedPermissionOnChainStatus(entry, { + getProviderForChainId, + contractsByChainId, + }); + + expect(result.status).toBe('Revoked'); + expect(getProviderForChainId).not.toHaveBeenCalled(); + }); + + it('preserves prior status when decoded context does not contain exactly one delegation', async () => { + const getProviderForChainId = jest.fn(); + const entry: PermissionInfoWithMetadata = { + permissionResponse: { + chainId: numberToHex(CHAIN_ID.sepolia), + from: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + permission: { + type: 'native-token-stream', + isAdjustmentAllowed: true, + data: { + maxAmount: '0x1', + initialAmount: '0x1', + amountPerSecond: '0x1', + startTime: 1, + justification: 'j', + }, + }, + context: '0x00000000', + delegationManager: contracts.DelegationManager, + }, + siteOrigin: 'https://example.org', + status: 'Expired', + }; + + const result = await resolveGrantedPermissionOnChainStatus(entry, { + getProviderForChainId, + contractsByChainId, + }); + + expect(result.status).toBe('Expired'); + expect(getProviderForChainId).not.toHaveBeenCalled(); + }); + + it('defaults missing entry status to Active when preserving after a resolution error', async () => { + const getProviderForChainId = jest.fn(); + const entry = { + permissionResponse: { + chainId: numberToHex(CHAIN_ID.sepolia), + from: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + permission: { + type: 'native-token-stream', + isAdjustmentAllowed: true, + data: { + maxAmount: '0x1', + initialAmount: '0x1', + amountPerSecond: '0x1', + startTime: 1, + justification: 'j', + }, + }, + context: '0x00000000', + delegationManager: contracts.DelegationManager, + }, + siteOrigin: 'https://example.org', + // status is missing, so we must add type assertion + } as unknown as PermissionInfoWithMetadata; + + const result = await resolveGrantedPermissionOnChainStatus(entry, { + getProviderForChainId, + contractsByChainId, + }); + + expect(result.status).toBe('Active'); + expect(getProviderForChainId).not.toHaveBeenCalled(); + }); + + it('sets Active when a single delegation is not disabled and has no timestamp expiry caveat', async () => { + const delegation: Delegation = { + delegate: '0x4f71DA06987BfeDE90aF0b33E1e3e4ffDCEE7a63', + delegator: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + authority: ROOT_AUTHORITY, + caveats: [ + { + enforcer: NativeTokenStreamingEnforcer, + terms: createNativeTokenStreamingTerms({ + initialAmount: hexToBigInt('0x6f05b59d3b20000'), + maxAmount: hexToBigInt('0x22b1c8c1227a0000'), + amountPerSecond: hexToBigInt('0x6f05b59d3b20000'), + startTime: 1747699200, + }), + args: '0x', + }, + ], + salt: 0n, + signature: '0x', + }; + const context = encodeDelegations([delegation]); + const entry: PermissionInfoWithMetadata = { + permissionResponse: { + chainId: numberToHex(CHAIN_ID.sepolia), + from: delegation.delegator, + permission: { + type: 'native-token-stream', + isAdjustmentAllowed: true, + data: { + maxAmount: '0x22b1c8c1227a0000', + initialAmount: '0x6f05b59d3b20000', + amountPerSecond: '0x6f05b59d3b20000', + startTime: 1747699200, + justification: 'j', + }, + }, + context, + delegationManager: contracts.DelegationManager, + }, + siteOrigin: 'https://example.org', + status: 'Expired', + }; + + const getProviderForChainId = jest.fn().mockResolvedValue({ + request: jest.fn(async (req) => { + if (req.method === 'eth_call') { + return '0x0000000000000000000000000000000000000000000000000000000000000000'; + } + if (req.method === 'eth_getBlockByNumber') { + return { timestamp: numberToHex(2_000_000_000) }; + } + throw new Error(`Unexpected RPC: ${req.method}`); + }), + }); + + const result = await resolveGrantedPermissionOnChainStatus(entry, { + getProviderForChainId, + contractsByChainId, + }); + + expect(result.status).toBe('Active'); + expect(getProviderForChainId).toHaveBeenCalledTimes(1); + }); + + it('sets Expired when latest block time is at or past timestamp caveat expiry', async () => { + const expirySeconds = 1_000_000_000; + const terms = createTimestampTerms({ + timestampAfterThreshold: 0, + timestampBeforeThreshold: expirySeconds, + }); + const delegation: Delegation = { + delegate: '0x4f71DA06987BfeDE90aF0b33E1e3e4ffDCEE7a63', + delegator: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + authority: ROOT_AUTHORITY, + caveats: [ + { + enforcer: NativeTokenStreamingEnforcer, + terms: createNativeTokenStreamingTerms({ + initialAmount: hexToBigInt('0x6f05b59d3b20000'), + maxAmount: hexToBigInt('0x22b1c8c1227a0000'), + amountPerSecond: hexToBigInt('0x6f05b59d3b20000'), + startTime: 1747699200, + }), + args: '0x', + }, + { + enforcer: TimestampEnforcer, + terms, + args: '0x', + }, + ], + salt: 0n, + signature: '0x', + }; + const context = encodeDelegations([delegation]); + const entry: PermissionInfoWithMetadata = { + permissionResponse: { + chainId: numberToHex(CHAIN_ID.sepolia), + from: delegation.delegator, + permission: { + type: 'native-token-stream', + isAdjustmentAllowed: true, + data: { + maxAmount: '0x22b1c8c1227a0000', + initialAmount: '0x6f05b59d3b20000', + amountPerSecond: '0x6f05b59d3b20000', + startTime: 1747699200, + justification: 'j', + }, + }, + context, + delegationManager: contracts.DelegationManager, + }, + siteOrigin: 'https://example.org', + status: 'Active', + }; + + const getProviderForChainId = jest.fn().mockResolvedValue({ + request: jest.fn(async (req) => { + if (req.method === 'eth_call') { + return '0x0000000000000000000000000000000000000000000000000000000000000000'; + } + if (req.method === 'eth_getBlockByNumber') { + return { timestamp: numberToHex(expirySeconds) }; + } + throw new Error(`Unexpected RPC: ${req.method}`); + }), + }); + + const result = await resolveGrantedPermissionOnChainStatus(entry, { + getProviderForChainId, + contractsByChainId, + }); + + expect(result.status).toBe('Expired'); + }); + + it('sets Revoked when disabledDelegations returns true', async () => { + const delegation: Delegation = { + delegate: '0x4f71DA06987BfeDE90aF0b33E1e3e4ffDCEE7a63', + delegator: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + authority: ROOT_AUTHORITY, + caveats: [ + { + enforcer: NativeTokenStreamingEnforcer, + terms: createNativeTokenStreamingTerms({ + initialAmount: hexToBigInt('0x6f05b59d3b20000'), + maxAmount: hexToBigInt('0x22b1c8c1227a0000'), + amountPerSecond: hexToBigInt('0x6f05b59d3b20000'), + startTime: 1747699200, + }), + args: '0x', + }, + ], + salt: 0n, + signature: '0x', + }; + const context = encodeDelegations([delegation]); + const entry: PermissionInfoWithMetadata = { + permissionResponse: { + chainId: numberToHex(CHAIN_ID.sepolia), + from: delegation.delegator, + permission: { + type: 'native-token-stream', + isAdjustmentAllowed: true, + data: { + maxAmount: '0x22b1c8c1227a0000', + initialAmount: '0x6f05b59d3b20000', + amountPerSecond: '0x6f05b59d3b20000', + startTime: 1747699200, + justification: 'j', + }, + }, + context, + delegationManager: contracts.DelegationManager, + }, + siteOrigin: 'https://example.org', + status: 'Active', + }; + + const getProviderForChainId = jest.fn().mockResolvedValue({ + request: jest.fn(async (req) => { + if (req.method === 'eth_call') { + return '0x0000000000000000000000000000000000000000000000000000000000000001'; + } + throw new Error(`Unexpected RPC: ${req.method}`); + }), + }); + + const result = await resolveGrantedPermissionOnChainStatus(entry, { + getProviderForChainId, + contractsByChainId, + }); + + expect(result.status).toBe('Revoked'); + }); + + it('preserves prior status when deployment contracts are missing for the chain', async () => { + const delegation: Delegation = { + delegate: '0x4f71DA06987BfeDE90aF0b33E1e3e4ffDCEE7a63', + delegator: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + authority: ROOT_AUTHORITY, + caveats: [ + { + enforcer: NativeTokenStreamingEnforcer, + terms: createNativeTokenStreamingTerms({ + initialAmount: hexToBigInt('0x6f05b59d3b20000'), + maxAmount: hexToBigInt('0x22b1c8c1227a0000'), + amountPerSecond: hexToBigInt('0x6f05b59d3b20000'), + startTime: 1747699200, + }), + args: '0x', + }, + ], + salt: 0n, + signature: '0x', + }; + const context = encodeDelegations([delegation]); + const entry: PermissionInfoWithMetadata = { + permissionResponse: { + chainId: numberToHex(CHAIN_ID.sepolia), + from: delegation.delegator, + permission: { + type: 'native-token-stream', + isAdjustmentAllowed: true, + data: { + maxAmount: '0x22b1c8c1227a0000', + initialAmount: '0x6f05b59d3b20000', + amountPerSecond: '0x6f05b59d3b20000', + startTime: 1747699200, + justification: 'j', + }, + }, + context, + delegationManager: contracts.DelegationManager, + }, + siteOrigin: 'https://example.org', + status: 'Expired', + }; + + const getProviderForChainId = jest.fn().mockResolvedValue({ + request: jest.fn(async (req) => { + if (req.method === 'eth_call') { + return '0x0000000000000000000000000000000000000000000000000000000000000000'; + } + throw new Error(`Unexpected RPC: ${req.method}`); + }), + }); + + const result = await resolveGrantedPermissionOnChainStatus(entry, { + getProviderForChainId, + contractsByChainId: {}, + }); + + expect(result.status).toBe('Expired'); + }); + + it('sets Active when latest block is strictly before timestamp caveat expiry', async () => { + const expirySeconds = 2_500_000_000; + const blockSeconds = expirySeconds - 10_000; + const terms = createTimestampTerms({ + timestampAfterThreshold: 0, + timestampBeforeThreshold: expirySeconds, + }); + const delegation: Delegation = { + delegate: '0x4f71DA06987BfeDE90aF0b33E1e3e4ffDCEE7a63', + delegator: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + authority: ROOT_AUTHORITY, + caveats: [ + { + enforcer: NativeTokenStreamingEnforcer, + terms: createNativeTokenStreamingTerms({ + initialAmount: hexToBigInt('0x6f05b59d3b20000'), + maxAmount: hexToBigInt('0x22b1c8c1227a0000'), + amountPerSecond: hexToBigInt('0x6f05b59d3b20000'), + startTime: 1747699200, + }), + args: '0x', + }, + { + enforcer: TimestampEnforcer, + terms, + args: '0x', + }, + ], + salt: 0n, + signature: '0x', + }; + const context = encodeDelegations([delegation]); + const entry: PermissionInfoWithMetadata = { + permissionResponse: { + chainId: numberToHex(CHAIN_ID.sepolia), + from: delegation.delegator, + permission: { + type: 'native-token-stream', + isAdjustmentAllowed: true, + data: { + maxAmount: '0x22b1c8c1227a0000', + initialAmount: '0x6f05b59d3b20000', + amountPerSecond: '0x6f05b59d3b20000', + startTime: 1747699200, + justification: 'j', + }, + }, + context, + delegationManager: contracts.DelegationManager, + }, + siteOrigin: 'https://example.org', + status: 'Expired', + }; + + const getProviderForChainId = jest.fn().mockResolvedValue({ + request: jest.fn(async (req) => { + if (req.method === 'eth_call') { + return '0x0000000000000000000000000000000000000000000000000000000000000000'; + } + if (req.method === 'eth_getBlockByNumber') { + return { timestamp: numberToHex(blockSeconds) }; + } + throw new Error(`Unexpected RPC: ${req.method}`); + }), + }); + + const result = await resolveGrantedPermissionOnChainStatus(entry, { + getProviderForChainId, + contractsByChainId, + }); + + expect(result.status).toBe('Active'); + }); + }); + + describe('updateGrantedPermissionsStatus', () => { + it('resolves each permission entry', async () => { + const getProviderForChainId = jest.fn(); + const contractsByChainId = + DELEGATOR_CONTRACTS[DELEGATION_FRAMEWORK_VERSION]; + + const entry: PermissionInfoWithMetadata = { + permissionResponse: { + chainId: numberToHex(CHAIN_ID.sepolia), + from: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + permission: { + type: 'native-token-stream', + isAdjustmentAllowed: true, + data: { + maxAmount: '0x1', + initialAmount: '0x1', + amountPerSecond: '0x1', + startTime: 1, + justification: 'j', + }, + }, + context: '0x00000000', + delegationManager: contracts.DelegationManager, + }, + siteOrigin: 'https://a.example', + status: 'Active', + revocationMetadata: { recordedAt: 1 }, + }; + + const results = await updateGrantedPermissionsStatus( + [entry, { ...entry, siteOrigin: 'https://b.example' }], + { getProviderForChainId, contractsByChainId }, + ); + + expect(results).toHaveLength(2); + expect(results[0].status).toBe('Revoked'); + expect(results[1].status).toBe('Revoked'); + expect(getProviderForChainId).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/gator-permissions-controller/src/permissionOnChainStatus.ts b/packages/gator-permissions-controller/src/permissionOnChainStatus.ts new file mode 100644 index 00000000000..6ac35cfde3a --- /dev/null +++ b/packages/gator-permissions-controller/src/permissionOnChainStatus.ts @@ -0,0 +1,201 @@ +import { encodeSingle, decodeSingle } from '@metamask/abi-utils'; +import { decodeDelegations, hashDelegation } from '@metamask/delegation-core'; +import type { Delegation } from '@metamask/delegation-core'; +import { bytesToHex, getChecksumAddress, hexToNumber } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +import type { DeployedContractsByName } from './decodePermission/types'; +import { + extractExpiryFromCaveatTerms, + getChecksumEnforcersByChainId, +} from './decodePermission/utils'; +import { controllerLog } from './logger'; +import type { + PermissionInfoWithMetadata, + GatorPermissionStatus, +} from './types'; + +/** Function selector for `DelegationManager.disabledDelegations(bytes32)`. */ +const DISABLED_DELEGATIONS_SELECTOR = '0x2d40d052'; + +/** + * Minimal EIP-1193 provider used for permission status RPCs. + */ +export type PermissionStatusEip1193Provider = { + request(args: { method: string; params?: unknown[] }): Promise; +}; + +/** + * Resolves an RPC provider for the given EIP-155 `chainId` (hex). + */ +export type GetProviderForChainId = ( + chainId: Hex, +) => Promise; + +export type PermissionOnChainStatusOptions = { + getProviderForChainId: GetProviderForChainId; + contractsByChainId: Record; +}; + +/** + * ABI-encodes a call to `DelegationManager.disabledDelegations(bytes32)`. + * + * @param delegationHash - Delegation struct hash (bytes32). + * @returns Calldata hex string (selector + encoded argument). + */ +export function encodeDisabledDelegationsCalldata(delegationHash: Hex): Hex { + const encodedArgs = bytesToHex(encodeSingle('bytes32', delegationHash)); + return `${DISABLED_DELEGATIONS_SELECTOR}${encodedArgs.slice(2)}`; +} + +/** + * Reads `disabledDelegations(delegationHash)` from the delegation manager. + * + * @param args - Arguments. + * @param args.provider - JSON-RPC provider for the permission's chain. + * @param args.delegationManager - DelegationManager contract address. + * @param args.delegationHash - Hash of the leaf delegation. + * @returns Whether the delegation is disabled on-chain. + */ +export async function readDelegationDisabledOnChain({ + provider, + delegationManager, + delegationHash, +}: { + provider: PermissionStatusEip1193Provider; + delegationManager: Hex; + delegationHash: Hex; +}): Promise { + const data = encodeDisabledDelegationsCalldata(delegationHash); + const raw = (await provider.request({ + method: 'eth_call', + params: [{ to: delegationManager, data }, 'latest'], + })) as Hex; + return decodeSingle('bool', raw); +} + +/** + * Returns the latest block's timestamp in seconds. + * + * @param provider - JSON-RPC provider for the chain. + * @returns Unix timestamp in seconds. + */ +export async function readLatestBlockTimestampSeconds( + provider: PermissionStatusEip1193Provider, +): Promise { + const block = (await provider.request({ + method: 'eth_getBlockByNumber', + params: ['latest', false], + })) as { timestamp?: Hex }; + if (!block?.timestamp) { + throw new Error('Latest block missing timestamp'); + } + return hexToNumber(block.timestamp); +} + +/** + * Reads TimestampEnforcer expiry (unix seconds) from the leaf delegation's caveats. + * + * @param leaf - Leaf delegation (index 0 when decoded from permission context). + * @param contracts - Deployed enforcer addresses for the chain. + * @returns Expiry timestamp in seconds, or `null` if no valid timestamp caveat. + */ +export function getExpiryFromDelegation( + leaf: Delegation, + contracts: DeployedContractsByName, +): number | null { + const { timestampEnforcer } = getChecksumEnforcersByChainId(contracts); + const targetEnforcer = getChecksumAddress(timestampEnforcer).toLowerCase(); + const timestampCaveat = leaf.caveats.find( + (caveat) => + getChecksumAddress(caveat.enforcer).toLowerCase() === targetEnforcer, + ); + if (!timestampCaveat?.terms) { + return null; + } + try { + return extractExpiryFromCaveatTerms(timestampCaveat.terms); + } catch { + return null; + } +} + +/** + * Recomputes {@link PermissionStatus} for one granted permission using chain state. + * + * @param entry - Granted permission row (including merged `status` from the prior sync). + * @param options - `getProviderForChainId` and deployment map for the framework version. + * @returns The same entry with an updated `status`. + */ +export async function resolveGrantedPermissionOnChainStatus( + entry: PermissionInfoWithMetadata, + options: PermissionOnChainStatusOptions, +): Promise { + if (entry.revocationMetadata) { + return { ...entry, status: 'Revoked' }; + } + + const originalStatus: GatorPermissionStatus = entry.status ?? 'Active'; + + try { + const delegations = decodeDelegations(entry.permissionResponse.context); + + if (delegations.length !== 1) { + throw new Error( + 'Unexpected delegations length in decoded permission context', + ); + } + const delegation = delegations[0]; + const delegationHash = hashDelegation(delegation); + + const provider = await options.getProviderForChainId( + entry.permissionResponse.chainId, + ); + + const isDisabled = await readDelegationDisabledOnChain({ + provider, + delegationManager: entry.permissionResponse.delegationManager, + delegationHash, + }); + + if (isDisabled) { + return { ...entry, status: 'Revoked' }; + } + + const chainId = hexToNumber(entry.permissionResponse.chainId); + const contracts = options.contractsByChainId[chainId]; + if (!contracts) { + return { ...entry, status: originalStatus }; + } + const expiry = getExpiryFromDelegation(delegation, contracts); + if (expiry === null) { + return { ...entry, status: 'Active' }; + } + const blockTimestamp = await readLatestBlockTimestampSeconds(provider); + if (blockTimestamp >= expiry) { + return { ...entry, status: 'Expired' }; + } + return { ...entry, status: 'Active' }; + } catch (error) { + controllerLog('Failed to resolve permission status', error); + return { ...entry, status: originalStatus }; + } +} + +/** + * Recomputes status for all granted permissions in parallel. + * + * @param grantedPermissions - Rows returned from the permissions provider snap. + * @param options - Provider factory and deployment map. + * @returns Same rows with updated `status` fields. + */ +export async function updateGrantedPermissionsStatus( + grantedPermissions: PermissionInfoWithMetadata[], + options: PermissionOnChainStatusOptions, +): Promise { + return Promise.all( + grantedPermissions.map((row) => + resolveGrantedPermissionOnChainStatus(row, options), + ), + ); +} diff --git a/packages/gator-permissions-controller/src/types.ts b/packages/gator-permissions-controller/src/types.ts index c1daeaa85cc..9f65b2ce0c9 100644 --- a/packages/gator-permissions-controller/src/types.ts +++ b/packages/gator-permissions-controller/src/types.ts @@ -112,6 +112,11 @@ export type PermissionInfo = Omit< 'dependencies' | 'to' >; +/** + * Lifecycle status of a granted permission for UI and sync. + */ +export type GatorPermissionStatus = 'Active' | 'Revoked' | 'Expired'; + /** * Granted permission with metadata (siteOrigin, optional revocationMetadata). * @@ -122,6 +127,10 @@ export type PermissionInfoWithMetadata< > = { permissionResponse: PermissionInfo; siteOrigin: string; + /** + * Whether the permission is active, revoked (off-chain and/or on-chain), or expired by time rule. + */ + status: GatorPermissionStatus; revocationMetadata?: RevocationMetadata; }; diff --git a/yarn.lock b/yarn.lock index 4a89180e573..8ddcdc06186 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4055,11 +4055,13 @@ __metadata: "@lavamoat/allow-scripts": "npm:^3.0.4" "@lavamoat/preinstall-always-fail": "npm:^2.1.0" "@metamask/7715-permission-types": "npm:^0.5.0" + "@metamask/abi-utils": "npm:^2.0.3" "@metamask/auto-changelog": "npm:^6.0.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/delegation-core": "npm:^0.2.0" "@metamask/delegation-deployments": "npm:^0.12.0" "@metamask/messenger": "npm:^1.1.1" + "@metamask/network-controller": "npm:^30.0.1" "@metamask/snaps-controllers": "npm:^19.0.0" "@metamask/snaps-sdk": "npm:^11.0.0" "@metamask/snaps-utils": "npm:^12.1.2" From ce9707806c73ed16500448c75d887546e4747821 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Wed, 15 Apr 2026 11:50:45 +1200 Subject: [PATCH 2/2] Documentation changes: - changelog entry - readme change referencing the dependency from permissions controller to network controller --- README.md | 1 + packages/gator-permissions-controller/CHANGELOG.md | 1 + 2 files changed, 2 insertions(+) diff --git a/README.md b/README.md index 315b9c53af0..7a518aeaaee 100644 --- a/README.md +++ b/README.md @@ -339,6 +339,7 @@ linkStyle default opacity:0.5 gas_fee_controller --> polling_controller; gator_permissions_controller --> base_controller; gator_permissions_controller --> messenger; + gator_permissions_controller --> network_controller; gator_permissions_controller --> transaction_controller; geolocation_controller --> base_controller; geolocation_controller --> controller_utils; diff --git a/packages/gator-permissions-controller/CHANGELOG.md b/packages/gator-permissions-controller/CHANGELOG.md index 8b55326db42..0f11b1d42de 100644 --- a/packages/gator-permissions-controller/CHANGELOG.md +++ b/packages/gator-permissions-controller/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Add `status` to `PermissionInfoWithMetadata` type, resolved from onchain data ([#8445](https://github.com/MetaMask/core/pull/8445)) - Bump `@metamask/messenger` from `^1.0.0` to `^1.1.1` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373)) - Bump `@metamask/transaction-controller` from `^64.0.0` to `^64.2.0` ([#8432](https://github.com/MetaMask/core/pull/8432), [#8447](https://github.com/MetaMask/core/pull/8447)) - Bump `@metamask/base-controller` from `^9.0.1` to `^9.1.0` ([#8457](https://github.com/MetaMask/core/pull/8457))