From 7cf702ea8a2628df1acde958629429173bce47d5 Mon Sep 17 00:00:00 2001
From: Idris Bowman <34751375+V00D00-child@users.noreply.github.com>
Date: Thu, 9 Oct 2025 17:11:46 -0400
Subject: [PATCH 1/7] Add ReviewGatorPermissionItem to render permission
details and getAggregatedGatorPermissionByChainId selector
---
app/_locales/en/messages.json | 20 +
app/_locales/en_GB/messages.json | 20 +
.../gator-permissions-utils.ts | 302 ++++++++++++
shared/lib/gator-permissions/index.ts | 1 +
.../gator-permissions/components/index.ts | 1 +
.../review-gator-permission-item.tsx | 439 ++++++++++++++++++
...eview-gator-permissions-page.test.tsx.snap | 20 +
.../review-gator-permissions-page.tsx | 103 +++-
.../gator-permissions/useGatorTokenInfo.ts | 51 ++
.../gator-permissions.test.ts | 194 +++++++-
.../gator-permissions/gator-permissions.ts | 52 +++
11 files changed, 1181 insertions(+), 22 deletions(-)
create mode 100644 shared/lib/gator-permissions/gator-permissions-utils.ts
create mode 100644 shared/lib/gator-permissions/index.ts
create mode 100644 ui/components/multichain/pages/gator-permissions/components/review-gator-permission-item.tsx
create mode 100644 ui/hooks/gator-permissions/useGatorTokenInfo.ts
diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json
index be523b8f8cd0..b5b0261828e4 100644
--- a/app/_locales/en/messages.json
+++ b/app/_locales/en/messages.json
@@ -2665,6 +2665,26 @@
"gasUsed": {
"message": "Gas used"
},
+ "gatorPermissionDailyRedemptionFrequency": {
+ "message": "Daily",
+ "description": "Time period for daily recurring permissions redemption"
+ },
+ "gatorPermissionMonthlyRedemptionFrequency": {
+ "message": "Monthly",
+ "description": "Time period for monthly recurring permissions redemption"
+ },
+ "gatorPermissionTokenPeriodicFrequencyLabel": {
+ "message": "Transfer Window",
+ "description": "Label for the transfer window of a token periodic permission"
+ },
+ "gatorPermissionTokenStreamFrequencyLabel": {
+ "message": "Period",
+ "description": "Label for the period of a token stream permission"
+ },
+ "gatorPermissionWeeklyRedemptionFrequency": {
+ "message": "Weekly",
+ "description": "Time period for weekly recurring permissions redemption"
+ },
"general": {
"message": "General"
},
diff --git a/app/_locales/en_GB/messages.json b/app/_locales/en_GB/messages.json
index be523b8f8cd0..264bda54836f 100644
--- a/app/_locales/en_GB/messages.json
+++ b/app/_locales/en_GB/messages.json
@@ -2665,6 +2665,26 @@
"gasUsed": {
"message": "Gas used"
},
+ "gatorPermissionDailyRedemption": {
+ "message": "Daily",
+ "description": "Time period for daily recurring permissions redemption"
+ },
+ "gatorPermissionMonthlyRedemption": {
+ "message": "Monthly",
+ "description": "Time period for monthly recurring permissions redemption"
+ },
+ "gatorPermissionTokenPeriodicFrequencyLabel": {
+ "message": "Transfer Window",
+ "description": "Label for the transfer window of a token periodic permission"
+ },
+ "gatorPermissionTokenStreamFrequencyLabel": {
+ "message": "Period",
+ "description": "Label for the period of a token stream permission"
+ },
+ "gatorPermissionWeeklyRedemption": {
+ "message": "Weekly",
+ "description": "Time period for weekly recurring permissions redemption"
+ },
"general": {
"message": "General"
},
diff --git a/shared/lib/gator-permissions/gator-permissions-utils.ts b/shared/lib/gator-permissions/gator-permissions-utils.ts
new file mode 100644
index 000000000000..bbe70e0974ce
--- /dev/null
+++ b/shared/lib/gator-permissions/gator-permissions-utils.ts
@@ -0,0 +1,302 @@
+import { Hex } from '@metamask/utils';
+import { CHAIN_ID_TO_CURRENCY_SYMBOL_MAP } from '../../constants/network';
+import { fetchAssetMetadata } from '../asset-utils';
+
+// Token info type used across helpers
+export type GatorTokenInfo = { symbol: string; decimals: number };
+
+// Type for the token details function that can be injected from UI
+export type GetTokenStandardAndDetailsByChain = (
+ address: string,
+ userAddress?: string,
+ tokenId?: string,
+ chainId?: string,
+) => Promise<{
+ decimals?: string | number;
+ symbol?: string;
+ standard?: string;
+ [key: string]: unknown;
+}>;
+
+// Type for translation function
+export type TranslationFunction = (key: string, ...args: unknown[]) => string;
+
+// Types for permission data
+export type GatorPermissionData = {
+ tokenAddress?: string;
+ amountPerSecond?: string;
+ periodDuration?: string;
+ periodAmount?: string;
+ [key: string]: unknown;
+};
+
+export type GatorPermissionRule = {
+ type: string;
+ isAdjustmentAllowed: boolean;
+ data: Record;
+};
+
+// Shared promise cache to dedupe and reuse token info fetches per chainId:address
+const gatorTokenInfoPromiseCache = new Map>();
+
+/**
+ * An enum representing the time periods for which the stream rate can be calculated.
+ */
+export enum TimePeriod {
+ DAILY = 'Daily',
+ WEEKLY = 'Weekly',
+ MONTHLY = 'Monthly',
+}
+
+/**
+ * A mapping of time periods to their equivalent seconds.
+ */
+export const TIME_PERIOD_TO_SECONDS: Record = {
+ [TimePeriod.DAILY]: 60n * 60n * 24n, // 86,400(seconds)
+ [TimePeriod.WEEKLY]: 60n * 60n * 24n * 7n, // 604,800(seconds)
+ // Monthly is difficult because months are not consistent in length.
+ // We approximate by calculating the number of seconds in 1/12th of a year.
+ [TimePeriod.MONTHLY]: (60n * 60n * 24n * 365n) / 12n, // 2,629,760(seconds)
+};
+
+/**
+ * Generates a human-readable description for a period duration in seconds to be used for translation.
+ *
+ * @param periodDuration - The period duration in seconds (can be string or number)
+ * @returns A human-readable frequency description to be used for translation.
+ */
+export function getPeriodFrequencyLocaleTranslationKey(
+ periodDuration: string | number,
+): string {
+ const duration = BigInt(periodDuration);
+ if (duration === TIME_PERIOD_TO_SECONDS[TimePeriod.DAILY]) {
+ return 'gatorPermissionDailyRedemptionFrequency';
+ } else if (duration === TIME_PERIOD_TO_SECONDS[TimePeriod.WEEKLY]) {
+ return 'gatorPermissionWeeklyRedemptionFrequency';
+ } else if (duration === TIME_PERIOD_TO_SECONDS[TimePeriod.MONTHLY]) {
+ return 'gatorPermissionMonthlyRedemptionFrequency';
+ }
+ // TODO: Handle custom time period that fall outside of the standard time periods
+ return '';
+}
+
+/**
+ * Fetch ERC-20 token info (symbol as name, decimals) without caching.
+ *
+ * Behavior:
+ * - If external services are enabled, attempts the MetaMask token metadata API first.
+ * - If missing data or disabled, falls back to background on-chain details by chain.
+ * - Returns a best-effort `{ name, decimals }` (defaults: name='Unknown Token', decimals=18).
+ *
+ * @param address
+ * @param chainId
+ * @param allowExternalServices
+ * @param getTokenStandardAndDetailsByChain
+ */
+export async function fetchGatorErc20TokenInfo(
+ address: string,
+ chainId: Hex,
+ allowExternalServices: boolean,
+ getTokenStandardAndDetailsByChain?: GetTokenStandardAndDetailsByChain,
+): Promise {
+ let symbol: string | undefined;
+ let decimals: number | undefined;
+
+ if (allowExternalServices) {
+ const metadata = await fetchAssetMetadata(address, chainId);
+ symbol = metadata?.symbol;
+ decimals = metadata?.decimals;
+ }
+
+ if (!symbol || decimals === null || decimals === undefined) {
+ if (getTokenStandardAndDetailsByChain) {
+ try {
+ const details = await getTokenStandardAndDetailsByChain(
+ address,
+ undefined,
+ undefined,
+ chainId,
+ );
+ const decRaw = details?.decimals as string | number | undefined;
+ if (typeof decRaw === 'number') {
+ decimals = decRaw;
+ } else if (typeof decRaw === 'string') {
+ const parsed10 = parseInt(decRaw, 10);
+ if (Number.isFinite(parsed10)) {
+ decimals = parsed10;
+ } else {
+ const parsed16 = parseInt(decRaw, 16);
+ if (Number.isFinite(parsed16)) {
+ decimals = parsed16;
+ }
+ }
+ }
+ symbol = details?.symbol ?? symbol;
+ } catch (_e) {
+ // ignore and keep fallbacks
+ }
+ }
+ }
+
+ return {
+ symbol: symbol || 'Unknown Token',
+ decimals: decimals ?? 18,
+ } as const;
+}
+
+/**
+ * Fetch ERC-20 token info (symbol as name, decimals) with caching and de-duped in-flight requests.
+ *
+ * Cache key: `${chainId}:${address.toLowerCase()}`
+ * Behavior:
+ * - Returns cached value when available.
+ * - If a request for the same key is in-flight, returns the same promise.
+ * - Otherwise, calls `fetchGatorErc20TokenInfo` and caches the result.
+ *
+ * @param address
+ * @param chainId
+ * @param allowExternalServices
+ * @param getTokenStandardAndDetailsByChain
+ */
+export async function getGatorErc20TokenInfo(
+ address: string,
+ chainId: Hex,
+ allowExternalServices: boolean,
+ getTokenStandardAndDetailsByChain?: GetTokenStandardAndDetailsByChain,
+): Promise {
+ const key = `${chainId}:${address.toLowerCase()}`;
+ const existing = gatorTokenInfoPromiseCache.get(key);
+ if (existing) {
+ return existing;
+ }
+ const promise = fetchGatorErc20TokenInfo(
+ address,
+ chainId,
+ allowExternalServices,
+ getTokenStandardAndDetailsByChain,
+ );
+ gatorTokenInfoPromiseCache.set(key, promise);
+ return promise;
+}
+
+/**
+ * Resolve token display info (name/symbol, decimals) for a Gator permission.
+ *
+ * - If `permissionType` includes 'native-token', returns network native symbol and 18 decimals.
+ * - Otherwise, fetches ERC-20 info (cached) using `tokenAddress` from `permissionData`.
+ *
+ * @param params
+ * @param params.permissionType
+ * @param params.chainId
+ * @param params.networkConfig
+ * @param params.tokenAddress
+ * @param params.allowExternalServices
+ * @param params.getTokenStandardAndDetailsByChain
+ */
+export async function getGatorPermissionTokenInfo(params: {
+ permissionType: string;
+ chainId: string;
+ networkConfig?: { nativeCurrency?: string; name?: string } | null;
+ tokenAddress?: string;
+ allowExternalServices: boolean;
+ getTokenStandardAndDetailsByChain?: GetTokenStandardAndDetailsByChain;
+}): Promise {
+ const {
+ permissionType,
+ chainId,
+ networkConfig,
+ tokenAddress,
+ allowExternalServices,
+ getTokenStandardAndDetailsByChain,
+ } = params;
+ const isNative = permissionType.includes('native-token');
+ if (isNative) {
+ const nativeSymbol =
+ networkConfig?.nativeCurrency ||
+ CHAIN_ID_TO_CURRENCY_SYMBOL_MAP[
+ chainId as keyof typeof CHAIN_ID_TO_CURRENCY_SYMBOL_MAP
+ ] ||
+ 'ETH';
+ return { symbol: nativeSymbol, decimals: 18 };
+ }
+
+ if (!tokenAddress) {
+ return { symbol: 'Unknown Token', decimals: 18 };
+ }
+ return await getGatorErc20TokenInfo(
+ tokenAddress,
+ chainId as Hex,
+ allowExternalServices,
+ getTokenStandardAndDetailsByChain,
+ );
+}
+
+/**
+ * Formats a token value to a human-readable string.
+ * @param args - The arguments to format.
+ * @param args.value - The token value in wei as a hex string.
+ * @param args.decimals - The number of decimal places the token uses.
+ * @returns The formatted human-readable token value.
+ */
+export const formatUnitsFromHex = ({
+ value,
+ decimals,
+}: {
+ value: Hex;
+ decimals: number;
+}): string => {
+ if (!value) {
+ return '0';
+ }
+ const valueBigInt = BigInt(value);
+ const valueString = valueBigInt.toString().padStart(decimals + 1, '0');
+
+ const decimalPart = valueString.slice(0, -decimals);
+ const fractionalPart = valueString.slice(-decimals);
+ const trimmedFractionalPart = fractionalPart.replace(/0+$/u, '');
+
+ if (trimmedFractionalPart.length > 0) {
+ return `${decimalPart}.${trimmedFractionalPart}`;
+ }
+ return decimalPart;
+};
+
+/**
+ * Converts a unix timestamp(in seconds) to a human-readable date format.
+ *
+ * @param timestamp - The unix timestamp in seconds.
+ * @returns The formatted date string in mm/dd/yyyy format.
+ */
+export const convertTimestampToReadableDate = (timestamp: number) => {
+ if (timestamp === 0) {
+ return '';
+ }
+ const date = new Date(timestamp * 1000); // Convert seconds to milliseconds
+
+ if (isNaN(date.getTime())) {
+ return '';
+ }
+
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ const year = date.getFullYear();
+
+ return `${month}/${day}/${year}`;
+};
+
+/**
+ * Extracts the expiry timestamp from the rules.
+ *
+ * @param rules - The rules to extract the expiry from.
+ * @returns The expiry timestamp.
+ */
+export const extractExpiry = (rules: GatorPermissionRule[]): number => {
+ if (!rules) {
+ return 0;
+ }
+ const expiry = rules.find((rule) => rule.type === 'expiry');
+ if (!expiry) {
+ return 0;
+ }
+ return expiry.data.timestamp;
+};
diff --git a/shared/lib/gator-permissions/index.ts b/shared/lib/gator-permissions/index.ts
new file mode 100644
index 000000000000..10cc3c2d4316
--- /dev/null
+++ b/shared/lib/gator-permissions/index.ts
@@ -0,0 +1 @@
+export * from './gator-permissions-utils';
diff --git a/ui/components/multichain/pages/gator-permissions/components/index.ts b/ui/components/multichain/pages/gator-permissions/components/index.ts
index 0d2db7b8b4fd..8b34a98ee113 100644
--- a/ui/components/multichain/pages/gator-permissions/components/index.ts
+++ b/ui/components/multichain/pages/gator-permissions/components/index.ts
@@ -1,2 +1,3 @@
export { PermissionListItem } from './permission-list-item';
export { PermissionGroupListItem } from './permission-group-list-item';
+export { ReviewGatorPermissionItem } from './review-gator-permission-item';
diff --git a/ui/components/multichain/pages/gator-permissions/components/review-gator-permission-item.tsx b/ui/components/multichain/pages/gator-permissions/components/review-gator-permission-item.tsx
new file mode 100644
index 000000000000..13b77311383c
--- /dev/null
+++ b/ui/components/multichain/pages/gator-permissions/components/review-gator-permission-item.tsx
@@ -0,0 +1,439 @@
+import React, { useMemo, useState } from 'react';
+import {
+ BoxFlexDirection,
+ IconColor,
+ BoxJustifyContent,
+ TextColor,
+ TextAlign,
+ TextVariant,
+ BoxBackgroundColor,
+ Box,
+ BoxAlignItems,
+ Text,
+ ButtonIcon,
+ Icon,
+ AvatarNetwork,
+ AvatarNetworkSize,
+ ButtonIconSize,
+ IconName,
+} from '@metamask/design-system-react';
+import { getImageForChainId } from '../../../../../selectors/multichain';
+import { getURLHost, shortenAddress } from '../../../../../helpers/utils/util';
+import {
+ PermissionTypesWithCustom,
+ Signer,
+ StoredGatorPermissionSanitized,
+} from '@metamask/gator-permissions-controller';
+import Card from '../../../../ui/card';
+import { useI18nContext } from '../../../../../hooks/useI18nContext';
+import {
+ convertTimestampToReadableDate,
+ getPeriodFrequencyLocaleTranslationKey,
+ formatUnitsFromHex,
+} from '../../../../../../shared/lib/gator-permissions';
+import { useGatorTokenInfo } from '../../../../../hooks/gator-permissions/useGatorTokenInfo';
+import { Hex } from 'viem';
+import { PreferredAvatar } from '../../../../app/preferred-avatar';
+import { BackgroundColor } from '../../../../../helpers/constants/design-system';
+
+type ReviewGatorPermissionItemProps = {
+ /**
+ * The network name to display
+ */
+ networkName: string;
+
+ /**
+ * The gator permission to display
+ */
+ gatorPermission: StoredGatorPermissionSanitized<
+ Signer,
+ PermissionTypesWithCustom
+ >;
+
+ /**
+ * The function to call when the revoke is clicked
+ */
+ onRevokeClick: () => void;
+};
+
+type PermissionExpandedDetails = Record;
+
+type PermissionDetails = {
+ amountLabel: string;
+ frequencyLabel: string;
+ amount: string;
+ frequency: string;
+};
+
+export const ReviewGatorPermissionItem = ({
+ networkName,
+ gatorPermission,
+ onRevokeClick,
+}: ReviewGatorPermissionItemProps) => {
+ const t = useI18nContext();
+ const { permissionResponse, siteOrigin } = gatorPermission;
+
+ const chainId = permissionResponse.chainId;
+ const permissionType = permissionResponse.permission.type;
+
+ const networkImageUrl = getImageForChainId(chainId);
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ const { loading: gatorTokenInfoLoading, data: gatorTokenInfo } =
+ useGatorTokenInfo(
+ permissionType,
+ chainId,
+ permissionResponse.permission.data.tokenAddress as string,
+ );
+
+ /**
+ * Handles the click event for the expand/collapse button
+ */
+ const handleExpandClick = () => {
+ setIsExpanded(!isExpanded);
+ };
+
+ /**
+ * Returns the expanded permission details
+ */
+ const expandedPermissionSecondaryDetails =
+ useMemo((): PermissionExpandedDetails => {
+ const { symbol, decimals } = gatorTokenInfo || {};
+ switch (permissionType) {
+ case 'native-token-stream':
+ case 'erc20-token-stream':
+ return {
+ 'Initial Allowance': `${
+ formatUnitsFromHex({
+ value: permissionResponse.permission.data.initialAmount as Hex,
+ decimals: decimals || 0,
+ }) as string
+ } ${symbol || ''}`,
+ 'Max Allowance': `${
+ formatUnitsFromHex({
+ value: permissionResponse.permission.data.maxAmount as Hex,
+ decimals: decimals || 0,
+ }) as string
+ } ${symbol || ''}`,
+ 'Start Date': convertTimestampToReadableDate(
+ permissionResponse.permission.data.startTime as number,
+ ),
+ 'Expiration Date': 'N/A', // TODO: Add expiry date once the type have been updated in the controller: https://github.com/MetaMask/core/pull/6379
+ 'Stream Rate':
+ (formatUnitsFromHex({
+ value: permissionResponse.permission.data
+ .amountPerSecond as Hex,
+ decimals: decimals || 0,
+ }) as string) +
+ ` ${symbol || ''}` +
+ '/sec',
+ };
+ case 'native-token-periodic':
+ case 'erc20-token-periodic':
+ return {
+ 'Start Date': convertTimestampToReadableDate(
+ permissionResponse.permission.data.startTime as number,
+ ),
+ 'Expiration Date': 'N/A', // TODO: Add expiry date once the type have been updated in the controller: https://github.com/MetaMask/core/pull/6379
+ };
+ default:
+ return {};
+ }
+ }, [permissionType, permissionResponse, gatorTokenInfo]);
+
+ /**
+ * Returns the permission details
+ */
+ const permissionDetails = useMemo((): PermissionDetails => {
+ let permissionMetadata = {
+ amountLabel: '',
+ frequencyLabel: '',
+ amount: '0',
+ frequency: '',
+ };
+ const { symbol, decimals } = gatorTokenInfo || {};
+
+ switch (permissionType) {
+ case 'native-token-stream':
+ case 'erc20-token-stream':
+ permissionMetadata.amount = `${
+ formatUnitsFromHex({
+ value: permissionResponse.permission.data.amountPerSecond as Hex,
+ decimals: decimals || 0,
+ }) as string
+ } ${symbol || ''}`;
+ permissionMetadata.frequency =
+ 'gatorPermissionWeeklyRedemptionFrequency';
+ permissionMetadata.frequencyLabel =
+ 'gatorPermissionTokenPeriodicFrequencyLabel';
+ permissionMetadata.amountLabel = 'Stream Amount';
+ break;
+ case 'native-token-periodic':
+ case 'erc20-token-periodic':
+ permissionMetadata.amount = `${
+ formatUnitsFromHex({
+ value: permissionResponse.permission.data.periodAmount as Hex,
+ decimals: decimals || 0,
+ }) as string
+ } ${symbol || ''}`;
+ permissionMetadata.frequency = getPeriodFrequencyLocaleTranslationKey(
+ permissionResponse.permission.data.periodDuration,
+ );
+ permissionMetadata.frequencyLabel =
+ 'gatorPermissionTokenStreamFrequencyLabel';
+ permissionMetadata.amountLabel = 'Amount';
+ break;
+ default:
+ break;
+ }
+ return permissionMetadata;
+ }, [permissionType, permissionResponse]);
+
+ /**
+ * Renders the permission details row
+ */
+ const renderExpandedPermissionDetailsRow = (key: string, value: string) => {
+ return (
+
+
+ {key}
+
+
+ {value}
+
+
+ );
+ };
+
+ if (gatorTokenInfoLoading || !gatorTokenInfo) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {getURLHost(siteOrigin)}
+
+
+
+ Revoke
+
+
+
+
+
+
+ {/* Amount Row */}
+
+
+ {permissionDetails.amountLabel}
+
+
+
+ {permissionDetails.amount}
+
+
+
+
+ {/* Frequency Row */}
+
+
+ {permissionDetails.frequencyLabel}
+
+
+
+ {t(permissionDetails.frequency)}
+
+
+
+
+ {/* Account row */}
+
+
+ Account
+
+
+
+
+ {shortenAddress(permissionResponse.address)}
+
+
+
+
+
+
+
+
+
+ {isExpanded ? 'Hide Details' : 'Show Details'}
+
+
+
+
+
+ {isExpanded && (
+ <>
+ {/* Network name row */}
+
+
+ Networks
+
+
+
+
+ {networkName}
+
+
+
+ {Object.entries(expandedPermissionSecondaryDetails).map(
+ ([key, value]) => {
+ return renderExpandedPermissionDetailsRow(key, value);
+ },
+ )}
+ >
+ )}
+
+
+ );
+};
diff --git a/ui/components/multichain/pages/gator-permissions/review-permissions/__snapshots__/review-gator-permissions-page.test.tsx.snap b/ui/components/multichain/pages/gator-permissions/review-permissions/__snapshots__/review-gator-permissions-page.test.tsx.snap
index be8d973d7dcf..b9a0b826c3fe 100644
--- a/ui/components/multichain/pages/gator-permissions/review-permissions/__snapshots__/review-gator-permissions-page.test.tsx.snap
+++ b/ui/components/multichain/pages/gator-permissions/review-permissions/__snapshots__/review-gator-permissions-page.test.tsx.snap
@@ -49,6 +49,26 @@ exports[`Review Gator Permissions Page render renders correctly 1`] = `
+
+
+
+ Nothing to see here
+
+
+ This is where you can see the permissions you've given to installed Snaps or connected sites.
+
+
+
diff --git a/ui/components/multichain/pages/gator-permissions/review-permissions/review-gator-permissions-page.tsx b/ui/components/multichain/pages/gator-permissions/review-permissions/review-gator-permissions-page.tsx
index 4712a0de1981..a64e68cb25cc 100644
--- a/ui/components/multichain/pages/gator-permissions/review-permissions/review-gator-permissions-page.tsx
+++ b/ui/components/multichain/pages/gator-permissions/review-permissions/review-gator-permissions-page.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useEffect, useMemo, useState } from 'react';
import { useHistory, useParams } from 'react-router-dom';
import { useSelector } from 'react-redux';
import { Hex } from '@metamask/utils';
@@ -10,12 +10,27 @@ import {
IconColor,
TextAlign,
TextVariant,
+ Box,
+ TextColor,
+ BoxJustifyContent,
+ BoxFlexDirection,
} from '@metamask/design-system-react';
-import { Header, Page } from '../../page';
+import {
+ PermissionTypesWithCustom,
+ Signer,
+ StoredGatorPermissionSanitized,
+} from '@metamask/gator-permissions-controller';
+import { Content, Header, Page } from '../../page';
import { BackgroundColor } from '../../../../../helpers/constants/design-system';
import { useI18nContext } from '../../../../../hooks/useI18nContext';
import { extractNetworkName } from '../helper';
import { getMultichainNetworkConfigurationsByChainId } from '../../../../../selectors';
+import { useRevokeGatorPermissions } from '../../../../../hooks/gator-permissions/useRevokeGatorPermissions';
+import {
+ AppState,
+ getAggregatedGatorPermissionByChainId,
+} from '../../../../../selectors/gator-permissions/gator-permissions';
+import { ReviewGatorPermissionItem } from '../components';
export const ReviewGatorPermissionsPage = () => {
const t = useI18nContext();
@@ -24,21 +39,71 @@ export const ReviewGatorPermissionsPage = () => {
const [, evmNetworks] = useSelector(
getMultichainNetworkConfigurationsByChainId,
);
- const getNetworkNameForChainId = () => {
+ const [totalGatorPermissions, setTotalGatorPermissions] = useState(0);
+
+ const networkName: string = useMemo(() => {
if (!chainId) {
return t('unknownNetworkForGatorPermissions');
}
const networkNameKey = extractNetworkName(evmNetworks, chainId as Hex);
- const networkName = t(networkNameKey);
+ const networkNameFromTranslation: string = t(networkNameKey);
// If the translation key doesn't exist (returns the same key), fall back to the full network name
- if (!networkName || networkName === networkNameKey) {
+ if (
+ !networkNameFromTranslation ||
+ networkNameFromTranslation === networkNameKey
+ ) {
return extractNetworkName(evmNetworks, chainId as Hex, true);
}
- return networkName;
+ return networkNameFromTranslation;
+ }, [chainId, evmNetworks, t]);
+
+ const gatorPermissions = useSelector((state: AppState) =>
+ getAggregatedGatorPermissionByChainId(state, {
+ aggregatedPermissionType: 'token-transfer',
+ chainId: chainId as Hex,
+ }),
+ );
+
+ const { revokeGatorPermission } = useRevokeGatorPermissions({
+ chainId: (chainId ?? '') as Hex,
+ });
+
+ useEffect(() => {
+ setTotalGatorPermissions(gatorPermissions.length);
+ }, [chainId, gatorPermissions]);
+
+ const handleRevokeClick = async (
+ permission: StoredGatorPermissionSanitized<
+ Signer,
+ PermissionTypesWithCustom
+ >,
+ ) => {
+ try {
+ await revokeGatorPermission(permission);
+ } catch (error) {
+ console.error('Error revoking gator permission:', error);
+ }
};
+ const renderGatorPermissions = (
+ permissions: StoredGatorPermissionSanitized<
+ Signer,
+ PermissionTypesWithCustom
+ >[],
+ ) =>
+ permissions.map((permission) => {
+ return (
+ handleRevokeClick(permission)}
+ />
+ );
+ });
+
return (
{
textAlign={TextAlign.Center}
data-testid="review-gator-permissions-page-title"
>
- {getNetworkNameForChainId()}
+ {networkName}
+
+ {totalGatorPermissions > 0 ? (
+ renderGatorPermissions(gatorPermissions)
+ ) : (
+
+
+ {t('permissionsPageEmptyContent')}
+
+
+ {t('permissionsPageEmptySubContent')}
+
+
+ )}
+
);
};
diff --git a/ui/hooks/gator-permissions/useGatorTokenInfo.ts b/ui/hooks/gator-permissions/useGatorTokenInfo.ts
new file mode 100644
index 000000000000..f4a808e690ba
--- /dev/null
+++ b/ui/hooks/gator-permissions/useGatorTokenInfo.ts
@@ -0,0 +1,51 @@
+import { useEffect, useState } from 'react';
+import {
+ getGatorPermissionTokenInfo,
+ GatorTokenInfo,
+} from '../../../shared/lib/gator-permissions';
+
+export function useGatorTokenInfo(
+ permissionType: string,
+ chainId: string,
+ tokenAddress: string,
+) {
+ const [loading, setLoading] = useState(true);
+ const [data, setData] = useState(undefined);
+ const [error, setError] = useState(undefined);
+
+ useEffect(() => {
+ let cancelled = false;
+ async function fetchGatorTokenInfo() {
+ try {
+ setError(undefined);
+ setLoading(true);
+
+ const newData = await getGatorPermissionTokenInfo({
+ permissionType: permissionType,
+ chainId: chainId,
+ tokenAddress: tokenAddress,
+ allowExternalServices: true,
+ });
+ if (!cancelled) {
+ setData(newData);
+ }
+ } catch (err) {
+ if (!cancelled) {
+ setError(err as Error);
+ }
+ } finally {
+ if (!cancelled) {
+ setLoading(false);
+ }
+ }
+ }
+
+ fetchGatorTokenInfo();
+
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ return { data, error, loading };
+}
diff --git a/ui/selectors/gator-permissions/gator-permissions.test.ts b/ui/selectors/gator-permissions/gator-permissions.test.ts
index e145f24fc124..6f369fd4a581 100644
--- a/ui/selectors/gator-permissions/gator-permissions.test.ts
+++ b/ui/selectors/gator-permissions/gator-permissions.test.ts
@@ -13,6 +13,8 @@ import {
getGatorPermissionsMap,
getAggregatedGatorPermissionsCountAcrossAllChains,
getPermissionGroupDetails,
+ getAggregatedGatorPermissionByChainId,
+ AppState,
} from './gator-permissions';
const MOCK_CHAIN_ID_MAINNET = '0x1' as Hex;
@@ -250,12 +252,11 @@ describe('Gator Permissions Selectors', () => {
siteOrigin: 'http://localhost:8001',
},
});
- const mockState = {
+ const mockState: AppState = {
metamask: {
gatorPermissionsMapSerialized: JSON.stringify(mockGatorPermissionsMap),
isGatorPermissionsEnabled: true,
isFetchingGatorPermissions: false,
- isUpdatingGatorPermissions: false,
gatorPermissionsProviderSnapId: 'local:http://localhost:8080/' as SnapId,
},
};
@@ -306,7 +307,6 @@ describe('Gator Permissions Selectors', () => {
}),
isGatorPermissionsEnabled: true,
isFetchingGatorPermissions: false,
- isUpdatingGatorPermissions: false,
gatorPermissionsProviderSnapId:
'local:http://localhost:8080/' as SnapId,
},
@@ -343,7 +343,6 @@ describe('Gator Permissions Selectors', () => {
),
isGatorPermissionsEnabled: true,
isFetchingGatorPermissions: false,
- isUpdatingGatorPermissions: false,
gatorPermissionsProviderSnapId:
'local:http://localhost:8080/' as SnapId,
},
@@ -409,7 +408,6 @@ describe('Gator Permissions Selectors', () => {
),
isGatorPermissionsEnabled: true,
isFetchingGatorPermissions: false,
- isUpdatingGatorPermissions: false,
gatorPermissionsProviderSnapId:
'local:http://localhost:8080/' as SnapId,
},
@@ -456,7 +454,6 @@ describe('Gator Permissions Selectors', () => {
),
isGatorPermissionsEnabled: true,
isFetchingGatorPermissions: false,
- isUpdatingGatorPermissions: false,
gatorPermissionsProviderSnapId:
'local:http://localhost:8080/' as SnapId,
},
@@ -503,7 +500,6 @@ describe('Gator Permissions Selectors', () => {
),
isGatorPermissionsEnabled: true,
isFetchingGatorPermissions: false,
- isUpdatingGatorPermissions: false,
gatorPermissionsProviderSnapId:
'local:http://localhost:8080/' as SnapId,
},
@@ -550,7 +546,6 @@ describe('Gator Permissions Selectors', () => {
),
isGatorPermissionsEnabled: true,
isFetchingGatorPermissions: false,
- isUpdatingGatorPermissions: false,
gatorPermissionsProviderSnapId:
'local:http://localhost:8080/' as SnapId,
},
@@ -622,7 +617,6 @@ describe('Gator Permissions Selectors', () => {
}),
isGatorPermissionsEnabled: true,
isFetchingGatorPermissions: false,
- isUpdatingGatorPermissions: false,
gatorPermissionsProviderSnapId:
'local:http://localhost:8080/' as SnapId,
},
@@ -656,7 +650,6 @@ describe('Gator Permissions Selectors', () => {
),
isGatorPermissionsEnabled: true,
isFetchingGatorPermissions: false,
- isUpdatingGatorPermissions: false,
gatorPermissionsProviderSnapId:
'local:http://localhost:8080/' as SnapId,
},
@@ -700,7 +693,6 @@ describe('Gator Permissions Selectors', () => {
),
isGatorPermissionsEnabled: true,
isFetchingGatorPermissions: false,
- isUpdatingGatorPermissions: false,
gatorPermissionsProviderSnapId:
'local:http://localhost:8080/' as SnapId,
},
@@ -744,7 +736,6 @@ describe('Gator Permissions Selectors', () => {
),
isGatorPermissionsEnabled: true,
isFetchingGatorPermissions: false,
- isUpdatingGatorPermissions: false,
gatorPermissionsProviderSnapId:
'local:http://localhost:8080/' as SnapId,
},
@@ -788,7 +779,6 @@ describe('Gator Permissions Selectors', () => {
),
isGatorPermissionsEnabled: true,
isFetchingGatorPermissions: false,
- isUpdatingGatorPermissions: false,
gatorPermissionsProviderSnapId:
'local:http://localhost:8080/' as SnapId,
},
@@ -838,7 +828,6 @@ describe('Gator Permissions Selectors', () => {
),
isGatorPermissionsEnabled: true,
isFetchingGatorPermissions: false,
- isUpdatingGatorPermissions: false,
gatorPermissionsProviderSnapId:
'local:http://localhost:8080/' as SnapId,
},
@@ -879,7 +868,6 @@ describe('Gator Permissions Selectors', () => {
),
isGatorPermissionsEnabled: true,
isFetchingGatorPermissions: false,
- isUpdatingGatorPermissions: false,
gatorPermissionsProviderSnapId:
'local:http://localhost:8080/' as SnapId,
},
@@ -901,4 +889,180 @@ describe('Gator Permissions Selectors', () => {
});
});
});
+
+ describe('getAggregatedGatorPermissionByChainId', () => {
+ it('should return aggregated token-transfer permissions for a given chainId', () => {
+ const result = getAggregatedGatorPermissionByChainId(mockState, {
+ aggregatedPermissionType: 'token-transfer',
+ chainId: MOCK_CHAIN_ID_MAINNET,
+ });
+
+ expect(result).toHaveLength(3);
+
+ const permissionTypes = result.map(
+ (
+ permission: StoredGatorPermissionSanitized<
+ Signer,
+ PermissionTypesWithCustom
+ >,
+ ) => permission.permissionResponse.permission.type,
+ );
+ expect(permissionTypes).toContain('native-token-stream');
+ expect(permissionTypes).toContain('erc20-token-stream');
+ expect(permissionTypes).toContain('native-token-periodic');
+ });
+
+ it('should return aggregated token-transfer permissions for a different chainId', () => {
+ const result = getAggregatedGatorPermissionByChainId(mockState, {
+ aggregatedPermissionType: 'token-transfer',
+ chainId: MOCK_CHAIN_ID_POLYGON,
+ });
+
+ expect(result).toHaveLength(3);
+
+ const permissionTypes = result.map(
+ (
+ permission: StoredGatorPermissionSanitized<
+ Signer,
+ PermissionTypesWithCustom
+ >,
+ ) => permission.permissionResponse.permission.type,
+ );
+ expect(permissionTypes).toContain('native-token-stream');
+ expect(permissionTypes).toContain('erc20-token-stream');
+ expect(permissionTypes).toContain('native-token-periodic');
+ });
+
+ it('should return empty array for non-existent chainId', () => {
+ const result = getAggregatedGatorPermissionByChainId(mockState, {
+ aggregatedPermissionType: 'token-transfer',
+ chainId: '0x1111111111111111111111111111111111111111' as Hex,
+ });
+
+ expect(result).toEqual([]);
+ });
+
+ it('should return empty array for unknown aggregated permission type', () => {
+ const result = getAggregatedGatorPermissionByChainId(mockState, {
+ aggregatedPermissionType: 'unknown-type',
+ chainId: MOCK_CHAIN_ID_MAINNET,
+ });
+
+ expect(result).toEqual([]);
+ });
+
+ it('should handle state with only some permission types populated', () => {
+ const mockStateWithPartialPermissions = {
+ metamask: {
+ gatorPermissionsMapSerialized: JSON.stringify({
+ 'native-token-stream': {
+ [MOCK_CHAIN_ID_MAINNET]: [
+ {
+ permissionResponse: {
+ chainId: MOCK_CHAIN_ID_MAINNET as Hex,
+ address: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778',
+ expiry: 1750291200,
+ permission: {
+ type: 'native-token-stream',
+ data: {
+ maxAmount: '0x22b1c8c1227a0000',
+ initialAmount: '0x6f05b59d3b20000',
+ amountPerSecond: '0x6f05b59d3b20000',
+ startTime: 1747699200,
+ justification: 'Test justification',
+ },
+ rules: {},
+ },
+ context: '0x00000000',
+ signerMeta: {
+ delegationManager:
+ '0xdb9B1e94B5b69Df7e401DDbedE43491141047dB3',
+ },
+ },
+ siteOrigin: 'http://localhost:8000',
+ },
+ ],
+ [MOCK_CHAIN_ID_POLYGON]: [],
+ },
+ 'erc20-token-stream': {
+ [MOCK_CHAIN_ID_MAINNET]: [],
+ [MOCK_CHAIN_ID_POLYGON]: [],
+ },
+ 'native-token-periodic': {
+ [MOCK_CHAIN_ID_MAINNET]: [],
+ [MOCK_CHAIN_ID_POLYGON]: [],
+ },
+ 'erc20-token-periodic': {
+ [MOCK_CHAIN_ID_MAINNET]: [],
+ [MOCK_CHAIN_ID_POLYGON]: [],
+ },
+ other: {
+ [MOCK_CHAIN_ID_MAINNET]: [],
+ [MOCK_CHAIN_ID_POLYGON]: [],
+ },
+ }),
+ isGatorPermissionsEnabled: true,
+ isFetchingGatorPermissions: false,
+ gatorPermissionsProviderSnapId:
+ 'local:http://localhost:8080/' as SnapId,
+ },
+ };
+
+ const result = getAggregatedGatorPermissionByChainId(
+ mockStateWithPartialPermissions,
+ {
+ aggregatedPermissionType: 'token-transfer',
+ chainId: MOCK_CHAIN_ID_MAINNET,
+ },
+ );
+
+ expect(result).toHaveLength(1);
+ expect(result[0].permissionResponse.permission.type).toBe(
+ 'native-token-stream',
+ );
+ });
+
+ it('should handle state with empty permission arrays', () => {
+ const mockStateWithEmptyPermissions = {
+ metamask: {
+ gatorPermissionsMapSerialized: JSON.stringify({
+ 'native-token-stream': {
+ [MOCK_CHAIN_ID_MAINNET]: [],
+ [MOCK_CHAIN_ID_POLYGON]: [],
+ },
+ 'erc20-token-stream': {
+ [MOCK_CHAIN_ID_MAINNET]: [],
+ [MOCK_CHAIN_ID_POLYGON]: [],
+ },
+ 'native-token-periodic': {
+ [MOCK_CHAIN_ID_MAINNET]: [],
+ [MOCK_CHAIN_ID_POLYGON]: [],
+ },
+ 'erc20-token-periodic': {
+ [MOCK_CHAIN_ID_MAINNET]: [],
+ [MOCK_CHAIN_ID_POLYGON]: [],
+ },
+ other: {
+ [MOCK_CHAIN_ID_MAINNET]: [],
+ [MOCK_CHAIN_ID_POLYGON]: [],
+ },
+ }),
+ isGatorPermissionsEnabled: true,
+ isFetchingGatorPermissions: false,
+ gatorPermissionsProviderSnapId:
+ 'local:http://localhost:8080/' as SnapId,
+ },
+ };
+
+ const result = getAggregatedGatorPermissionByChainId(
+ mockStateWithEmptyPermissions,
+ {
+ aggregatedPermissionType: 'token-transfer',
+ chainId: MOCK_CHAIN_ID_MAINNET,
+ },
+ );
+
+ expect(result).toEqual([]);
+ });
+ });
});
diff --git a/ui/selectors/gator-permissions/gator-permissions.ts b/ui/selectors/gator-permissions/gator-permissions.ts
index e7567df58bef..107013e4f232 100644
--- a/ui/selectors/gator-permissions/gator-permissions.ts
+++ b/ui/selectors/gator-permissions/gator-permissions.ts
@@ -241,3 +241,55 @@ export const getPermissionGroupDetails = createSelector(
}
},
);
+
+/**
+ * Get aggregated list of gator permissions for a specific chainId.
+ *
+ * @param _state - The current state
+ * @param options - The options to get permissions for (e.g. { aggregatedPermissionType: 'token-transfer', chainId: '0x1' })
+ * @param options.aggregatedPermissionType - The aggregated permission type to get permissions for (e.g. 'token-transfer' is a combination of the token streams and token subscriptions types)
+ * @param options.chainId - The chainId to get permissions for (e.g. 0x1)
+ * @returns A aggregated list of gator permissions filtered by chainId.
+ */
+export const getAggregatedGatorPermissionByChainId = createSelector(
+ [
+ getGatorPermissionsMap,
+ (
+ _state: AppState,
+ options: { aggregatedPermissionType: string; chainId: Hex },
+ ) => options,
+ ],
+ (
+ gatorPermissionsMap,
+ { aggregatedPermissionType, chainId },
+ ): StoredGatorPermissionSanitized[] => {
+ switch (aggregatedPermissionType) {
+ case 'token-transfer': {
+ const nativeTokenStreams =
+ gatorPermissionsMap['native-token-stream'][chainId] || [];
+
+ const erc20TokenStreams =
+ gatorPermissionsMap['erc20-token-stream'][chainId] || [];
+
+ const nativeTokenPeriodicPermissions =
+ gatorPermissionsMap['native-token-periodic'][chainId] || [];
+
+ const erc20TokenPeriodicPermissions =
+ gatorPermissionsMap['erc20-token-periodic'][chainId] || [];
+
+ return [
+ ...nativeTokenStreams,
+ ...erc20TokenStreams,
+ ...nativeTokenPeriodicPermissions,
+ ...erc20TokenPeriodicPermissions,
+ ];
+ }
+ default: {
+ console.warn(
+ `Unknown aggregated permission type: ${aggregatedPermissionType}`,
+ );
+ return [];
+ }
+ }
+ },
+);
From 4860776fcc887936bf34ce3c7dc5ab984c65c623 Mon Sep 17 00:00:00 2001
From: Idris Bowman <34751375+V00D00-child@users.noreply.github.com>
Date: Wed, 15 Oct 2025 16:31:33 -0400
Subject: [PATCH 2/7] Refactor ReviewGatorPermissionItem to support locales
---
app/_locales/en/messages.json | 34 +-
app/_locales/en_GB/messages.json | 34 +-
shared/constants/time.ts | 1 +
shared/lib/gator-permissions/index.ts | 3 +-
shared/lib/gator-permissions/time-utils.ts | 87 +++++
...or-permissions-utils.ts => token-utils.ts} | 117 ------
.../review-gator-permission-item.tsx | 365 ++++++++++--------
7 files changed, 354 insertions(+), 287 deletions(-)
create mode 100644 shared/lib/gator-permissions/time-utils.ts
rename shared/lib/gator-permissions/{gator-permissions-utils.ts => token-utils.ts} (61%)
diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json
index b5b0261828e4..002325d080ac 100644
--- a/app/_locales/en/messages.json
+++ b/app/_locales/en/messages.json
@@ -2665,11 +2665,15 @@
"gasUsed": {
"message": "Gas used"
},
- "gatorPermissionDailyRedemptionFrequency": {
+ "gatorPermissionCustomFrequency": {
+ "message": "Custom",
+ "description": "Time period for custom recurring permissions redemption"
+ },
+ "gatorPermissionDailyFrequency": {
"message": "Daily",
"description": "Time period for daily recurring permissions redemption"
},
- "gatorPermissionMonthlyRedemptionFrequency": {
+ "gatorPermissionMonthlyFrequency": {
"message": "Monthly",
"description": "Time period for monthly recurring permissions redemption"
},
@@ -2681,10 +2685,34 @@
"message": "Period",
"description": "Label for the period of a token stream permission"
},
- "gatorPermissionWeeklyRedemptionFrequency": {
+ "gatorPermissionWeeklyFrequency": {
"message": "Weekly",
"description": "Time period for weekly recurring permissions redemption"
},
+ "gatorPermissionsExpirationDate": {
+ "message": "Expiration date",
+ "description": "Label for the expiration date of a permission"
+ },
+ "gatorPermissionsInitialAllowance": {
+ "message": "Initial allowance",
+ "description": "Label for the initial allowance of a permission"
+ },
+ "gatorPermissionsMaxAllowance": {
+ "message": "Max allowance",
+ "description": "Label for the max allowance of a permission"
+ },
+ "gatorPermissionsStartDate": {
+ "message": "Start date",
+ "description": "Label for the start date of a permission"
+ },
+ "gatorPermissionsStreamRate": {
+ "message": "Stream rate",
+ "description": "Label for the stream rate of a permission"
+ },
+ "gatorPermissionsStreamingAmountLabel": {
+ "message": "Streaming amount",
+ "description": "Label for the stream rate of a permission"
+ },
"general": {
"message": "General"
},
diff --git a/app/_locales/en_GB/messages.json b/app/_locales/en_GB/messages.json
index 264bda54836f..002325d080ac 100644
--- a/app/_locales/en_GB/messages.json
+++ b/app/_locales/en_GB/messages.json
@@ -2665,11 +2665,15 @@
"gasUsed": {
"message": "Gas used"
},
- "gatorPermissionDailyRedemption": {
+ "gatorPermissionCustomFrequency": {
+ "message": "Custom",
+ "description": "Time period for custom recurring permissions redemption"
+ },
+ "gatorPermissionDailyFrequency": {
"message": "Daily",
"description": "Time period for daily recurring permissions redemption"
},
- "gatorPermissionMonthlyRedemption": {
+ "gatorPermissionMonthlyFrequency": {
"message": "Monthly",
"description": "Time period for monthly recurring permissions redemption"
},
@@ -2681,10 +2685,34 @@
"message": "Period",
"description": "Label for the period of a token stream permission"
},
- "gatorPermissionWeeklyRedemption": {
+ "gatorPermissionWeeklyFrequency": {
"message": "Weekly",
"description": "Time period for weekly recurring permissions redemption"
},
+ "gatorPermissionsExpirationDate": {
+ "message": "Expiration date",
+ "description": "Label for the expiration date of a permission"
+ },
+ "gatorPermissionsInitialAllowance": {
+ "message": "Initial allowance",
+ "description": "Label for the initial allowance of a permission"
+ },
+ "gatorPermissionsMaxAllowance": {
+ "message": "Max allowance",
+ "description": "Label for the max allowance of a permission"
+ },
+ "gatorPermissionsStartDate": {
+ "message": "Start date",
+ "description": "Label for the start date of a permission"
+ },
+ "gatorPermissionsStreamRate": {
+ "message": "Stream rate",
+ "description": "Label for the stream rate of a permission"
+ },
+ "gatorPermissionsStreamingAmountLabel": {
+ "message": "Streaming amount",
+ "description": "Label for the stream rate of a permission"
+ },
"general": {
"message": "General"
},
diff --git a/shared/constants/time.ts b/shared/constants/time.ts
index 00daabab6989..77cff68a0a79 100644
--- a/shared/constants/time.ts
+++ b/shared/constants/time.ts
@@ -4,3 +4,4 @@ export const MINUTE = SECOND * 60;
export const HOUR = MINUTE * 60;
export const DAY = HOUR * 24;
export const WEEK = DAY * 7;
+export const THIRTY_DAYS = DAY * 30;
diff --git a/shared/lib/gator-permissions/index.ts b/shared/lib/gator-permissions/index.ts
index 10cc3c2d4316..4e4c22c9dc02 100644
--- a/shared/lib/gator-permissions/index.ts
+++ b/shared/lib/gator-permissions/index.ts
@@ -1 +1,2 @@
-export * from './gator-permissions-utils';
+export * from './token-utils';
+export * from './time-utils';
diff --git a/shared/lib/gator-permissions/time-utils.ts b/shared/lib/gator-permissions/time-utils.ts
new file mode 100644
index 000000000000..2b82bd2755ad
--- /dev/null
+++ b/shared/lib/gator-permissions/time-utils.ts
@@ -0,0 +1,87 @@
+import { DAY, THIRTY_DAYS, WEEK } from '../../constants/time';
+
+/**
+ * An enum representing the time periods for which the stream rate can be calculated.
+ */
+export enum TimePeriod {
+ DAILY = 'Daily',
+ WEEKLY = 'Weekly',
+ MONTHLY = 'Monthly',
+}
+
+export type GatorPermissionRule = {
+ type: string;
+ isAdjustmentAllowed: boolean;
+ data: Record;
+};
+
+/**
+ * A mapping of time periods to their equivalent seconds.
+ */
+export const TIME_PERIOD_TO_SECONDS: Record = {
+ [TimePeriod.DAILY]: 60n * 60n * 24n, // 86,400(seconds)
+ [TimePeriod.WEEKLY]: 60n * 60n * 24n * 7n, // 604,800(seconds)
+ // Monthly is difficult because months are not consistent in length.
+ // We approximate by calculating the number of seconds in 1/12th of a year.
+ [TimePeriod.MONTHLY]: (60n * 60n * 24n * 365n) / 12n, // 2,629,760(seconds)
+};
+
+/**
+ * Generates a human-readable description for a period duration in seconds to be used for translation.
+ *
+ * @param periodDurationInSeconds - The period duration in seconds
+ * @returns A human-readable frequency description to be used for translation.
+ */
+export function getPeriodFrequencyValueTranslationKey(
+ periodDurationInSeconds: number,
+): string {
+ const periodDurationMs = periodDurationInSeconds * 1000;
+ if (periodDurationMs === DAY) {
+ return 'gatorPermissionDailyFrequency';
+ } else if (periodDurationMs === WEEK) {
+ return 'gatorPermissionWeeklyFrequency';
+ } else if (periodDurationMs === THIRTY_DAYS) {
+ return 'gatorPermissionMonthlyFrequency';
+ }
+ return 'gatorPermissionCustomFrequency';
+}
+
+/**
+ * Converts a unix timestamp(in seconds) to a human-readable date format.
+ *
+ * @param timestamp - The unix timestamp in seconds.
+ * @returns The formatted date string in mm/dd/yyyy format.
+ */
+export const convertTimestampToReadableDate = (timestamp: number): string => {
+ if (timestamp === 0) {
+ return '';
+ }
+ const date = new Date(timestamp * 1000); // Convert seconds to milliseconds
+
+ if (isNaN(date.getTime())) {
+ return '';
+ }
+
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ const year = date.getFullYear();
+
+ return `${month}/${day}/${year}`;
+};
+
+/**
+ * Extracts the expiry timestamp from the rules and converts it to a readable date.
+ *
+ * @param rules - The rules to extract the expiry from.
+ * @returns The expiry timestamp in a readable date format.
+ */
+export const extractExpiryToReadableDate = (
+ rules: GatorPermissionRule[],
+): string => {
+ const expiry = rules.find((rule) => rule.type === 'expiry');
+ if (expiry) {
+ return convertTimestampToReadableDate(expiry.data.timestamp as number);
+ }
+
+ return 'No expiry';
+};
diff --git a/shared/lib/gator-permissions/gator-permissions-utils.ts b/shared/lib/gator-permissions/token-utils.ts
similarity index 61%
rename from shared/lib/gator-permissions/gator-permissions-utils.ts
rename to shared/lib/gator-permissions/token-utils.ts
index bbe70e0974ce..099340e1dcbd 100644
--- a/shared/lib/gator-permissions/gator-permissions-utils.ts
+++ b/shared/lib/gator-permissions/token-utils.ts
@@ -30,56 +30,9 @@ export type GatorPermissionData = {
[key: string]: unknown;
};
-export type GatorPermissionRule = {
- type: string;
- isAdjustmentAllowed: boolean;
- data: Record;
-};
-
// Shared promise cache to dedupe and reuse token info fetches per chainId:address
const gatorTokenInfoPromiseCache = new Map>();
-/**
- * An enum representing the time periods for which the stream rate can be calculated.
- */
-export enum TimePeriod {
- DAILY = 'Daily',
- WEEKLY = 'Weekly',
- MONTHLY = 'Monthly',
-}
-
-/**
- * A mapping of time periods to their equivalent seconds.
- */
-export const TIME_PERIOD_TO_SECONDS: Record = {
- [TimePeriod.DAILY]: 60n * 60n * 24n, // 86,400(seconds)
- [TimePeriod.WEEKLY]: 60n * 60n * 24n * 7n, // 604,800(seconds)
- // Monthly is difficult because months are not consistent in length.
- // We approximate by calculating the number of seconds in 1/12th of a year.
- [TimePeriod.MONTHLY]: (60n * 60n * 24n * 365n) / 12n, // 2,629,760(seconds)
-};
-
-/**
- * Generates a human-readable description for a period duration in seconds to be used for translation.
- *
- * @param periodDuration - The period duration in seconds (can be string or number)
- * @returns A human-readable frequency description to be used for translation.
- */
-export function getPeriodFrequencyLocaleTranslationKey(
- periodDuration: string | number,
-): string {
- const duration = BigInt(periodDuration);
- if (duration === TIME_PERIOD_TO_SECONDS[TimePeriod.DAILY]) {
- return 'gatorPermissionDailyRedemptionFrequency';
- } else if (duration === TIME_PERIOD_TO_SECONDS[TimePeriod.WEEKLY]) {
- return 'gatorPermissionWeeklyRedemptionFrequency';
- } else if (duration === TIME_PERIOD_TO_SECONDS[TimePeriod.MONTHLY]) {
- return 'gatorPermissionMonthlyRedemptionFrequency';
- }
- // TODO: Handle custom time period that fall outside of the standard time periods
- return '';
-}
-
/**
* Fetch ERC-20 token info (symbol as name, decimals) without caching.
*
@@ -230,73 +183,3 @@ export async function getGatorPermissionTokenInfo(params: {
getTokenStandardAndDetailsByChain,
);
}
-
-/**
- * Formats a token value to a human-readable string.
- * @param args - The arguments to format.
- * @param args.value - The token value in wei as a hex string.
- * @param args.decimals - The number of decimal places the token uses.
- * @returns The formatted human-readable token value.
- */
-export const formatUnitsFromHex = ({
- value,
- decimals,
-}: {
- value: Hex;
- decimals: number;
-}): string => {
- if (!value) {
- return '0';
- }
- const valueBigInt = BigInt(value);
- const valueString = valueBigInt.toString().padStart(decimals + 1, '0');
-
- const decimalPart = valueString.slice(0, -decimals);
- const fractionalPart = valueString.slice(-decimals);
- const trimmedFractionalPart = fractionalPart.replace(/0+$/u, '');
-
- if (trimmedFractionalPart.length > 0) {
- return `${decimalPart}.${trimmedFractionalPart}`;
- }
- return decimalPart;
-};
-
-/**
- * Converts a unix timestamp(in seconds) to a human-readable date format.
- *
- * @param timestamp - The unix timestamp in seconds.
- * @returns The formatted date string in mm/dd/yyyy format.
- */
-export const convertTimestampToReadableDate = (timestamp: number) => {
- if (timestamp === 0) {
- return '';
- }
- const date = new Date(timestamp * 1000); // Convert seconds to milliseconds
-
- if (isNaN(date.getTime())) {
- return '';
- }
-
- const month = String(date.getMonth() + 1).padStart(2, '0');
- const day = String(date.getDate()).padStart(2, '0');
- const year = date.getFullYear();
-
- return `${month}/${day}/${year}`;
-};
-
-/**
- * Extracts the expiry timestamp from the rules.
- *
- * @param rules - The rules to extract the expiry from.
- * @returns The expiry timestamp.
- */
-export const extractExpiry = (rules: GatorPermissionRule[]): number => {
- if (!rules) {
- return 0;
- }
- const expiry = rules.find((rule) => rule.type === 'expiry');
- if (!expiry) {
- return 0;
- }
- return expiry.data.timestamp;
-};
diff --git a/ui/components/multichain/pages/gator-permissions/components/review-gator-permission-item.tsx b/ui/components/multichain/pages/gator-permissions/components/review-gator-permission-item.tsx
index 13b77311383c..46829f01dfb1 100644
--- a/ui/components/multichain/pages/gator-permissions/components/review-gator-permission-item.tsx
+++ b/ui/components/multichain/pages/gator-permissions/components/review-gator-permission-item.tsx
@@ -17,24 +17,29 @@ import {
ButtonIconSize,
IconName,
} from '@metamask/design-system-react';
-import { getImageForChainId } from '../../../../../selectors/multichain';
-import { getURLHost, shortenAddress } from '../../../../../helpers/utils/util';
import {
+ Erc20TokenPeriodicPermission,
+ Erc20TokenStreamPermission,
+ NativeTokenPeriodicPermission,
+ NativeTokenStreamPermission,
PermissionTypesWithCustom,
Signer,
StoredGatorPermissionSanitized,
} from '@metamask/gator-permissions-controller';
+import { getImageForChainId } from '../../../../../selectors/multichain';
+import { getURLHost, shortenAddress } from '../../../../../helpers/utils/util';
import Card from '../../../../ui/card';
import { useI18nContext } from '../../../../../hooks/useI18nContext';
import {
convertTimestampToReadableDate,
- getPeriodFrequencyLocaleTranslationKey,
- formatUnitsFromHex,
+ getPeriodFrequencyValueTranslationKey,
+ extractExpiryToReadableDate,
} from '../../../../../../shared/lib/gator-permissions';
import { useGatorTokenInfo } from '../../../../../hooks/gator-permissions/useGatorTokenInfo';
import { Hex } from 'viem';
import { PreferredAvatar } from '../../../../app/preferred-avatar';
import { BackgroundColor } from '../../../../../helpers/constants/design-system';
+import { Numeric } from '../../../../../../shared/modules/Numeric';
type ReviewGatorPermissionItemProps = {
/**
@@ -56,13 +61,21 @@ type ReviewGatorPermissionItemProps = {
onRevokeClick: () => void;
};
+/**
+ * The expanded permission details key(translation key) -> value
+ */
type PermissionExpandedDetails = Record;
type PermissionDetails = {
- amountLabel: string;
- frequencyLabel: string;
- amount: string;
- frequency: string;
+ amountLabel: {
+ translationKey: string;
+ value: string;
+ };
+ frequencyLabel: {
+ translationKey: string;
+ valueTranslationKey: string;
+ };
+ expandedDetails: PermissionExpandedDetails;
};
export const ReviewGatorPermissionItem = ({
@@ -75,10 +88,14 @@ export const ReviewGatorPermissionItem = ({
const chainId = permissionResponse.chainId;
const permissionType = permissionResponse.permission.type;
+ const permissionAccount = permissionResponse.address || '0x';
const networkImageUrl = getImageForChainId(chainId);
const [isExpanded, setIsExpanded] = useState(false);
+ const getDecimalizedHexValue = (value: Hex, assetDecimals: number) =>
+ new Numeric(value, 16).toBase(10).shiftedBy(assetDecimals).toString();
+
const { loading: gatorTokenInfoLoading, data: gatorTokenInfo } =
useGatorTokenInfo(
permissionType,
@@ -94,128 +111,192 @@ export const ReviewGatorPermissionItem = ({
};
/**
- * Returns the expanded permission details
+ * Returns the token stream permission details
+ * @param assetDecimals - The number of decimal places the token uses
+ * @param tokenSymbol - The symbol of the token
+ * @param permission - The stream permission data
+ * @returns The permission details
+ */
+ const getTokenStreamPermissionDetails = (
+ assetDecimals: number,
+ tokenSymbol: string,
+ permission: NativeTokenStreamPermission | Erc20TokenStreamPermission,
+ ): PermissionDetails => {
+ return {
+ amountLabel: {
+ translationKey: 'gatorPermissionsStreamingAmountLabel',
+ value: `${getDecimalizedHexValue(
+ permission.data.amountPerSecond,
+ assetDecimals,
+ )} ${tokenSymbol}`,
+ },
+ frequencyLabel: {
+ translationKey: 'gatorPermissionTokenStreamFrequencyLabel',
+ valueTranslationKey: 'gatorPermissionWeeklyFrequency',
+ },
+ expandedDetails: {
+ gatorPermissionsInitialAllowance: `${getDecimalizedHexValue(
+ permission.data.initialAmount || '0x0',
+ assetDecimals,
+ )} ${tokenSymbol}`,
+ gatorPermissionsMaxAllowance: `${getDecimalizedHexValue(
+ permission.data.maxAmount || '0x0',
+ assetDecimals,
+ )} ${tokenSymbol}`,
+ gatorPermissionsStartDate: convertTimestampToReadableDate(
+ permission.data.startTime as number,
+ ),
+ gatorPermissionsExpirationDate: extractExpiryToReadableDate(
+ (permission as any).rules || [],
+ ), // TODO: Need to expose rules on StoredGatorPermissionSanitized in the gator-permissions-controller
+ gatorPermissionsStreamRate: `${getDecimalizedHexValue(
+ permission.data.amountPerSecond,
+ assetDecimals,
+ )} ${tokenSymbol}/sec`,
+ },
+ };
+ };
+
+ /**
+ * Returns the token periodic permission details
+ * @param assetDecimals - The number of decimal places the token uses
+ * @param tokenSymbol - The symbol of the token
+ * @param permission - The periodic permission data
+ * @returns The permission details
*/
- const expandedPermissionSecondaryDetails =
- useMemo((): PermissionExpandedDetails => {
- const { symbol, decimals } = gatorTokenInfo || {};
- switch (permissionType) {
- case 'native-token-stream':
- case 'erc20-token-stream':
- return {
- 'Initial Allowance': `${
- formatUnitsFromHex({
- value: permissionResponse.permission.data.initialAmount as Hex,
- decimals: decimals || 0,
- }) as string
- } ${symbol || ''}`,
- 'Max Allowance': `${
- formatUnitsFromHex({
- value: permissionResponse.permission.data.maxAmount as Hex,
- decimals: decimals || 0,
- }) as string
- } ${symbol || ''}`,
- 'Start Date': convertTimestampToReadableDate(
- permissionResponse.permission.data.startTime as number,
- ),
- 'Expiration Date': 'N/A', // TODO: Add expiry date once the type have been updated in the controller: https://github.com/MetaMask/core/pull/6379
- 'Stream Rate':
- (formatUnitsFromHex({
- value: permissionResponse.permission.data
- .amountPerSecond as Hex,
- decimals: decimals || 0,
- }) as string) +
- ` ${symbol || ''}` +
- '/sec',
- };
- case 'native-token-periodic':
- case 'erc20-token-periodic':
- return {
- 'Start Date': convertTimestampToReadableDate(
- permissionResponse.permission.data.startTime as number,
- ),
- 'Expiration Date': 'N/A', // TODO: Add expiry date once the type have been updated in the controller: https://github.com/MetaMask/core/pull/6379
- };
- default:
- return {};
- }
- }, [permissionType, permissionResponse, gatorTokenInfo]);
+ const getTokenPeriodicPermissionDetails = (
+ assetDecimals: number,
+ tokenSymbol: string,
+ permission: NativeTokenPeriodicPermission | Erc20TokenPeriodicPermission,
+ ): PermissionDetails => {
+ return {
+ amountLabel: {
+ translationKey: 'amount',
+ value: `${getDecimalizedHexValue(
+ permission.data.periodAmount,
+ assetDecimals,
+ )} ${tokenSymbol}`,
+ },
+ frequencyLabel: {
+ translationKey: 'gatorPermissionTokenPeriodicFrequencyLabel',
+ valueTranslationKey: getPeriodFrequencyValueTranslationKey(
+ permission.data.periodDuration,
+ ),
+ },
+ expandedDetails: {
+ gatorPermissionsStartDate: convertTimestampToReadableDate(
+ permission.data.startTime ?? 0,
+ ),
+ gatorPermissionsExpirationDate: extractExpiryToReadableDate(
+ (permissionResponse.permission as any).rules || [],
+ ), // TODO: Need to expose rules on StoredGatorPermissionSanitized in the gator-permissions-controller
+ },
+ };
+ };
/**
* Returns the permission details
+ * @returns The permission details
*/
const permissionDetails = useMemo((): PermissionDetails => {
- let permissionMetadata = {
- amountLabel: '',
- frequencyLabel: '',
- amount: '0',
- frequency: '',
- };
const { symbol, decimals } = gatorTokenInfo || {};
-
switch (permissionType) {
case 'native-token-stream':
case 'erc20-token-stream':
- permissionMetadata.amount = `${
- formatUnitsFromHex({
- value: permissionResponse.permission.data.amountPerSecond as Hex,
- decimals: decimals || 0,
- }) as string
- } ${symbol || ''}`;
- permissionMetadata.frequency =
- 'gatorPermissionWeeklyRedemptionFrequency';
- permissionMetadata.frequencyLabel =
- 'gatorPermissionTokenPeriodicFrequencyLabel';
- permissionMetadata.amountLabel = 'Stream Amount';
- break;
+ return getTokenStreamPermissionDetails(
+ decimals || 0,
+ symbol || 'Unknown Token',
+ permissionResponse.permission,
+ );
case 'native-token-periodic':
case 'erc20-token-periodic':
- permissionMetadata.amount = `${
- formatUnitsFromHex({
- value: permissionResponse.permission.data.periodAmount as Hex,
- decimals: decimals || 0,
- }) as string
- } ${symbol || ''}`;
- permissionMetadata.frequency = getPeriodFrequencyLocaleTranslationKey(
- permissionResponse.permission.data.periodDuration,
+ return getTokenPeriodicPermissionDetails(
+ decimals || 0,
+ symbol || 'Unknown Token',
+ permissionResponse.permission,
);
- permissionMetadata.frequencyLabel =
- 'gatorPermissionTokenStreamFrequencyLabel';
- permissionMetadata.amountLabel = 'Amount';
- break;
default:
- break;
+ throw new Error(`Invalid permission type: ${permissionType}`);
}
- return permissionMetadata;
}, [permissionType, permissionResponse]);
/**
- * Renders the permission details row
+ * Renders the expanded permission details
+ * @param expandedPermissionSecondaryDetails - The expanded permission secondary details
+ * @returns The expanded permission details
*/
- const renderExpandedPermissionDetailsRow = (key: string, value: string) => {
+ const renderExpandedPermissionDetails = (
+ expandedPermissionSecondaryDetails: PermissionExpandedDetails,
+ ) => {
return (
-
-
- {key}
-
-
+ {/* Network name row */}
+
- {value}
-
-
+
+ {t('networks')}
+
+
+
+
+ {networkName}
+
+
+
+
+ {/* Expanded permission secondary details */}
+ {Object.entries(expandedPermissionSecondaryDetails).map(
+ ([key, value]) => {
+ return (
+
+
+ {t(key)}
+
+
+ {value}
+
+
+ );
+ },
+ )}
+ >
);
};
@@ -237,7 +318,7 @@ export const ReviewGatorPermissionItem = ({
margin={4}
backgroundColor={BackgroundColor.backgroundDefault}
>
-
+
- {permissionDetails.amountLabel}
+ {t(permissionDetails.amountLabel.translationKey)}
- {permissionDetails.amount}
+ {permissionDetails.amountLabel.value}
@@ -310,7 +391,7 @@ export const ReviewGatorPermissionItem = ({
color={TextColor.TextAlternative}
variant={TextVariant.BodyMd}
>
- {permissionDetails.frequencyLabel}
+ {t(permissionDetails.frequencyLabel.translationKey)}
- {t(permissionDetails.frequency)}
+ {t(permissionDetails.frequencyLabel.valueTranslationKey)}
@@ -341,7 +422,7 @@ export const ReviewGatorPermissionItem = ({
color={TextColor.TextAlternative}
variant={TextVariant.BodyMd}
>
- Account
+ {t('account')}
-
+
- {shortenAddress(permissionResponse.address)}
+ {shortenAddress(permissionAccount)}
-
+ {/* Expand/Collapse view */}
+
-
- {isExpanded && (
- <>
- {/* Network name row */}
-
-
- Networks
-
-
-
-
- {networkName}
-
-
-
- {Object.entries(expandedPermissionSecondaryDetails).map(
- ([key, value]) => {
- return renderExpandedPermissionDetailsRow(key, value);
- },
- )}
- >
- )}
+ {isExpanded &&
+ renderExpandedPermissionDetails(permissionDetails.expandedDetails)}
);
From 35b302c944e9f5d65d254c7c90a55e2fa5e814e1 Mon Sep 17 00:00:00 2001
From: Idris Bowman <34751375+V00D00-child@users.noreply.github.com>
Date: Thu, 16 Oct 2025 16:45:23 -0400
Subject: [PATCH 3/7] Use existing selectors to fetch token symbol and decimal
---
app/_locales/en/messages.json | 12 +
app/_locales/en_GB/messages.json | 12 +
shared/lib/gator-permissions/index.ts | 1 -
shared/lib/gator-permissions/time-utils.ts | 32 +-
shared/lib/gator-permissions/token-utils.ts | 185 ----------
.../review-gator-permission-item.tsx | 315 ++++++++++--------
.../gator-permissions/useGatorTokenInfo.ts | 51 ---
7 files changed, 202 insertions(+), 406 deletions(-)
delete mode 100644 shared/lib/gator-permissions/token-utils.ts
delete mode 100644 ui/hooks/gator-permissions/useGatorTokenInfo.ts
diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json
index 1d7b47224a97..1fcd48ffc007 100644
--- a/app/_locales/en/messages.json
+++ b/app/_locales/en/messages.json
@@ -2683,6 +2683,10 @@
"gasUsed": {
"message": "Gas used"
},
+ "gatorPermissionAnnualFrequency": {
+ "message": "Yearly",
+ "description": "Time period for annual recurring permissions redemption"
+ },
"gatorPermissionCustomFrequency": {
"message": "Custom",
"description": "Time period for custom recurring permissions redemption"
@@ -2691,10 +2695,18 @@
"message": "Daily",
"description": "Time period for daily recurring permissions redemption"
},
+ "gatorPermissionFortnightlyFrequency": {
+ "message": "Bi-Weekly",
+ "description": "Time period for fortnightly recurring permissions redemption"
+ },
"gatorPermissionMonthlyFrequency": {
"message": "Monthly",
"description": "Time period for monthly recurring permissions redemption"
},
+ "gatorPermissionNoExpiration": {
+ "message": "No expiration",
+ "description": "Label for a permission with no expiration"
+ },
"gatorPermissionTokenPeriodicFrequencyLabel": {
"message": "Transfer Window",
"description": "Label for the transfer window of a token periodic permission"
diff --git a/app/_locales/en_GB/messages.json b/app/_locales/en_GB/messages.json
index 1d7b47224a97..1fcd48ffc007 100644
--- a/app/_locales/en_GB/messages.json
+++ b/app/_locales/en_GB/messages.json
@@ -2683,6 +2683,10 @@
"gasUsed": {
"message": "Gas used"
},
+ "gatorPermissionAnnualFrequency": {
+ "message": "Yearly",
+ "description": "Time period for annual recurring permissions redemption"
+ },
"gatorPermissionCustomFrequency": {
"message": "Custom",
"description": "Time period for custom recurring permissions redemption"
@@ -2691,10 +2695,18 @@
"message": "Daily",
"description": "Time period for daily recurring permissions redemption"
},
+ "gatorPermissionFortnightlyFrequency": {
+ "message": "Bi-Weekly",
+ "description": "Time period for fortnightly recurring permissions redemption"
+ },
"gatorPermissionMonthlyFrequency": {
"message": "Monthly",
"description": "Time period for monthly recurring permissions redemption"
},
+ "gatorPermissionNoExpiration": {
+ "message": "No expiration",
+ "description": "Label for a permission with no expiration"
+ },
"gatorPermissionTokenPeriodicFrequencyLabel": {
"message": "Transfer Window",
"description": "Label for the transfer window of a token periodic permission"
diff --git a/shared/lib/gator-permissions/index.ts b/shared/lib/gator-permissions/index.ts
index 4e4c22c9dc02..c2763082734a 100644
--- a/shared/lib/gator-permissions/index.ts
+++ b/shared/lib/gator-permissions/index.ts
@@ -1,2 +1 @@
-export * from './token-utils';
export * from './time-utils';
diff --git a/shared/lib/gator-permissions/time-utils.ts b/shared/lib/gator-permissions/time-utils.ts
index 2b82bd2755ad..ac3a46fda0ee 100644
--- a/shared/lib/gator-permissions/time-utils.ts
+++ b/shared/lib/gator-permissions/time-utils.ts
@@ -1,29 +1,9 @@
-import { DAY, THIRTY_DAYS, WEEK } from '../../constants/time';
-
-/**
- * An enum representing the time periods for which the stream rate can be calculated.
- */
-export enum TimePeriod {
- DAILY = 'Daily',
- WEEKLY = 'Weekly',
- MONTHLY = 'Monthly',
-}
+import { DAY, FORTNIGHT, MONTH, WEEK, YEAR } from '../../constants/time';
export type GatorPermissionRule = {
type: string;
isAdjustmentAllowed: boolean;
- data: Record;
-};
-
-/**
- * A mapping of time periods to their equivalent seconds.
- */
-export const TIME_PERIOD_TO_SECONDS: Record = {
- [TimePeriod.DAILY]: 60n * 60n * 24n, // 86,400(seconds)
- [TimePeriod.WEEKLY]: 60n * 60n * 24n * 7n, // 604,800(seconds)
- // Monthly is difficult because months are not consistent in length.
- // We approximate by calculating the number of seconds in 1/12th of a year.
- [TimePeriod.MONTHLY]: (60n * 60n * 24n * 365n) / 12n, // 2,629,760(seconds)
+ data: Record;
};
/**
@@ -40,8 +20,12 @@ export function getPeriodFrequencyValueTranslationKey(
return 'gatorPermissionDailyFrequency';
} else if (periodDurationMs === WEEK) {
return 'gatorPermissionWeeklyFrequency';
- } else if (periodDurationMs === THIRTY_DAYS) {
+ } else if (periodDurationMs === FORTNIGHT) {
+ return 'gatorPermissionFortnightlyFrequency';
+ } else if (periodDurationMs === MONTH) {
return 'gatorPermissionMonthlyFrequency';
+ } else if (periodDurationMs === YEAR) {
+ return 'gatorPermissionAnnualFrequency';
}
return 'gatorPermissionCustomFrequency';
}
@@ -83,5 +67,5 @@ export const extractExpiryToReadableDate = (
return convertTimestampToReadableDate(expiry.data.timestamp as number);
}
- return 'No expiry';
+ return '';
};
diff --git a/shared/lib/gator-permissions/token-utils.ts b/shared/lib/gator-permissions/token-utils.ts
deleted file mode 100644
index 099340e1dcbd..000000000000
--- a/shared/lib/gator-permissions/token-utils.ts
+++ /dev/null
@@ -1,185 +0,0 @@
-import { Hex } from '@metamask/utils';
-import { CHAIN_ID_TO_CURRENCY_SYMBOL_MAP } from '../../constants/network';
-import { fetchAssetMetadata } from '../asset-utils';
-
-// Token info type used across helpers
-export type GatorTokenInfo = { symbol: string; decimals: number };
-
-// Type for the token details function that can be injected from UI
-export type GetTokenStandardAndDetailsByChain = (
- address: string,
- userAddress?: string,
- tokenId?: string,
- chainId?: string,
-) => Promise<{
- decimals?: string | number;
- symbol?: string;
- standard?: string;
- [key: string]: unknown;
-}>;
-
-// Type for translation function
-export type TranslationFunction = (key: string, ...args: unknown[]) => string;
-
-// Types for permission data
-export type GatorPermissionData = {
- tokenAddress?: string;
- amountPerSecond?: string;
- periodDuration?: string;
- periodAmount?: string;
- [key: string]: unknown;
-};
-
-// Shared promise cache to dedupe and reuse token info fetches per chainId:address
-const gatorTokenInfoPromiseCache = new Map>();
-
-/**
- * Fetch ERC-20 token info (symbol as name, decimals) without caching.
- *
- * Behavior:
- * - If external services are enabled, attempts the MetaMask token metadata API first.
- * - If missing data or disabled, falls back to background on-chain details by chain.
- * - Returns a best-effort `{ name, decimals }` (defaults: name='Unknown Token', decimals=18).
- *
- * @param address
- * @param chainId
- * @param allowExternalServices
- * @param getTokenStandardAndDetailsByChain
- */
-export async function fetchGatorErc20TokenInfo(
- address: string,
- chainId: Hex,
- allowExternalServices: boolean,
- getTokenStandardAndDetailsByChain?: GetTokenStandardAndDetailsByChain,
-): Promise {
- let symbol: string | undefined;
- let decimals: number | undefined;
-
- if (allowExternalServices) {
- const metadata = await fetchAssetMetadata(address, chainId);
- symbol = metadata?.symbol;
- decimals = metadata?.decimals;
- }
-
- if (!symbol || decimals === null || decimals === undefined) {
- if (getTokenStandardAndDetailsByChain) {
- try {
- const details = await getTokenStandardAndDetailsByChain(
- address,
- undefined,
- undefined,
- chainId,
- );
- const decRaw = details?.decimals as string | number | undefined;
- if (typeof decRaw === 'number') {
- decimals = decRaw;
- } else if (typeof decRaw === 'string') {
- const parsed10 = parseInt(decRaw, 10);
- if (Number.isFinite(parsed10)) {
- decimals = parsed10;
- } else {
- const parsed16 = parseInt(decRaw, 16);
- if (Number.isFinite(parsed16)) {
- decimals = parsed16;
- }
- }
- }
- symbol = details?.symbol ?? symbol;
- } catch (_e) {
- // ignore and keep fallbacks
- }
- }
- }
-
- return {
- symbol: symbol || 'Unknown Token',
- decimals: decimals ?? 18,
- } as const;
-}
-
-/**
- * Fetch ERC-20 token info (symbol as name, decimals) with caching and de-duped in-flight requests.
- *
- * Cache key: `${chainId}:${address.toLowerCase()}`
- * Behavior:
- * - Returns cached value when available.
- * - If a request for the same key is in-flight, returns the same promise.
- * - Otherwise, calls `fetchGatorErc20TokenInfo` and caches the result.
- *
- * @param address
- * @param chainId
- * @param allowExternalServices
- * @param getTokenStandardAndDetailsByChain
- */
-export async function getGatorErc20TokenInfo(
- address: string,
- chainId: Hex,
- allowExternalServices: boolean,
- getTokenStandardAndDetailsByChain?: GetTokenStandardAndDetailsByChain,
-): Promise {
- const key = `${chainId}:${address.toLowerCase()}`;
- const existing = gatorTokenInfoPromiseCache.get(key);
- if (existing) {
- return existing;
- }
- const promise = fetchGatorErc20TokenInfo(
- address,
- chainId,
- allowExternalServices,
- getTokenStandardAndDetailsByChain,
- );
- gatorTokenInfoPromiseCache.set(key, promise);
- return promise;
-}
-
-/**
- * Resolve token display info (name/symbol, decimals) for a Gator permission.
- *
- * - If `permissionType` includes 'native-token', returns network native symbol and 18 decimals.
- * - Otherwise, fetches ERC-20 info (cached) using `tokenAddress` from `permissionData`.
- *
- * @param params
- * @param params.permissionType
- * @param params.chainId
- * @param params.networkConfig
- * @param params.tokenAddress
- * @param params.allowExternalServices
- * @param params.getTokenStandardAndDetailsByChain
- */
-export async function getGatorPermissionTokenInfo(params: {
- permissionType: string;
- chainId: string;
- networkConfig?: { nativeCurrency?: string; name?: string } | null;
- tokenAddress?: string;
- allowExternalServices: boolean;
- getTokenStandardAndDetailsByChain?: GetTokenStandardAndDetailsByChain;
-}): Promise {
- const {
- permissionType,
- chainId,
- networkConfig,
- tokenAddress,
- allowExternalServices,
- getTokenStandardAndDetailsByChain,
- } = params;
- const isNative = permissionType.includes('native-token');
- if (isNative) {
- const nativeSymbol =
- networkConfig?.nativeCurrency ||
- CHAIN_ID_TO_CURRENCY_SYMBOL_MAP[
- chainId as keyof typeof CHAIN_ID_TO_CURRENCY_SYMBOL_MAP
- ] ||
- 'ETH';
- return { symbol: nativeSymbol, decimals: 18 };
- }
-
- if (!tokenAddress) {
- return { symbol: 'Unknown Token', decimals: 18 };
- }
- return await getGatorErc20TokenInfo(
- tokenAddress,
- chainId as Hex,
- allowExternalServices,
- getTokenStandardAndDetailsByChain,
- );
-}
diff --git a/ui/components/multichain/pages/gator-permissions/components/review-gator-permission-item.tsx b/ui/components/multichain/pages/gator-permissions/components/review-gator-permission-item.tsx
index 46829f01dfb1..ae1e7f55a592 100644
--- a/ui/components/multichain/pages/gator-permissions/components/review-gator-permission-item.tsx
+++ b/ui/components/multichain/pages/gator-permissions/components/review-gator-permission-item.tsx
@@ -1,4 +1,6 @@
import React, { useMemo, useState } from 'react';
+import { useSelector } from 'react-redux';
+import { Hex } from '@metamask/utils';
import {
BoxFlexDirection,
IconColor,
@@ -11,7 +13,6 @@ import {
BoxAlignItems,
Text,
ButtonIcon,
- Icon,
AvatarNetwork,
AvatarNetworkSize,
ButtonIconSize,
@@ -34,12 +35,21 @@ import {
convertTimestampToReadableDate,
getPeriodFrequencyValueTranslationKey,
extractExpiryToReadableDate,
+ GatorPermissionRule,
} from '../../../../../../shared/lib/gator-permissions';
-import { useGatorTokenInfo } from '../../../../../hooks/gator-permissions/useGatorTokenInfo';
-import { Hex } from 'viem';
import { PreferredAvatar } from '../../../../app/preferred-avatar';
import { BackgroundColor } from '../../../../../helpers/constants/design-system';
import { Numeric } from '../../../../../../shared/modules/Numeric';
+import {
+ getNativeTokenInfo,
+ selectERC20TokensByChain,
+} from '../../../../../selectors/selectors';
+import { getTokenMetadata } from '../../../../../helpers/utils/token-util';
+
+type TokenMetadata = {
+ symbol: string;
+ decimals: number;
+};
type ReviewGatorPermissionItemProps = {
/**
@@ -85,23 +95,45 @@ export const ReviewGatorPermissionItem = ({
}: ReviewGatorPermissionItemProps) => {
const t = useI18nContext();
const { permissionResponse, siteOrigin } = gatorPermission;
-
- const chainId = permissionResponse.chainId;
+ const [chainId] = permissionResponse.chainId;
const permissionType = permissionResponse.permission.type;
const permissionAccount = permissionResponse.address || '0x';
+ const tokenAddress = permissionResponse.permission.data.tokenAddress as
+ | Hex
+ | undefined;
- const networkImageUrl = getImageForChainId(chainId);
const [isExpanded, setIsExpanded] = useState(false);
+ const tokensByChain = useSelector(selectERC20TokensByChain);
+ const nativeTokenMetadata = useSelector((state) =>
+ getNativeTokenInfo(state, chainId),
+ ) as TokenMetadata;
- const getDecimalizedHexValue = (value: Hex, assetDecimals: number) =>
- new Numeric(value, 16).toBase(10).shiftedBy(assetDecimals).toString();
-
- const { loading: gatorTokenInfoLoading, data: gatorTokenInfo } =
- useGatorTokenInfo(
- permissionType,
- chainId,
- permissionResponse.permission.data.tokenAddress as string,
- );
+ const tokenMetadata: TokenMetadata = useMemo(() => {
+ if (tokenAddress) {
+ const tokenListForChain = tokensByChain?.[chainId]?.data || {};
+ const foundTokenMetadata = getTokenMetadata(
+ tokenAddress,
+ tokenListForChain,
+ );
+ if (foundTokenMetadata) {
+ return {
+ symbol: foundTokenMetadata.symbol || 'Unknown Token',
+ decimals: foundTokenMetadata.decimals || 18,
+ };
+ }
+ console.warn(
+ `Token metadata not found for address: ${tokenAddress} for chain: ${chainId}`,
+ );
+ return {
+ symbol: 'Unknown Token',
+ decimals: 18,
+ };
+ }
+ return {
+ symbol: nativeTokenMetadata.symbol,
+ decimals: nativeTokenMetadata.decimals,
+ };
+ }, [tokensByChain, chainId, tokenAddress, nativeTokenMetadata]);
/**
* Handles the click event for the expand/collapse button
@@ -110,25 +142,50 @@ export const ReviewGatorPermissionItem = ({
setIsExpanded(!isExpanded);
};
+ /**
+ * Converts a hex value to a decimal value
+ *
+ * @param value - The hex value to convert
+ * @param decimals - The number of decimals to shift the value by
+ * @returns The decimal value
+ */
+ const getDecimalizedHexValue = (value: Hex, decimals: number) =>
+ new Numeric(value, 16).toBase(10).shiftedBy(decimals).toString();
+
+ /**
+ * Returns the expiration date from the rules
+ *
+ * @param rules - The rules to extract the expiration from
+ * @returns The expiration date
+ */
+ const getExpirationDate = (rules: GatorPermissionRule[]): string => {
+ // TODO: Need to expose rules on StoredGatorPermissionSanitized in the gator-permissions-controller so we can have stronger typing
+ if (!rules) {
+ return t('gatorPermissionNoExpiration');
+ }
+ if (rules.length === 0) {
+ return t('gatorPermissionNoExpiration');
+ }
+ return extractExpiryToReadableDate(rules);
+ };
+
/**
* Returns the token stream permission details
- * @param assetDecimals - The number of decimal places the token uses
- * @param tokenSymbol - The symbol of the token
+ *
* @param permission - The stream permission data
* @returns The permission details
*/
const getTokenStreamPermissionDetails = (
- assetDecimals: number,
- tokenSymbol: string,
permission: NativeTokenStreamPermission | Erc20TokenStreamPermission,
): PermissionDetails => {
+ const { symbol, decimals } = tokenMetadata;
return {
amountLabel: {
translationKey: 'gatorPermissionsStreamingAmountLabel',
value: `${getDecimalizedHexValue(
permission.data.amountPerSecond,
- assetDecimals,
- )} ${tokenSymbol}`,
+ decimals,
+ )} ${symbol}`,
},
frequencyLabel: {
translationKey: 'gatorPermissionTokenStreamFrequencyLabel',
@@ -137,45 +194,43 @@ export const ReviewGatorPermissionItem = ({
expandedDetails: {
gatorPermissionsInitialAllowance: `${getDecimalizedHexValue(
permission.data.initialAmount || '0x0',
- assetDecimals,
- )} ${tokenSymbol}`,
+ decimals,
+ )} ${symbol}`,
gatorPermissionsMaxAllowance: `${getDecimalizedHexValue(
permission.data.maxAmount || '0x0',
- assetDecimals,
- )} ${tokenSymbol}`,
+ decimals,
+ )} ${symbol}`,
gatorPermissionsStartDate: convertTimestampToReadableDate(
permission.data.startTime as number,
),
- gatorPermissionsExpirationDate: extractExpiryToReadableDate(
- (permission as any).rules || [],
- ), // TODO: Need to expose rules on StoredGatorPermissionSanitized in the gator-permissions-controller
+ gatorPermissionsExpirationDate: getExpirationDate(
+ (permission as unknown as { rules: GatorPermissionRule[] }).rules,
+ ),
gatorPermissionsStreamRate: `${getDecimalizedHexValue(
permission.data.amountPerSecond,
- assetDecimals,
- )} ${tokenSymbol}/sec`,
+ decimals,
+ )} ${symbol}/sec`,
},
};
};
/**
* Returns the token periodic permission details
- * @param assetDecimals - The number of decimal places the token uses
- * @param tokenSymbol - The symbol of the token
+ *
* @param permission - The periodic permission data
* @returns The permission details
*/
const getTokenPeriodicPermissionDetails = (
- assetDecimals: number,
- tokenSymbol: string,
permission: NativeTokenPeriodicPermission | Erc20TokenPeriodicPermission,
): PermissionDetails => {
+ const { symbol, decimals } = tokenMetadata;
return {
amountLabel: {
translationKey: 'amount',
value: `${getDecimalizedHexValue(
permission.data.periodAmount,
- assetDecimals,
- )} ${tokenSymbol}`,
+ decimals,
+ )} ${symbol}`,
},
frequencyLabel: {
translationKey: 'gatorPermissionTokenPeriodicFrequencyLabel',
@@ -187,129 +242,30 @@ export const ReviewGatorPermissionItem = ({
gatorPermissionsStartDate: convertTimestampToReadableDate(
permission.data.startTime ?? 0,
),
- gatorPermissionsExpirationDate: extractExpiryToReadableDate(
- (permissionResponse.permission as any).rules || [],
- ), // TODO: Need to expose rules on StoredGatorPermissionSanitized in the gator-permissions-controller
+ gatorPermissionsExpirationDate: getExpirationDate(
+ (permission as unknown as { rules: GatorPermissionRule[] }).rules,
+ ),
},
};
};
/**
* Returns the permission details
+ *
* @returns The permission details
*/
const permissionDetails = useMemo((): PermissionDetails => {
- const { symbol, decimals } = gatorTokenInfo || {};
switch (permissionType) {
case 'native-token-stream':
case 'erc20-token-stream':
- return getTokenStreamPermissionDetails(
- decimals || 0,
- symbol || 'Unknown Token',
- permissionResponse.permission,
- );
+ return getTokenStreamPermissionDetails(permissionResponse.permission);
case 'native-token-periodic':
case 'erc20-token-periodic':
- return getTokenPeriodicPermissionDetails(
- decimals || 0,
- symbol || 'Unknown Token',
- permissionResponse.permission,
- );
+ return getTokenPeriodicPermissionDetails(permissionResponse.permission);
default:
throw new Error(`Invalid permission type: ${permissionType}`);
}
- }, [permissionType, permissionResponse]);
-
- /**
- * Renders the expanded permission details
- * @param expandedPermissionSecondaryDetails - The expanded permission secondary details
- * @returns The expanded permission details
- */
- const renderExpandedPermissionDetails = (
- expandedPermissionSecondaryDetails: PermissionExpandedDetails,
- ) => {
- return (
- <>
- {/* Network name row */}
-
-
- {t('networks')}
-
-
-
-
- {networkName}
-
-
-
-
- {/* Expanded permission secondary details */}
- {Object.entries(expandedPermissionSecondaryDetails).map(
- ([key, value]) => {
- return (
-
-
- {t(key)}
-
-
- {value}
-
-
- );
- },
- )}
- >
- );
- };
-
- if (gatorTokenInfoLoading || !gatorTokenInfo) {
- return (
-
-
-
- );
- }
+ }, [permissionType, permissionResponse, tokenMetadata]);
return (
+ {/* Permission details */}
{/* Amount Row */}
- {/* Expand/Collapse view */}
+ {/* Expanded permission details */}
- {isExpanded &&
- renderExpandedPermissionDetails(permissionDetails.expandedDetails)}
+
+ {isExpanded && (
+ <>
+ {/* Network name row */}
+
+
+ {t('networks')}
+
+
+
+
+ {networkName}
+
+
+
+
+ {Object.entries(permissionDetails.expandedDetails).map(
+ ([key, value]) => {
+ return (
+
+
+ {t(key)}
+
+
+ {value}
+
+
+ );
+ },
+ )}
+ >
+ )}
);
diff --git a/ui/hooks/gator-permissions/useGatorTokenInfo.ts b/ui/hooks/gator-permissions/useGatorTokenInfo.ts
deleted file mode 100644
index f4a808e690ba..000000000000
--- a/ui/hooks/gator-permissions/useGatorTokenInfo.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import { useEffect, useState } from 'react';
-import {
- getGatorPermissionTokenInfo,
- GatorTokenInfo,
-} from '../../../shared/lib/gator-permissions';
-
-export function useGatorTokenInfo(
- permissionType: string,
- chainId: string,
- tokenAddress: string,
-) {
- const [loading, setLoading] = useState(true);
- const [data, setData] = useState(undefined);
- const [error, setError] = useState(undefined);
-
- useEffect(() => {
- let cancelled = false;
- async function fetchGatorTokenInfo() {
- try {
- setError(undefined);
- setLoading(true);
-
- const newData = await getGatorPermissionTokenInfo({
- permissionType: permissionType,
- chainId: chainId,
- tokenAddress: tokenAddress,
- allowExternalServices: true,
- });
- if (!cancelled) {
- setData(newData);
- }
- } catch (err) {
- if (!cancelled) {
- setError(err as Error);
- }
- } finally {
- if (!cancelled) {
- setLoading(false);
- }
- }
- }
-
- fetchGatorTokenInfo();
-
- return () => {
- cancelled = true;
- };
- }, []);
-
- return { data, error, loading };
-}
From a2e9579c6c258c1ed14e75b7b379935cc1a674bc Mon Sep 17 00:00:00 2001
From: Idris Bowman <34751375+V00D00-child@users.noreply.github.com>
Date: Fri, 17 Oct 2025 15:09:44 -0400
Subject: [PATCH 4/7] Add test coverage and handle rendering permission when no
token decimal is found
---
app/_locales/en/messages.json | 4 +
app/_locales/en_GB/messages.json | 4 +
shared/constants/time.ts | 40 +
.../lib/gator-permissions/time-utils.test.ts | 195 ++++
shared/lib/gator-permissions/time-utils.ts | 66 +-
...review-gator-permission-item.test.tsx.snap | 891 ++++++++++++++++++
.../review-gator-permission-item.test.tsx | 436 +++++++++
.../review-gator-permission-item.tsx | 282 ++++--
8 files changed, 1819 insertions(+), 99 deletions(-)
create mode 100644 shared/lib/gator-permissions/time-utils.test.ts
create mode 100644 ui/components/multichain/pages/gator-permissions/components/__snapshots__/review-gator-permission-item.test.tsx.snap
create mode 100644 ui/components/multichain/pages/gator-permissions/components/review-gator-permission-item.test.tsx
diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json
index 1fcd48ffc007..34a09bbfa069 100644
--- a/app/_locales/en/messages.json
+++ b/app/_locales/en/messages.json
@@ -2715,6 +2715,10 @@
"message": "Period",
"description": "Label for the period of a token stream permission"
},
+ "gatorPermissionUnknownTokenAmount": {
+ "message": "Unknown amount",
+ "description": "Text for an unknown amount with no decimals"
+ },
"gatorPermissionWeeklyFrequency": {
"message": "Weekly",
"description": "Time period for weekly recurring permissions redemption"
diff --git a/app/_locales/en_GB/messages.json b/app/_locales/en_GB/messages.json
index 1fcd48ffc007..34a09bbfa069 100644
--- a/app/_locales/en_GB/messages.json
+++ b/app/_locales/en_GB/messages.json
@@ -2715,6 +2715,10 @@
"message": "Period",
"description": "Label for the period of a token stream permission"
},
+ "gatorPermissionUnknownTokenAmount": {
+ "message": "Unknown amount",
+ "description": "Text for an unknown amount with no decimals"
+ },
"gatorPermissionWeeklyFrequency": {
"message": "Weekly",
"description": "Time period for weekly recurring permissions redemption"
diff --git a/shared/constants/time.ts b/shared/constants/time.ts
index 5c4b5d7472cd..47e46ad4266d 100644
--- a/shared/constants/time.ts
+++ b/shared/constants/time.ts
@@ -1,9 +1,49 @@
export const MILLISECOND = 1;
+
+/**
+ * Number of milliseconds in a second.
+ * 1,000(milliseconds)
+ */
export const SECOND = MILLISECOND * 1000;
+
+/**
+ * Number of milliseconds in a minute.
+ * 60,000(milliseconds)
+ */
export const MINUTE = SECOND * 60;
+
+/**
+ * Number of milliseconds in an hour.
+ * 3,600,000(milliseconds)
+ */
export const HOUR = MINUTE * 60;
+
+/**
+ * Number of milliseconds in a day.
+ * 86,400,000(milliseconds)
+ */
export const DAY = HOUR * 24;
+
+/**
+ * Number of milliseconds in a week.
+ * 604,800,000(milliseconds)
+ */
export const WEEK = DAY * 7;
+
+/**
+ * Number of milliseconds in a fortnight.
+ * 1,209,600,000(milliseconds)
+ */
export const FORTNIGHT = DAY * 14;
+
+/**
+ * Number of milliseconds in a month.
+ * 2,629,760,000(milliseconds)
+ */
export const MONTH = DAY * 30;
+
+/**
+ * Number of milliseconds in a year.
+ * 31,536,000,000(milliseconds)
+ */
export const YEAR = DAY * 365;
diff --git a/shared/lib/gator-permissions/time-utils.test.ts b/shared/lib/gator-permissions/time-utils.test.ts
new file mode 100644
index 000000000000..23447752905a
--- /dev/null
+++ b/shared/lib/gator-permissions/time-utils.test.ts
@@ -0,0 +1,195 @@
+import { bigIntToHex } from '@metamask/utils';
+import {
+ DAY,
+ FORTNIGHT,
+ MONTH,
+ SECOND,
+ WEEK,
+ YEAR,
+} from '../../constants/time';
+import {
+ convertAmountPerSecondToAmountPerPeriod,
+ convertMillisecondsToSeconds,
+ convertTimestampToReadableDate,
+ extractExpiryToReadableDate,
+ getPeriodFrequencyValueTranslationKey,
+ GatorPermissionRule,
+} from './time-utils';
+
+describe('time-utils', () => {
+ describe('getPeriodFrequencyValueTranslationKey', () => {
+ it('returns daily frequency for 1 day period', () => {
+ const result = getPeriodFrequencyValueTranslationKey(DAY / SECOND);
+ expect(result).toBe('gatorPermissionDailyFrequency');
+ });
+
+ it('returns weekly frequency for 1 week period', () => {
+ const result = getPeriodFrequencyValueTranslationKey(WEEK / SECOND);
+ expect(result).toBe('gatorPermissionWeeklyFrequency');
+ });
+
+ it('returns fortnightly frequency for 2 weeks period', () => {
+ const result = getPeriodFrequencyValueTranslationKey(FORTNIGHT / SECOND);
+ expect(result).toBe('gatorPermissionFortnightlyFrequency');
+ });
+
+ it('returns monthly frequency for 1 month period', () => {
+ const result = getPeriodFrequencyValueTranslationKey(MONTH / SECOND);
+ expect(result).toBe('gatorPermissionMonthlyFrequency');
+ });
+
+ it('returns annual frequency for 1 year period', () => {
+ const result = getPeriodFrequencyValueTranslationKey(YEAR / SECOND);
+ expect(result).toBe('gatorPermissionAnnualFrequency');
+ });
+
+ it('returns custom frequency for arbitrary period', () => {
+ const result = getPeriodFrequencyValueTranslationKey(123456);
+ expect(result).toBe('gatorPermissionCustomFrequency');
+ });
+
+ it('returns custom frequency for zero period', () => {
+ const result = getPeriodFrequencyValueTranslationKey(0);
+ expect(result).toBe('gatorPermissionCustomFrequency');
+ });
+ });
+
+ describe('convertMillisecondsToSeconds', () => {
+ it('converts milliseconds to seconds correctly', () => {
+ expect(convertMillisecondsToSeconds(1000)).toBe(1);
+ expect(convertMillisecondsToSeconds(5000)).toBe(5);
+ expect(convertMillisecondsToSeconds(60000)).toBe(60);
+ });
+
+ it('converts 0 milliseconds to 0 seconds', () => {
+ expect(convertMillisecondsToSeconds(0)).toBe(0);
+ });
+
+ it('handles fractional seconds', () => {
+ expect(convertMillisecondsToSeconds(1500)).toBe(1.5);
+ expect(convertMillisecondsToSeconds(250)).toBe(0.25);
+ });
+
+ it('converts WEEK constant correctly', () => {
+ // WEEK is in milliseconds, should be 604800 seconds
+ expect(convertMillisecondsToSeconds(WEEK)).toBe(604800);
+ });
+ });
+
+ describe('convertAmountPerSecondToAmountPerPeriod', () => {
+ it('converts amount per second to weekly amount', () => {
+ // 0x1 = 1 per second
+ // 1 * 604,800 seconds/week = 604,800
+ const result = convertAmountPerSecondToAmountPerPeriod('0x1', 'weekly');
+ expect(result).toBe(bigIntToHex(BigInt(604800)));
+ });
+
+ it('converts amount per second to monthly amount', () => {
+ // 0x1 = 1 per second
+ const result = convertAmountPerSecondToAmountPerPeriod('0x1', 'monthly');
+ const expectedSeconds = MONTH / SECOND;
+ expect(result).toBe(bigIntToHex(BigInt(expectedSeconds)));
+ });
+
+ it('converts amount per second to fortnightly amount', () => {
+ // 0x1 = 1 per second
+ const result = convertAmountPerSecondToAmountPerPeriod(
+ '0x1',
+ 'fortnightly',
+ );
+ const expectedSeconds = FORTNIGHT / SECOND;
+ expect(result).toBe(bigIntToHex(BigInt(expectedSeconds)));
+ });
+
+ it('converts amount per second to yearly amount', () => {
+ // 0x1 = 1 per second
+ const result = convertAmountPerSecondToAmountPerPeriod('0x1', 'yearly');
+ const expectedSeconds = YEAR / SECOND;
+ expect(result).toBe(bigIntToHex(BigInt(expectedSeconds)));
+ });
+
+ it('converts larger amounts correctly', () => {
+ // 0x6f05b59d3b20000 = 500000000000000000 (0.5 ETH in wei)
+ // 0.5 ETH/sec * 604,800 seconds/week = 302,400 ETH/week
+ const result = convertAmountPerSecondToAmountPerPeriod(
+ '0x6f05b59d3b20000',
+ 'weekly',
+ );
+ const expected = BigInt('500000000000000000') * BigInt(604800);
+ expect(result).toBe(bigIntToHex(expected));
+ });
+
+ it('converts zero amount correctly', () => {
+ const result = convertAmountPerSecondToAmountPerPeriod('0x0', 'weekly');
+ expect(result).toBe('0x0');
+ });
+
+ it('throws error for invalid period', () => {
+ expect(() =>
+ convertAmountPerSecondToAmountPerPeriod('0x1', 'invalid' as 'weekly'),
+ ).toThrow('Invalid period: invalid');
+ });
+ });
+
+ describe('convertTimestampToReadableDate', () => {
+ it('converts timestamp to mm/dd/yyyy format', () => {
+ // 1747699200 = Mon May 19 2025 16:00:00 GMT+0000 (UTC)
+ const result = convertTimestampToReadableDate(1747699200);
+ expect(result).toMatch(/^05\/(19|20)\/2025$/u);
+ });
+
+ it('returns empty string for timestamp 0', () => {
+ const result = convertTimestampToReadableDate(0);
+ expect(result).toBe('');
+ });
+
+ it('converts another timestamp correctly', () => {
+ const result = convertTimestampToReadableDate(1735689600);
+ expect(result).toMatch(/^\d{2}\/\d{2}\/202(4|5)$/u);
+ });
+
+ it('pads single digit months and days with zeros', () => {
+ // March 5, 2025 = 1741132800
+ const result = convertTimestampToReadableDate(1741132800);
+ expect(result).toMatch(/^\d{2}\/\d{2}\/\d{4}$/u);
+ });
+ });
+
+ describe('extractExpiryToReadableDate', () => {
+ it('extracts and converts expiry timestamp from rules', () => {
+ const rules: GatorPermissionRule[] = [
+ {
+ type: 'expiry',
+ isAdjustmentAllowed: false,
+ data: {
+ timestamp: 1747699200,
+ },
+ },
+ ];
+
+ const result = extractExpiryToReadableDate(rules);
+ expect(result).toMatch(/^05\/(19|20)\/2025$/u);
+ });
+
+ it('returns empty string when no expiry rule exists', () => {
+ const rules: GatorPermissionRule[] = [
+ {
+ type: 'other-rule',
+ isAdjustmentAllowed: false,
+ data: {
+ someData: 'value',
+ },
+ },
+ ];
+
+ const result = extractExpiryToReadableDate(rules);
+ expect(result).toBe('');
+ });
+
+ it('returns empty string for empty rules array', () => {
+ const rules: GatorPermissionRule[] = [];
+ const result = extractExpiryToReadableDate(rules);
+ expect(result).toBe('');
+ });
+ });
+});
diff --git a/shared/lib/gator-permissions/time-utils.ts b/shared/lib/gator-permissions/time-utils.ts
index ac3a46fda0ee..21d7b7f72eb7 100644
--- a/shared/lib/gator-permissions/time-utils.ts
+++ b/shared/lib/gator-permissions/time-utils.ts
@@ -1,4 +1,12 @@
-import { DAY, FORTNIGHT, MONTH, WEEK, YEAR } from '../../constants/time';
+import { bigIntToHex, Hex, hexToBigInt } from '@metamask/utils';
+import {
+ DAY,
+ FORTNIGHT,
+ MONTH,
+ SECOND,
+ WEEK,
+ YEAR,
+} from '../../constants/time';
export type GatorPermissionRule = {
type: string;
@@ -15,21 +23,65 @@ export type GatorPermissionRule = {
export function getPeriodFrequencyValueTranslationKey(
periodDurationInSeconds: number,
): string {
- const periodDurationMs = periodDurationInSeconds * 1000;
- if (periodDurationMs === DAY) {
+ const periodDurationMillisecond = periodDurationInSeconds * SECOND;
+ if (periodDurationMillisecond === DAY) {
return 'gatorPermissionDailyFrequency';
- } else if (periodDurationMs === WEEK) {
+ } else if (periodDurationMillisecond === WEEK) {
return 'gatorPermissionWeeklyFrequency';
- } else if (periodDurationMs === FORTNIGHT) {
+ } else if (periodDurationMillisecond === FORTNIGHT) {
return 'gatorPermissionFortnightlyFrequency';
- } else if (periodDurationMs === MONTH) {
+ } else if (periodDurationMillisecond === MONTH) {
return 'gatorPermissionMonthlyFrequency';
- } else if (periodDurationMs === YEAR) {
+ } else if (periodDurationMillisecond === YEAR) {
return 'gatorPermissionAnnualFrequency';
}
return 'gatorPermissionCustomFrequency';
}
+/**
+ * Converts milliseconds to seconds.
+ *
+ * @param milliseconds - The milliseconds to convert.
+ * @returns The seconds.
+ */
+export function convertMillisecondsToSeconds(milliseconds: number): number {
+ return milliseconds / SECOND;
+}
+
+/**
+ * Converts an amount per second to an amount per period.
+ *
+ * @param amountPerSecond - The amount per second in hexadecimal format.
+ * @param period - The period to convert to.
+ * @returns The amount per period.
+ */
+export function convertAmountPerSecondToAmountPerPeriod(
+ amountPerSecond: Hex,
+ period: 'weekly' | 'monthly' | 'fortnightly' | 'yearly',
+): Hex {
+ const amountBigInt = hexToBigInt(amountPerSecond);
+ switch (period) {
+ case 'weekly':
+ return bigIntToHex(
+ amountBigInt * BigInt(convertMillisecondsToSeconds(WEEK)),
+ );
+ case 'monthly':
+ return bigIntToHex(
+ amountBigInt * BigInt(convertMillisecondsToSeconds(MONTH)),
+ );
+ case 'fortnightly':
+ return bigIntToHex(
+ amountBigInt * BigInt(convertMillisecondsToSeconds(FORTNIGHT)),
+ );
+ case 'yearly':
+ return bigIntToHex(
+ amountBigInt * BigInt(convertMillisecondsToSeconds(YEAR)),
+ );
+ default:
+ throw new Error(`Invalid period: ${period as string}`);
+ }
+}
+
/**
* Converts a unix timestamp(in seconds) to a human-readable date format.
*
diff --git a/ui/components/multichain/pages/gator-permissions/components/__snapshots__/review-gator-permission-item.test.tsx.snap b/ui/components/multichain/pages/gator-permissions/components/__snapshots__/review-gator-permission-item.test.tsx.snap
new file mode 100644
index 000000000000..d65459d39182
--- /dev/null
+++ b/ui/components/multichain/pages/gator-permissions/components/__snapshots__/review-gator-permission-item.test.tsx.snap
@@ -0,0 +1,891 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Permission List Item render ERC20 token permissions renders erc20 token periodic permission correctly 1`] = `
+
+
+
+
+
+
+
+ Transfer Window
+
+
+
+
+
+ Account
+
+
+
+
+ 0x4f71D...E7a63
+
+
+
+
+
+
+
+
+ Show Details
+
+
+
+
+
+
+
+`;
+
+exports[`Permission List Item render ERC20 token permissions renders erc20 token permission with unknown token amount correctly 1`] = `
+
+
+
+
+
+
+ Streaming amount
+
+
+
+
+
+
+ Account
+
+
+
+
+ 0x4f71D...E7a63
+
+
+
+
+
+
+
+
+ Show Details
+
+
+
+
+
+
+
+`;
+
+exports[`Permission List Item render ERC20 token permissions renders erc20 token stream permission correctly 1`] = `
+
+
+
+
+
+
+ Streaming amount
+
+
+
+
+
+
+ Account
+
+
+
+
+ 0x4f71D...E7a63
+
+
+
+
+
+
+
+
+ Show Details
+
+
+
+
+
+
+
+`;
+
+exports[`Permission List Item render NATIVE token permissions renders native token periodic permission correctly 1`] = `
+
+
+
+
+
+
+
+ Transfer Window
+
+
+
+
+
+ Account
+
+
+
+
+ 0x4f71D...E7a63
+
+
+
+
+
+
+
+
+ Show Details
+
+
+
+
+
+
+
+`;
+
+exports[`Permission List Item render NATIVE token permissions renders native token stream permission correctly 1`] = `
+
+
+
+
+
+
+ Streaming amount
+
+
+
+
+
+
+ Account
+
+
+
+
+ 0x4f71D...E7a63
+
+
+
+
+
+
+
+
+ Show Details
+
+
+
+
+
+
+
+`;
diff --git a/ui/components/multichain/pages/gator-permissions/components/review-gator-permission-item.test.tsx b/ui/components/multichain/pages/gator-permissions/components/review-gator-permission-item.test.tsx
new file mode 100644
index 000000000000..cabcb5865c80
--- /dev/null
+++ b/ui/components/multichain/pages/gator-permissions/components/review-gator-permission-item.test.tsx
@@ -0,0 +1,436 @@
+import React from 'react';
+import { Hex } from '@metamask/utils';
+import {
+ StoredGatorPermissionSanitized,
+ Signer,
+ NativeTokenStreamPermission,
+ Erc20TokenStreamPermission,
+ NativeTokenPeriodicPermission,
+ Erc20TokenPeriodicPermission,
+} from '@metamask/gator-permissions-controller';
+import { fireEvent } from '@testing-library/react';
+import { renderWithProvider } from '../../../../../../test/lib/render-helpers';
+import configureStore from '../../../../../store/store';
+import mockState from '../../../../../../test/data/mock-state.json';
+import { ReviewGatorPermissionItem } from './review-gator-permission-item';
+
+const store = configureStore({
+ ...mockState,
+ metamask: {
+ ...mockState.metamask,
+ },
+});
+
+describe('Permission List Item', () => {
+ describe('render', () => {
+ const mockOnClick = jest.fn();
+ const mockNetworkName = 'Ethereum';
+ const mockSelectedAccountAddress =
+ '0x4f71DA06987BfeDE90aF0b33E1e3e4ffDCEE7a63';
+
+ describe('NATIVE token permissions', () => {
+ const mockNativeTokenStreamPermission: StoredGatorPermissionSanitized<
+ Signer,
+ NativeTokenStreamPermission
+ > = {
+ permissionResponse: {
+ chainId: '0x1',
+ address: mockSelectedAccountAddress,
+ permission: {
+ type: 'native-token-stream',
+ isAdjustmentAllowed: false,
+ data: {
+ maxAmount: '0x22b1c8c1227a0000', // 2.5 ETH (18 decimals)
+ initialAmount: '0x6f05b59d3b20000', // 0.5 ETH (18 decimals)
+ amountPerSecond: '0x6f05b59d3b20000', // 0.5 ETH/sec (18 decimals)
+ startTime: 1747699200,
+ justification:
+ 'This is a very important request for streaming allowance for some very important thing',
+ },
+ },
+ context: '0x00000000',
+ signerMeta: {
+ delegationManager: '0xdb9B1e94B5b69Df7e401DDbedE43491141047dB3',
+ },
+ },
+ siteOrigin: 'http://localhost:8000',
+ };
+
+ const mockNativeTokenPeriodicPermission: StoredGatorPermissionSanitized<
+ Signer,
+ NativeTokenPeriodicPermission
+ > = {
+ permissionResponse: {
+ chainId: '0x1',
+ address: mockSelectedAccountAddress,
+ permission: {
+ type: 'native-token-periodic',
+ isAdjustmentAllowed: false,
+ data: {
+ periodAmount: '0x6f05b59d3b20000', // 0.5 ETH per week (18 decimals)
+ periodDuration: 604800, // 1 week in seconds
+ startTime: 1747699200,
+ justification:
+ 'This is a very important request for periodic allowance',
+ },
+ },
+ context: '0x00000000',
+ signerMeta: {
+ delegationManager: '0xdb9B1e94B5b69Df7e401DDbedE43491141047dB3',
+ },
+ },
+ siteOrigin: 'http://localhost:8000',
+ };
+
+ it('renders native token stream permission correctly', () => {
+ const { container, getByTestId } = renderWithProvider(
+ mockOnClick()}
+ />,
+ store,
+ );
+ expect(container).toMatchSnapshot();
+
+ expect(getByTestId('review-gator-permission-item')).toBeInTheDocument();
+
+ // Verify the streaming amount per week
+ // 0x6f05b59d3b20000 = 0.5 ETH per second
+ // 0.5 ETH/sec * 604,800 seconds/week = 302,400 ETH/week
+ const amountLabel = getByTestId('review-gator-permission-amount-label');
+ expect(amountLabel).toHaveTextContent('302400 ETH');
+
+ // Verify frequency label
+ const frequencyLabel = getByTestId(
+ 'review-gator-permission-frequency-label',
+ );
+ expect(frequencyLabel).toBeInTheDocument();
+
+ // Expand to see more details
+ const expandButton = container.querySelector('[aria-label="expand"]');
+ if (expandButton) {
+ fireEvent.click(expandButton);
+ }
+
+ // Verify initial allowance: 0x6f05b59d3b20000 = 0.5 ETH
+ const initialAllowance = getByTestId(
+ 'review-gator-permission-initial-allowance',
+ );
+ expect(initialAllowance).toHaveTextContent('0.5 ETH');
+
+ // Verify max allowance: 0x22b1c8c1227a0000 = 2.5 ETH
+ const maxAllowance = getByTestId(
+ 'review-gator-permission-max-allowance',
+ );
+ expect(maxAllowance).toHaveTextContent('2.5 ETH');
+
+ // Verify stream rate: 0x6f05b59d3b20000 = 0.5 ETH/sec
+ const streamRate = getByTestId('review-gator-permission-stream-rate');
+ expect(streamRate).toHaveTextContent('0.5 ETH/sec');
+
+ // Verify start date is rendered
+ const startDate = getByTestId('review-gator-permission-start-date');
+ expect(startDate).toBeInTheDocument();
+ expect(startDate).toHaveTextContent('05/19/2025');
+
+ // Verify expiration date is rendered
+ const expirationDate = getByTestId(
+ 'review-gator-permission-expiration-date',
+ );
+ expect(expirationDate).toBeInTheDocument();
+
+ // Verify network name is rendered
+ const networkName = getByTestId('review-gator-permission-network-name');
+ expect(networkName).toHaveTextContent(mockNetworkName);
+ });
+
+ it('renders native token periodic permission correctly', () => {
+ const { container, getByTestId } = renderWithProvider(
+ mockOnClick()}
+ />,
+ store,
+ );
+ expect(container).toMatchSnapshot();
+
+ expect(getByTestId('review-gator-permission-item')).toBeInTheDocument();
+
+ // Verify the periodic amount
+ // 0x6f05b59d3b20000 = 0.5 ETH per period (weekly)
+ const amountLabel = getByTestId('review-gator-permission-amount-label');
+ expect(amountLabel).toHaveTextContent('0.5 ETH');
+
+ // Verify frequency label shows weekly
+ const frequencyLabel = getByTestId(
+ 'review-gator-permission-frequency-label',
+ );
+ expect(frequencyLabel).toBeInTheDocument();
+ expect(frequencyLabel).toHaveTextContent('Weekly');
+
+ // Expand to see more details
+ const expandButton = container.querySelector('[aria-label="expand"]');
+ if (expandButton) {
+ fireEvent.click(expandButton);
+ }
+
+ // Verify start date is rendered
+ const startDate = getByTestId('review-gator-permission-start-date');
+ expect(startDate).toBeInTheDocument();
+ expect(startDate).toHaveTextContent('05/19/2025');
+
+ // Verify expiration date is rendered
+ const expirationDate = getByTestId(
+ 'review-gator-permission-expiration-date',
+ );
+ expect(expirationDate).toBeInTheDocument();
+
+ // Verify network name is rendered
+ const networkName = getByTestId('review-gator-permission-network-name');
+ expect(networkName).toHaveTextContent(mockNetworkName);
+ });
+ });
+
+ describe('ERC20 token permissions', () => {
+ /**
+ * WBTC, 8 decimal on chain 0x5
+ */
+ const mockTokenAddress: Hex =
+ '0x2260fac5e5542a773aa44fbcfedf7c193bc2c599';
+
+ const mockErc20TokenPeriodicPermission: StoredGatorPermissionSanitized<
+ Signer,
+ Erc20TokenPeriodicPermission
+ > = {
+ permissionResponse: {
+ chainId: '0x5',
+ address: mockSelectedAccountAddress,
+ permission: {
+ type: 'erc20-token-periodic',
+ isAdjustmentAllowed: false,
+ data: {
+ tokenAddress: mockTokenAddress, // WBTC with 8 decimals
+ periodAmount: '0x2faf080', // 0.5 WBTC per week (8 decimals)
+ periodDuration: 604800, // 1 week in seconds
+ startTime: 1747699200,
+ justification:
+ 'This is a very important request for ERC20 periodic allowance',
+ },
+ },
+ context: '0x00000000',
+ signerMeta: {
+ delegationManager: '0xdb9B1e94B5b69Df7e401DDbedE43491141047dB3',
+ },
+ },
+ siteOrigin: 'http://localhost:8000',
+ };
+
+ const mockErc20TokenStreamPermission: StoredGatorPermissionSanitized<
+ Signer,
+ Erc20TokenStreamPermission
+ > = {
+ permissionResponse: {
+ chainId: '0x5',
+ address: mockSelectedAccountAddress,
+ permission: {
+ type: 'erc20-token-stream',
+ isAdjustmentAllowed: false,
+ data: {
+ tokenAddress: mockTokenAddress, // WBTC with 8 decimals
+ maxAmount: '0xee6b280', // 2.5 WBTC (8 decimals)
+ initialAmount: '0x2faf080', // 0.5 WBTC (8 decimals)
+ amountPerSecond: '0x2faf080', // 0.5 WBTC/sec (8 decimals)
+ startTime: 1747699200,
+ justification:
+ 'This is a very important request for ERC20 streaming allowance',
+ },
+ },
+ context: '0x00000000',
+ signerMeta: {
+ delegationManager: '0xdb9B1e94B5b69Df7e401DDbedE43491141047dB3',
+ },
+ },
+ siteOrigin: 'http://localhost:8000',
+ };
+
+ it('renders erc20 token stream permission correctly', () => {
+ const { container, getByTestId } = renderWithProvider(
+ mockOnClick()}
+ />,
+ store,
+ );
+ expect(container).toMatchSnapshot();
+
+ expect(getByTestId('review-gator-permission-item')).toBeInTheDocument();
+
+ // Verify the streaming amount per week for ERC20 token (WBTC with 8 decimals)
+ // 0x2faf080 = 50,000,000 = 0.5 WBTC per second (8 decimals)
+ // 0.5 WBTC/sec * 604,800 seconds/week = 302,400 WBTC/week
+ const amountLabel = getByTestId('review-gator-permission-amount-label');
+ expect(amountLabel).toHaveTextContent('302400');
+
+ // Verify frequency label
+ const frequencyLabel = getByTestId(
+ 'review-gator-permission-frequency-label',
+ );
+ expect(frequencyLabel).toBeInTheDocument();
+
+ // Expand to see more details
+ const expandButton = container.querySelector('[aria-label="expand"]');
+ if (expandButton) {
+ fireEvent.click(expandButton);
+ }
+
+ // Verify initial allowance: 0x2faf080 = 0.5 WBTC (8 decimals)
+ const initialAllowance = getByTestId(
+ 'review-gator-permission-initial-allowance',
+ );
+ expect(initialAllowance).toHaveTextContent('0.5 WBTC');
+
+ // Verify max allowance: 0xee6b280 = 2.5 WBTC (8 decimals)
+ const maxAllowance = getByTestId(
+ 'review-gator-permission-max-allowance',
+ );
+ expect(maxAllowance).toHaveTextContent('2.5 WBTC');
+
+ // Verify stream rate: 0x2faf080 = 0.5 WBTC/sec (8 decimals)
+ const streamRate = getByTestId('review-gator-permission-stream-rate');
+ expect(streamRate).toHaveTextContent('0.5 WBTC/sec');
+
+ // Verify start date is rendered
+ const startDate = getByTestId('review-gator-permission-start-date');
+ expect(startDate).toBeInTheDocument();
+ expect(startDate).toHaveTextContent('05/19/2025');
+
+ // Verify expiration date is rendered
+ const expirationDate = getByTestId(
+ 'review-gator-permission-expiration-date',
+ );
+ expect(expirationDate).toBeInTheDocument();
+
+ // Verify network name is rendered
+ const networkName = getByTestId('review-gator-permission-network-name');
+ expect(networkName).toHaveTextContent(mockNetworkName);
+ });
+
+ it('renders erc20 token periodic permission correctly', () => {
+ const { container, getByTestId } = renderWithProvider(
+ mockOnClick()}
+ />,
+ store,
+ );
+ expect(container).toMatchSnapshot();
+
+ expect(getByTestId('review-gator-permission-item')).toBeInTheDocument();
+
+ // Verify the periodic amount for ERC20 token (WBTC with 8 decimals)
+ // 0x2faf080 = 50,000,000 = 0.5 WBTC per week (8 decimals)
+ const amountLabel = getByTestId('review-gator-permission-amount-label');
+ expect(amountLabel).toHaveTextContent('0.5');
+
+ // Verify frequency label shows weekly
+ const frequencyLabel = getByTestId(
+ 'review-gator-permission-frequency-label',
+ );
+ expect(frequencyLabel).toBeInTheDocument();
+ expect(frequencyLabel).toHaveTextContent('Weekly');
+
+ // Expand to see more details
+ const expandButton = container.querySelector('[aria-label="expand"]');
+ if (expandButton) {
+ fireEvent.click(expandButton);
+ }
+
+ // Verify start date is rendered
+ const startDate = getByTestId('review-gator-permission-start-date');
+ expect(startDate).toBeInTheDocument();
+ expect(startDate).toHaveTextContent('05/19/2025');
+
+ // Verify network name is rendered
+ const networkName = getByTestId('review-gator-permission-network-name');
+ expect(networkName).toHaveTextContent(mockNetworkName);
+ });
+
+ it('renders erc20 token permission with unknown token amount correctly', () => {
+ // Use a token address that won't be found in the mock state
+ // This simulates a scenario where token metadata is not available
+ const unknownTokenAddress: Hex =
+ '0x0000000000000000000000000000000000000001';
+
+ const mockUnknownTokenStreamPermission: StoredGatorPermissionSanitized<
+ Signer,
+ Erc20TokenStreamPermission
+ > = {
+ permissionResponse: {
+ chainId: '0x5',
+ address: mockSelectedAccountAddress,
+ permission: {
+ type: 'erc20-token-stream',
+ isAdjustmentAllowed: false,
+ data: {
+ tokenAddress: unknownTokenAddress, // Unknown token
+ maxAmount: '0xee6b280',
+ initialAmount: '0x2faf080',
+ amountPerSecond: '0x2faf080',
+ startTime: 1747699200,
+ justification: 'Test unknown token',
+ },
+ },
+ context: '0x00000000',
+ signerMeta: {
+ delegationManager: '0xdb9B1e94B5b69Df7e401DDbedE43491141047dB3',
+ },
+ },
+ siteOrigin: 'http://localhost:8000',
+ };
+
+ const { container, getByTestId } = renderWithProvider(
+ mockOnClick()}
+ />,
+ store,
+ );
+
+ expect(container).toMatchSnapshot();
+
+ expect(getByTestId('review-gator-permission-item')).toBeInTheDocument();
+
+ // Verify that when token metadata is not found, it shows unknown amount text
+ const amountLabel = getByTestId('review-gator-permission-amount-label');
+ expect(amountLabel.textContent).toContain('Unknown amount');
+
+ // Expand to see more details
+ const expandButton = container.querySelector('[aria-label="expand"]');
+ if (expandButton) {
+ fireEvent.click(expandButton);
+ }
+
+ // Verify initial allowance shows unknown amount
+ const initialAllowance = getByTestId(
+ 'review-gator-permission-initial-allowance',
+ );
+ expect(initialAllowance.textContent).toContain('Unknown amount');
+
+ // Verify max allowance shows unknown amount
+ const maxAllowance = getByTestId(
+ 'review-gator-permission-max-allowance',
+ );
+ expect(maxAllowance.textContent).toContain('Unknown amount');
+
+ // Verify stream rate shows unknown amount
+ const streamRate = getByTestId('review-gator-permission-stream-rate');
+ expect(streamRate.textContent).toContain('Unknown amount');
+ });
+ });
+ });
+});
diff --git a/ui/components/multichain/pages/gator-permissions/components/review-gator-permission-item.tsx b/ui/components/multichain/pages/gator-permissions/components/review-gator-permission-item.tsx
index ae1e7f55a592..2e9ac12a9699 100644
--- a/ui/components/multichain/pages/gator-permissions/components/review-gator-permission-item.tsx
+++ b/ui/components/multichain/pages/gator-permissions/components/review-gator-permission-item.tsx
@@ -1,4 +1,4 @@
-import React, { useMemo, useState } from 'react';
+import React, { useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import { Hex } from '@metamask/utils';
import {
@@ -36,6 +36,7 @@ import {
getPeriodFrequencyValueTranslationKey,
extractExpiryToReadableDate,
GatorPermissionRule,
+ convertAmountPerSecondToAmountPerPeriod,
} from '../../../../../../shared/lib/gator-permissions';
import { PreferredAvatar } from '../../../../app/preferred-avatar';
import { BackgroundColor } from '../../../../../helpers/constants/design-system';
@@ -48,7 +49,8 @@ import { getTokenMetadata } from '../../../../../helpers/utils/token-util';
type TokenMetadata = {
symbol: string;
- decimals: number;
+ decimals: number | null;
+ name: string;
};
type ReviewGatorPermissionItemProps = {
@@ -71,19 +73,25 @@ type ReviewGatorPermissionItemProps = {
onRevokeClick: () => void;
};
-/**
- * The expanded permission details key(translation key) -> value
- */
-type PermissionExpandedDetails = Record;
+type PermissionExpandedDetails = Record<
+ string,
+ {
+ translationKey: string;
+ value: string;
+ testId: string;
+ }
+>;
type PermissionDetails = {
amountLabel: {
translationKey: string;
value: string;
+ testId: string;
};
frequencyLabel: {
translationKey: string;
valueTranslationKey: string;
+ testId: string;
};
expandedDetails: PermissionExpandedDetails;
};
@@ -95,7 +103,7 @@ export const ReviewGatorPermissionItem = ({
}: ReviewGatorPermissionItemProps) => {
const t = useI18nContext();
const { permissionResponse, siteOrigin } = gatorPermission;
- const [chainId] = permissionResponse.chainId;
+ const { chainId } = permissionResponse;
const permissionType = permissionResponse.permission.type;
const permissionAccount = permissionResponse.address || '0x';
const tokenAddress = permissionResponse.permission.data.tokenAddress as
@@ -119,6 +127,7 @@ export const ReviewGatorPermissionItem = ({
return {
symbol: foundTokenMetadata.symbol || 'Unknown Token',
decimals: foundTokenMetadata.decimals || 18,
+ name: foundTokenMetadata.name || 'Unknown Token',
};
}
console.warn(
@@ -126,21 +135,23 @@ export const ReviewGatorPermissionItem = ({
);
return {
symbol: 'Unknown Token',
- decimals: 18,
+ decimals: null,
+ name: 'Unknown Token',
};
}
return {
symbol: nativeTokenMetadata.symbol,
decimals: nativeTokenMetadata.decimals,
+ name: nativeTokenMetadata.name,
};
}, [tokensByChain, chainId, tokenAddress, nativeTokenMetadata]);
/**
* Handles the click event for the expand/collapse button
*/
- const handleExpandClick = () => {
+ const handleExpandClick = useCallback(() => {
setIsExpanded(!isExpanded);
- };
+ }, [isExpanded]);
/**
* Converts a hex value to a decimal value
@@ -149,8 +160,11 @@ export const ReviewGatorPermissionItem = ({
* @param decimals - The number of decimals to shift the value by
* @returns The decimal value
*/
- const getDecimalizedHexValue = (value: Hex, decimals: number) =>
- new Numeric(value, 16).toBase(10).shiftedBy(decimals).toString();
+ const getDecimalizedHexValue = useCallback(
+ (value: Hex, decimals: number) =>
+ new Numeric(value, 16).toBase(10).shiftedBy(decimals).toString(),
+ [],
+ );
/**
* Returns the expiration date from the rules
@@ -158,16 +172,27 @@ export const ReviewGatorPermissionItem = ({
* @param rules - The rules to extract the expiration from
* @returns The expiration date
*/
- const getExpirationDate = (rules: GatorPermissionRule[]): string => {
- // TODO: Need to expose rules on StoredGatorPermissionSanitized in the gator-permissions-controller so we can have stronger typing
- if (!rules) {
- return t('gatorPermissionNoExpiration');
- }
- if (rules.length === 0) {
- return t('gatorPermissionNoExpiration');
- }
- return extractExpiryToReadableDate(rules);
- };
+ const getExpirationDate = useCallback(
+ (rules: GatorPermissionRule[]): string => {
+ if (!rules) {
+ return t('gatorPermissionNoExpiration');
+ }
+ if (rules.length === 0) {
+ return t('gatorPermissionNoExpiration');
+ }
+ return extractExpiryToReadableDate(rules);
+ },
+ [t],
+ );
+
+ /**
+ * Returns the unknown token amount text
+ *
+ * @returns The unknown token amount text
+ */
+ const getUnknownTokenAmountText = useCallback(() => {
+ return t('gatorPermissionUnknownTokenAmount');
+ }, [t]);
/**
* Returns the token stream permission details
@@ -175,44 +200,85 @@ export const ReviewGatorPermissionItem = ({
* @param permission - The stream permission data
* @returns The permission details
*/
- const getTokenStreamPermissionDetails = (
- permission: NativeTokenStreamPermission | Erc20TokenStreamPermission,
- ): PermissionDetails => {
- const { symbol, decimals } = tokenMetadata;
- return {
- amountLabel: {
- translationKey: 'gatorPermissionsStreamingAmountLabel',
- value: `${getDecimalizedHexValue(
- permission.data.amountPerSecond,
- decimals,
- )} ${symbol}`,
- },
- frequencyLabel: {
- translationKey: 'gatorPermissionTokenStreamFrequencyLabel',
- valueTranslationKey: 'gatorPermissionWeeklyFrequency',
- },
- expandedDetails: {
- gatorPermissionsInitialAllowance: `${getDecimalizedHexValue(
- permission.data.initialAmount || '0x0',
- decimals,
- )} ${symbol}`,
- gatorPermissionsMaxAllowance: `${getDecimalizedHexValue(
- permission.data.maxAmount || '0x0',
- decimals,
- )} ${symbol}`,
- gatorPermissionsStartDate: convertTimestampToReadableDate(
- permission.data.startTime as number,
- ),
- gatorPermissionsExpirationDate: getExpirationDate(
- (permission as unknown as { rules: GatorPermissionRule[] }).rules,
- ),
- gatorPermissionsStreamRate: `${getDecimalizedHexValue(
- permission.data.amountPerSecond,
- decimals,
- )} ${symbol}/sec`,
- },
- };
- };
+ const getTokenStreamPermissionDetails = useCallback(
+ (
+ permission: NativeTokenStreamPermission | Erc20TokenStreamPermission,
+ ): PermissionDetails => {
+ const { symbol, decimals } = tokenMetadata;
+ const amountPerPeriod = convertAmountPerSecondToAmountPerPeriod(
+ permission.data.amountPerSecond,
+ 'weekly',
+ );
+ return {
+ amountLabel: {
+ translationKey: 'gatorPermissionsStreamingAmountLabel',
+ value: decimals
+ ? `${getDecimalizedHexValue(amountPerPeriod, decimals)} ${symbol}`
+ : getUnknownTokenAmountText(),
+ testId: 'review-gator-permission-amount-label',
+ },
+ frequencyLabel: {
+ translationKey: 'gatorPermissionTokenStreamFrequencyLabel',
+ valueTranslationKey: 'gatorPermissionWeeklyFrequency',
+ testId: 'review-gator-permission-frequency-label',
+ },
+ expandedDetails: {
+ initialAllowance: {
+ translationKey: 'gatorPermissionsInitialAllowance',
+ value: decimals
+ ? `${getDecimalizedHexValue(
+ permission.data.initialAmount || '0x0',
+ decimals,
+ )} ${symbol}`
+ : getUnknownTokenAmountText(),
+ testId: 'review-gator-permission-initial-allowance',
+ },
+ maxAllowance: {
+ translationKey: 'gatorPermissionsMaxAllowance',
+ value: decimals
+ ? `${getDecimalizedHexValue(
+ permission.data.maxAmount || '0x0',
+ decimals,
+ )} ${symbol}`
+ : getUnknownTokenAmountText(),
+ testId: 'review-gator-permission-max-allowance',
+ },
+ startDate: {
+ translationKey: 'gatorPermissionsStartDate',
+ value: convertTimestampToReadableDate(
+ permission.data.startTime as number,
+ ),
+ testId: 'review-gator-permission-start-date',
+ },
+
+ // TODO: Need to expose rules on StoredGatorPermissionSanitized in the gator-permissions-controller so we can have stronger typing
+ expirationDate: {
+ translationKey: 'gatorPermissionsExpirationDate',
+ value: getExpirationDate(
+ (permission as unknown as { rules: GatorPermissionRule[] }).rules,
+ ),
+ testId: 'review-gator-permission-expiration-date',
+ },
+ streamRate: {
+ translationKey: 'gatorPermissionsStreamRate',
+ value: decimals
+ ? `${getDecimalizedHexValue(
+ permission.data.amountPerSecond,
+ decimals,
+ )} ${symbol}/sec`
+ : getUnknownTokenAmountText(),
+ testId: 'review-gator-permission-stream-rate',
+ },
+ },
+ };
+ },
+ [
+ tokenMetadata,
+ getDecimalizedHexValue,
+ getExpirationDate,
+ getUnknownTokenAmountText,
+ ],
+ );
/**
* Returns the token periodic permission details
@@ -220,34 +286,56 @@ export const ReviewGatorPermissionItem = ({
* @param permission - The periodic permission data
* @returns The permission details
*/
- const getTokenPeriodicPermissionDetails = (
- permission: NativeTokenPeriodicPermission | Erc20TokenPeriodicPermission,
- ): PermissionDetails => {
- const { symbol, decimals } = tokenMetadata;
- return {
- amountLabel: {
- translationKey: 'amount',
- value: `${getDecimalizedHexValue(
- permission.data.periodAmount,
- decimals,
- )} ${symbol}`,
- },
- frequencyLabel: {
- translationKey: 'gatorPermissionTokenPeriodicFrequencyLabel',
- valueTranslationKey: getPeriodFrequencyValueTranslationKey(
- permission.data.periodDuration,
- ),
- },
- expandedDetails: {
- gatorPermissionsStartDate: convertTimestampToReadableDate(
- permission.data.startTime ?? 0,
- ),
- gatorPermissionsExpirationDate: getExpirationDate(
- (permission as unknown as { rules: GatorPermissionRule[] }).rules,
- ),
- },
- };
- };
+ const getTokenPeriodicPermissionDetails = useCallback(
+ (
+ permission: NativeTokenPeriodicPermission | Erc20TokenPeriodicPermission,
+ ): PermissionDetails => {
+ const { symbol, decimals } = tokenMetadata;
+ return {
+ amountLabel: {
+ translationKey: 'amount',
+ value: decimals
+ ? `${getDecimalizedHexValue(
+ permission.data.periodAmount,
+ decimals,
+ )} ${symbol}`
+ : getUnknownTokenAmountText(),
+ testId: 'review-gator-permission-amount-label',
+ },
+ frequencyLabel: {
+ translationKey: 'gatorPermissionTokenPeriodicFrequencyLabel',
+ valueTranslationKey: getPeriodFrequencyValueTranslationKey(
+ permission.data.periodDuration,
+ ),
+ testId: 'review-gator-permission-frequency-label',
+ },
+ expandedDetails: {
+ startDate: {
+ translationKey: 'gatorPermissionsStartDate',
+ value: convertTimestampToReadableDate(
+ permission.data.startTime ?? 0,
+ ),
+ testId: 'review-gator-permission-start-date',
+ },
+
+ // TODO: Need to expose rules on StoredGatorPermissionSanitized in the gator-permissions-controller so we can have stronger typing
+ expirationDate: {
+ translationKey: 'gatorPermissionsExpirationDate',
+ value: getExpirationDate(
+ (permission as unknown as { rules: GatorPermissionRule[] }).rules,
+ ),
+ testId: 'review-gator-permission-expiration-date',
+ },
+ },
+ };
+ },
+ [
+ tokenMetadata,
+ getDecimalizedHexValue,
+ getExpirationDate,
+ getUnknownTokenAmountText,
+ ],
+ );
/**
* Returns the permission details
@@ -265,7 +353,12 @@ export const ReviewGatorPermissionItem = ({
default:
throw new Error(`Invalid permission type: ${permissionType}`);
}
- }, [permissionType, permissionResponse, tokenMetadata]);
+ }, [
+ permissionType,
+ getTokenStreamPermissionDetails,
+ permissionResponse.permission,
+ getTokenPeriodicPermissionDetails,
+ ]);
return (
{permissionDetails.amountLabel.value}
@@ -360,6 +454,7 @@ export const ReviewGatorPermissionItem = ({
{t(permissionDetails.frequencyLabel.valueTranslationKey)}
@@ -461,6 +556,7 @@ export const ReviewGatorPermissionItem = ({
textAlign={TextAlign.Right}
color={TextColor.TextAlternative}
variant={TextVariant.BodyMd}
+ data-testid="review-gator-permission-network-name"
>
{networkName}
@@ -468,9 +564,10 @@ export const ReviewGatorPermissionItem = ({
{Object.entries(permissionDetails.expandedDetails).map(
- ([key, value]) => {
+ ([key, detail]) => {
return (
- {t(key)}
+ {t(detail.translationKey)}
- {value}
+ {detail.value}
);
From 79558cff0de4776c206c67d2c6726633641a4678 Mon Sep 17 00:00:00 2001
From: hanzel98
Date: Sat, 25 Oct 2025 17:48:16 -0600
Subject: [PATCH 5/7] chore: added already disabled delegation check, and
filtered permissioned
---
.../gator-permissions-controller-init.ts | 2 +
.../gator-permissions-controller-messenger.ts | 7 +-
app/scripts/metamask-controller.js | 13 ++++
shared/lib/delegation/delegation.ts | 31 ++++++++
.../gator-permissions/useGatorPermissions.ts | 4 +-
.../useRevokeGatorPermissions.ts | 36 +++++++++
.../gator-permissions-controller.ts | 73 +++++++++++++++++--
7 files changed, 156 insertions(+), 10 deletions(-)
diff --git a/app/scripts/controller-init/gator-permissions/gator-permissions-controller-init.ts b/app/scripts/controller-init/gator-permissions/gator-permissions-controller-init.ts
index 6b59ed975abb..789c6aac6044 100644
--- a/app/scripts/controller-init/gator-permissions/gator-permissions-controller-init.ts
+++ b/app/scripts/controller-init/gator-permissions/gator-permissions-controller-init.ts
@@ -59,6 +59,8 @@ export const GatorPermissionsControllerInit: ControllerInitFunction<
api: {
fetchAndUpdateGatorPermissions:
controller.fetchAndUpdateGatorPermissions.bind(controller),
+ addPendingRevocation: controller.addPendingRevocation.bind(controller),
+ submitRevocation: controller.submitRevocation.bind(controller),
},
};
};
diff --git a/app/scripts/controller-init/messengers/gator-permissions/gator-permissions-controller-messenger.ts b/app/scripts/controller-init/messengers/gator-permissions/gator-permissions-controller-messenger.ts
index 0fe2bf2534cf..99d844f5b354 100644
--- a/app/scripts/controller-init/messengers/gator-permissions/gator-permissions-controller-messenger.ts
+++ b/app/scripts/controller-init/messengers/gator-permissions/gator-permissions-controller-messenger.ts
@@ -1,13 +1,16 @@
import { Messenger } from '@metamask/base-controller';
import { GatorPermissionsControllerStateChangeEvent } from '@metamask/gator-permissions-controller';
import { HandleSnapRequest, HasSnap } from '@metamask/snaps-controllers';
+import { TransactionControllerTransactionConfirmedEvent } from '@metamask/transaction-controller';
export type GatorPermissionsControllerMessenger = ReturnType<
typeof getGatorPermissionsControllerMessenger
>;
type MessengerActions = HandleSnapRequest | HasSnap;
-type MessengerEvents = GatorPermissionsControllerStateChangeEvent;
+type MessengerEvents =
+ | GatorPermissionsControllerStateChangeEvent
+ | TransactionControllerTransactionConfirmedEvent;
/**
* Get a restricted messenger for the Gator Permissions controller. This is scoped to the
@@ -22,6 +25,6 @@ export function getGatorPermissionsControllerMessenger(
return messenger.getRestricted({
name: 'GatorPermissionsController',
allowedActions: ['SnapController:handleRequest', 'SnapController:has'],
- allowedEvents: [],
+ allowedEvents: ['TransactionController:transactionConfirmed'],
});
}
diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js
index 56676183a7c1..1d7586b9270b 100644
--- a/app/scripts/metamask-controller.js
+++ b/app/scripts/metamask-controller.js
@@ -2201,6 +2201,19 @@ export default class MetamaskController extends EventEmitter {
return {
// etc
getState: this.getState.bind(this),
+ call: async (method, params, networkClientId) => {
+ // Handle eth_call specifically
+ if (method === 'eth_call') {
+ const networkClient =
+ this.networkController.getNetworkClientById(networkClientId);
+ return await networkClient.provider.request({
+ method: 'eth_call',
+ params,
+ });
+ }
+ // For other methods, use the controller messenger
+ return await this.controllerMessenger.call(method, ...params);
+ },
setCurrentCurrency: currencyRateController.setCurrentCurrency.bind(
currencyRateController,
),
diff --git a/shared/lib/delegation/delegation.ts b/shared/lib/delegation/delegation.ts
index ee7c87bf3e23..af42e20f105c 100644
--- a/shared/lib/delegation/delegation.ts
+++ b/shared/lib/delegation/delegation.ts
@@ -264,6 +264,37 @@ export const encodeDisableDelegation = ({
return concat([encodedSignature, encodedData]);
};
+/**
+ * Encodes the calldata for a disabledDelegations(bytes32) view call.
+ * This is used to check if a delegation has already been disabled on-chain.
+ *
+ * @param params
+ * @param params.delegationHash - The hash of the delegation to check.
+ * @returns The encoded calldata.
+ */
+export const encodeDisabledDelegationsCheck = ({
+ delegationHash,
+}: {
+ delegationHash: Hex;
+}): Hex => {
+ const encodedSignature = toFunctionSelector('disabledDelegations(bytes32)');
+ const encodedData = toHex(encode(['bytes32'], [delegationHash]));
+ return concat([encodedSignature, encodedData]);
+};
+
+/**
+ * Decodes the result from a disabledDelegations(bytes32) view call.
+ *
+ * @param result - The raw hex result from the eth_call.
+ * @returns True if the delegation is disabled, false otherwise.
+ */
+export const decodeDisabledDelegationsResult = (result: Hex): boolean => {
+ if (!result || result === '0x') {
+ return false;
+ }
+ return BigInt(result) !== 0n;
+};
+
/**
* Encodes the calldata for a redeemDelegations(delegations,modes,executions) call.
*
diff --git a/ui/hooks/gator-permissions/useGatorPermissions.ts b/ui/hooks/gator-permissions/useGatorPermissions.ts
index 93eb0700c16c..699a6a598e0f 100644
--- a/ui/hooks/gator-permissions/useGatorPermissions.ts
+++ b/ui/hooks/gator-permissions/useGatorPermissions.ts
@@ -17,7 +17,9 @@ export function useGatorPermissions() {
setError(undefined);
setLoading(true);
- const newData = await fetchAndUpdateGatorPermissions();
+ const newData = await fetchAndUpdateGatorPermissions({
+ isRevoked: false,
+ });
if (!cancelled) {
setData(newData);
await forceUpdateMetamaskState(dispatch);
diff --git a/ui/hooks/gator-permissions/useRevokeGatorPermissions.ts b/ui/hooks/gator-permissions/useRevokeGatorPermissions.ts
index f304e9f57b0c..1d8286901688 100644
--- a/ui/hooks/gator-permissions/useRevokeGatorPermissions.ts
+++ b/ui/hooks/gator-permissions/useRevokeGatorPermissions.ts
@@ -20,8 +20,14 @@ import { useConfirmationNavigation } from '../../pages/confirmations/hooks/useCo
import {
encodeDisableDelegation,
Delegation,
+ getDelegationHashOffchain,
} from '../../../shared/lib/delegation/delegation';
import { isEqualCaseInsensitive } from '../../../shared/modules/string-utils';
+import {
+ addPendingRevocation,
+ submitRevocation,
+ isDelegationDisabled,
+} from '../../store/controller-actions/gator-permissions-controller';
export type RevokeGatorPermissionArgs = {
accountAddress: Hex;
@@ -213,6 +219,34 @@ export function useRevokeGatorPermissions({
const delegation =
extractDelegationFromGatorPermissionContext(permissionContext);
+ const delegationHash = getDelegationHashOffchain(delegation);
+ // const delegationHash =
+ // '0xfd165b374563126931d2be865bbec75623dca111840d148cf88492c0bb997f96';
+ console.log('🔐 Delegation hash:', delegationHash);
+
+ // Check if delegation is already disabled on-chain
+ console.log('⏳ About to call isDelegationDisabled...');
+ const isDisabled = await isDelegationDisabled(
+ delegationManagerAddress,
+ delegationHash,
+ networkClientId,
+ );
+ console.log('⏳ isDelegationDisabled completed, result:', isDisabled);
+
+ if (isDisabled) {
+ console.log(
+ '✅ Delegation already disabled on-chain, submitting revocation directly',
+ );
+ await submitRevocation(permissionContext);
+ // Return a mock transaction meta since no actual transaction is needed
+ return {
+ id: `revoked-${Date.now()}`,
+ status: 'confirmed',
+ } as TransactionMeta;
+ }
+
+ console.log('⚠️ Delegation is active, creating disable transaction');
+
const encodedCallData = encodeDisableDelegation({
delegation,
});
@@ -238,6 +272,8 @@ export function useRevokeGatorPermissions({
throw new Error('No transaction id found');
}
+ await addPendingRevocation(transactionMeta.id, permissionContext);
+
return transactionMeta;
},
[
diff --git a/ui/store/controller-actions/gator-permissions-controller.ts b/ui/store/controller-actions/gator-permissions-controller.ts
index e7919c333154..633f589140b1 100644
--- a/ui/store/controller-actions/gator-permissions-controller.ts
+++ b/ui/store/controller-actions/gator-permissions-controller.ts
@@ -1,10 +1,69 @@
import { GatorPermissionsMap } from '@metamask/gator-permissions-controller';
+import { Hex, Json } from '@metamask/utils';
+import {
+ encodeDisabledDelegationsCheck,
+ decodeDisabledDelegationsResult,
+} from '../../../shared/lib/delegation/delegation';
import { submitRequestToBackground } from '../background-connection';
-export const fetchAndUpdateGatorPermissions =
- async (): Promise => {
- return await submitRequestToBackground(
- 'fetchAndUpdateGatorPermissions',
- [],
- );
- };
+export const fetchAndUpdateGatorPermissions = async (
+ params?: Json,
+): Promise => {
+ return await submitRequestToBackground(
+ 'fetchAndUpdateGatorPermissions',
+ params ? [params] : [],
+ );
+};
+
+export const addPendingRevocation = async (
+ txId: string,
+ permissionContext: Hex,
+): Promise => {
+ return await submitRequestToBackground('addPendingRevocation', [
+ txId,
+ permissionContext,
+ ]);
+};
+
+export const submitRevocation = async (
+ permissionContext: Hex,
+): Promise => {
+ return await submitRequestToBackground('submitRevocation', [
+ { permissionContext },
+ ]);
+};
+
+/**
+ * Checks if a delegation is already disabled on-chain by querying the
+ * delegation manager contract's disabledDelegations mapping.
+ *
+ * @param delegationManagerAddress - The delegation manager contract address.
+ * @param delegationHash - The hash of the delegation to check.
+ * @param networkClientId - The network client ID to use for the query.
+ * @returns True if the delegation is disabled, false otherwise.
+ */
+export const isDelegationDisabled = async (
+ delegationManagerAddress: Hex,
+ delegationHash: Hex,
+ networkClientId: string,
+): Promise => {
+ // Encode the call to disabledDelegations(bytes32)
+ const callData = encodeDisabledDelegationsCheck({ delegationHash });
+
+ // Make eth_call request through the network controller
+ const result = await submitRequestToBackground('call', [
+ 'eth_call',
+ [
+ {
+ to: delegationManagerAddress,
+ data: callData,
+ },
+ 'latest',
+ ],
+ networkClientId,
+ ]);
+
+ const isDisabled = decodeDisabledDelegationsResult(result);
+
+ return isDisabled;
+};
From a0e62d638ae0fb5c42b3301549874c98d00a2748 Mon Sep 17 00:00:00 2001
From: hanzel98
Date: Sat, 25 Oct 2025 20:04:42 -0600
Subject: [PATCH 6/7] chore: adding new allowed events
---
.../gator-permissions-controller-messenger.ts | 16 +++++++++++++---
1 file changed, 13 insertions(+), 3 deletions(-)
diff --git a/app/scripts/controller-init/messengers/gator-permissions/gator-permissions-controller-messenger.ts b/app/scripts/controller-init/messengers/gator-permissions/gator-permissions-controller-messenger.ts
index 99d844f5b354..7436eadcdf2e 100644
--- a/app/scripts/controller-init/messengers/gator-permissions/gator-permissions-controller-messenger.ts
+++ b/app/scripts/controller-init/messengers/gator-permissions/gator-permissions-controller-messenger.ts
@@ -1,7 +1,11 @@
import { Messenger } from '@metamask/base-controller';
import { GatorPermissionsControllerStateChangeEvent } from '@metamask/gator-permissions-controller';
import { HandleSnapRequest, HasSnap } from '@metamask/snaps-controllers';
-import { TransactionControllerTransactionConfirmedEvent } from '@metamask/transaction-controller';
+import {
+ TransactionControllerTransactionConfirmedEvent,
+ TransactionControllerTransactionFailedEvent,
+ TransactionControllerTransactionDroppedEvent,
+} from '@metamask/transaction-controller';
export type GatorPermissionsControllerMessenger = ReturnType<
typeof getGatorPermissionsControllerMessenger
@@ -10,7 +14,9 @@ export type GatorPermissionsControllerMessenger = ReturnType<
type MessengerActions = HandleSnapRequest | HasSnap;
type MessengerEvents =
| GatorPermissionsControllerStateChangeEvent
- | TransactionControllerTransactionConfirmedEvent;
+ | TransactionControllerTransactionConfirmedEvent
+ | TransactionControllerTransactionFailedEvent
+ | TransactionControllerTransactionDroppedEvent;
/**
* Get a restricted messenger for the Gator Permissions controller. This is scoped to the
@@ -25,6 +31,10 @@ export function getGatorPermissionsControllerMessenger(
return messenger.getRestricted({
name: 'GatorPermissionsController',
allowedActions: ['SnapController:handleRequest', 'SnapController:has'],
- allowedEvents: ['TransactionController:transactionConfirmed'],
+ allowedEvents: [
+ 'TransactionController:transactionConfirmed',
+ 'TransactionController:transactionFailed',
+ 'TransactionController:transactionDropped',
+ ],
});
}
From a3dd9a08a90aa9aef092f2a2a7cc70d920573fdd Mon Sep 17 00:00:00 2001
From: hanzel98
Date: Sat, 25 Oct 2025 21:10:26 -0600
Subject: [PATCH 7/7] chore: deleted console logs, added mocks
---
.../useRevokeGatorPermissions.test.tsx | 40 ++++++++++++++++++-
.../useRevokeGatorPermissions.ts | 38 +++++++++++-------
2 files changed, 63 insertions(+), 15 deletions(-)
diff --git a/ui/hooks/gator-permissions/useRevokeGatorPermissions.test.tsx b/ui/hooks/gator-permissions/useRevokeGatorPermissions.test.tsx
index b89bfe1cf1d9..b6f4f2d5c3c6 100644
--- a/ui/hooks/gator-permissions/useRevokeGatorPermissions.test.tsx
+++ b/ui/hooks/gator-permissions/useRevokeGatorPermissions.test.tsx
@@ -18,11 +18,19 @@ import {
} from '@metamask/gator-permissions-controller';
import { RpcEndpointType } from '@metamask/network-controller';
import { addTransaction } from '../../store/actions';
-import { encodeDisableDelegation } from '../../../shared/lib/delegation/delegation';
+import {
+ encodeDisableDelegation,
+ getDelegationHashOffchain,
+} from '../../../shared/lib/delegation/delegation';
import {
getInternalAccounts,
selectDefaultRpcEndpointByChainId,
} from '../../selectors';
+import {
+ addPendingRevocation,
+ submitRevocation,
+ isDelegationDisabled,
+} from '../../store/controller-actions/gator-permissions-controller';
import { useRevokeGatorPermissions } from './useRevokeGatorPermissions';
// Mock the dependencies
@@ -35,12 +43,22 @@ jest.mock('../../store/controller-actions/transaction-controller', () => ({
addTransactionBatch: jest.fn(),
}));
+jest.mock(
+ '../../store/controller-actions/gator-permissions-controller',
+ () => ({
+ addPendingRevocation: jest.fn(),
+ submitRevocation: jest.fn(),
+ isDelegationDisabled: jest.fn(),
+ }),
+);
+
jest.mock('@metamask/delegation-core', () => ({
decodeDelegations: jest.fn(),
}));
jest.mock('../../../shared/lib/delegation/delegation', () => ({
encodeDisableDelegation: jest.fn(),
+ getDelegationHashOffchain: jest.fn(),
}));
jest.mock('../../../shared/lib/delegation', () => ({
@@ -76,6 +94,20 @@ const mockEncodeDisableDelegation =
encodeDisableDelegation as jest.MockedFunction<
typeof encodeDisableDelegation
>;
+const mockGetDelegationHashOffchain =
+ getDelegationHashOffchain as jest.MockedFunction<
+ typeof getDelegationHashOffchain
+ >;
+
+const mockAddPendingRevocation = addPendingRevocation as jest.MockedFunction<
+ typeof addPendingRevocation
+>;
+const mockSubmitRevocation = submitRevocation as jest.MockedFunction<
+ typeof submitRevocation
+>;
+const mockIsDelegationDisabled = isDelegationDisabled as jest.MockedFunction<
+ typeof isDelegationDisabled
+>;
const mockGetInternalAccounts = getInternalAccounts as jest.MockedFunction<
typeof getInternalAccounts
@@ -227,6 +259,12 @@ describe('useRevokeGatorPermissions', () => {
mockEncodeDisableDelegation.mockReturnValue(
'0xencodeddata' as `0x${string}`,
);
+ mockGetDelegationHashOffchain.mockReturnValue(
+ '0xfd165b374563126931d2be865bbec75623dca111840d148cf88492c0bb997f96' as `0x${string}`,
+ );
+ mockIsDelegationDisabled.mockResolvedValue(false);
+ mockSubmitRevocation.mockResolvedValue(undefined);
+ mockAddPendingRevocation.mockResolvedValue(undefined);
mockAddTransaction.mockResolvedValue(mockTransactionMeta as never);
// Setup selector mocks
diff --git a/ui/hooks/gator-permissions/useRevokeGatorPermissions.ts b/ui/hooks/gator-permissions/useRevokeGatorPermissions.ts
index 1d8286901688..09de0a4c8973 100644
--- a/ui/hooks/gator-permissions/useRevokeGatorPermissions.ts
+++ b/ui/hooks/gator-permissions/useRevokeGatorPermissions.ts
@@ -220,32 +220,41 @@ export function useRevokeGatorPermissions({
extractDelegationFromGatorPermissionContext(permissionContext);
const delegationHash = getDelegationHashOffchain(delegation);
- // const delegationHash =
- // '0xfd165b374563126931d2be865bbec75623dca111840d148cf88492c0bb997f96';
- console.log('🔐 Delegation hash:', delegationHash);
- // Check if delegation is already disabled on-chain
- console.log('⏳ About to call isDelegationDisabled...');
const isDisabled = await isDelegationDisabled(
delegationManagerAddress,
delegationHash,
networkClientId,
);
- console.log('⏳ isDelegationDisabled completed, result:', isDisabled);
if (isDisabled) {
- console.log(
- '✅ Delegation already disabled on-chain, submitting revocation directly',
- );
await submitRevocation(permissionContext);
// Return a mock transaction meta since no actual transaction is needed
- return {
+ const mockTransactionMeta = {
id: `revoked-${Date.now()}`,
- status: 'confirmed',
- } as TransactionMeta;
- }
+ status: 'confirmed' as const,
+ txParams: {
+ from: accountAddress,
+ to: delegationManagerAddress,
+ data: '0x',
+ value: '0x0',
+ gas: '0x0',
+ gasPrice: '0x0',
+ nonce: '0x0',
+ },
+ chainId,
+ networkClientId,
+ type: TransactionType.contractInteraction,
+ time: Date.now(),
+ history: [] as unknown[],
+ };
+
+ // Initialize history with the initial state
+ const initialHistoryEntry = { ...mockTransactionMeta };
+ mockTransactionMeta.history = [initialHistoryEntry];
- console.log('⚠️ Delegation is active, creating disable transaction');
+ return mockTransactionMeta as TransactionMeta;
+ }
const encodedCallData = encodeDisableDelegation({
delegation,
@@ -280,6 +289,7 @@ export function useRevokeGatorPermissions({
getDefaultRpcEndpoint,
buildRevokeGatorPermissionArgs,
extractDelegationFromGatorPermissionContext,
+ chainId,
],
);