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`] = ` +
+
+
+
+

+ localhost:8000 +

+
+

+ Revoke +

+
+
+
+
+
+

+ Amount +

+
+

+ 0.5 WBTC +

+
+
+
+

+ Transfer Window +

+
+

+ Weekly +

+
+
+
+

+ Account +

+
+
+
+
+ + + + + +
+
+
+

+ 0x4f71D...E7a63 +

+
+
+
+
+
+
+

+ Show Details +

+ +
+
+
+
+
+`; + +exports[`Permission List Item render ERC20 token permissions renders erc20 token permission with unknown token amount correctly 1`] = ` +
+
+
+
+

+ localhost:8000 +

+
+

+ Revoke +

+
+
+
+
+
+

+ Streaming amount +

+
+

+ Unknown amount +

+
+
+
+

+ Period +

+
+

+ Weekly +

+
+
+
+

+ Account +

+
+
+
+
+ + + + + +
+
+
+

+ 0x4f71D...E7a63 +

+
+
+
+
+
+
+

+ Show Details +

+ +
+
+
+
+
+`; + +exports[`Permission List Item render ERC20 token permissions renders erc20 token stream permission correctly 1`] = ` +
+
+
+
+

+ localhost:8000 +

+
+

+ Revoke +

+
+
+
+
+
+

+ Streaming amount +

+
+

+ 302400 WBTC +

+
+
+
+

+ Period +

+
+

+ Weekly +

+
+
+
+

+ Account +

+
+
+
+
+ + + + + +
+
+
+

+ 0x4f71D...E7a63 +

+
+
+
+
+
+
+

+ Show Details +

+ +
+
+
+
+
+`; + +exports[`Permission List Item render NATIVE token permissions renders native token periodic permission correctly 1`] = ` +
+
+
+
+

+ localhost:8000 +

+
+

+ Revoke +

+
+
+
+
+
+

+ Amount +

+
+

+ 0.5 ETH +

+
+
+
+

+ Transfer Window +

+
+

+ Weekly +

+
+
+
+

+ Account +

+
+
+
+
+ + + + + +
+
+
+

+ 0x4f71D...E7a63 +

+
+
+
+
+
+
+

+ Show Details +

+ +
+
+
+
+
+`; + +exports[`Permission List Item render NATIVE token permissions renders native token stream permission correctly 1`] = ` +
+
+
+
+

+ localhost:8000 +

+
+

+ Revoke +

+
+
+
+
+
+

+ Streaming amount +

+
+

+ 302400 ETH +

+
+
+
+

+ Period +

+
+

+ Weekly +

+
+
+
+

+ 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, ], );