diff --git a/packages/gator-permissions-snap/locales/en.json b/packages/gator-permissions-snap/locales/en.json index c61f85c2..930eb657 100644 --- a/packages/gator-permissions-snap/locales/en.json +++ b/packages/gator-permissions-snap/locales/en.json @@ -257,10 +257,22 @@ "message": "Existing permissions" }, "existingPermissionsDescription": { - "message": "You've already granted permissions for this site. Do you want to continue?" + "message": "Permissions you’ve already granted to this site" }, "existingPermissionsConfirmButton": { - "message": "Continue" + "message": "Back to request" + }, + "existingPermissionsExistingMessage": { + "message": "You have granted permissions to this site in the past." + }, + "existingPermissionsSimilarMessage": { + "message": "You have granted similar permissions to this site in the past." + }, + "existingPermissionsLink": { + "message": "Review them" + }, + "existingPermissionsLoadError": { + "message": "We couldn’t load the list. You can go back and try again." }, "chainLabel": { "message": "Network" diff --git a/packages/gator-permissions-snap/src/core/confirmation.tsx b/packages/gator-permissions-snap/src/core/confirmation.tsx index df215c0e..fc37e97c 100644 --- a/packages/gator-permissions-snap/src/core/confirmation.tsx +++ b/packages/gator-permissions-snap/src/core/confirmation.tsx @@ -1,21 +1,19 @@ import { UserInputEventType } from '@metamask/snaps-sdk'; import type { SnapElement } from '@metamask/snaps-sdk/jsx'; -import { Button, Container, Footer } from '@metamask/snaps-sdk/jsx'; import type { DialogInterface } from './dialogInterface'; import type { UserEventDispatcher } from '../userEventDispatcher'; import type { Timeout, TimeoutFactory } from './timeoutFactory'; import type { ConfirmationProps } from './types'; -import { t } from '../utils/i18n'; /** * Dialog for handling user confirmation of permission grants. * Manages the UI state, timeout behavior, and user interactions. */ export class ConfirmationDialog { - static readonly #cancelButton = 'cancel-button'; + static readonly cancelButton = 'cancel-button'; - static readonly #grantButton = 'grant-button'; + static readonly grantButton = 'grant-button'; readonly #dialogInterface: DialogInterface; @@ -25,8 +23,6 @@ export class ConfirmationDialog { #ui: SnapElement; - #isGrantDisabled = true; - #timeout: Timeout | undefined; #hasTimedOut = false; @@ -119,7 +115,7 @@ export class ConfirmationDialog { }); const { unbind: unbindGrantButtonClick } = this.#userEventDispatcher.on({ - elementName: ConfirmationDialog.#grantButton, + elementName: ConfirmationDialog.grantButton, eventType: UserInputEventType.ButtonClickEvent, interfaceId, handler: async () => { @@ -141,7 +137,7 @@ export class ConfirmationDialog { }); const { unbind: unbindCancelButtonClick } = this.#userEventDispatcher.on({ - elementName: ConfirmationDialog.#cancelButton, + elementName: ConfirmationDialog.cancelButton, eventType: UserInputEventType.ButtonClickEvent, interfaceId, handler: async () => { @@ -184,41 +180,18 @@ export class ConfirmationDialog { } #buildConfirmation(): JSX.Element { - return ( - - {this.#ui} - - - ); + return this.#ui; } /** * Updates the confirmation dialog content. + * Grant enable/disable is encoded in the `ui` tree (for example `PermissionHandlerContent` props). + * * @param options - The update options. * @param options.ui - The new UI content. - * @param options.isGrantDisabled - Whether the grant button should be disabled. */ - async updateContent({ - ui, - isGrantDisabled, - }: { - ui: SnapElement; - isGrantDisabled: boolean; - }): Promise { + async updateContent({ ui }: { ui: SnapElement }): Promise { this.#ui = ui; - this.#isGrantDisabled = isGrantDisabled; - await this.#dialogInterface.show(this.#buildConfirmation()); } diff --git a/packages/gator-permissions-snap/src/core/existingpermissions/existingPermissionsContent.tsx b/packages/gator-permissions-snap/src/core/existingpermissions/existingPermissionsContent.tsx index f41243a0..ff8ab647 100644 --- a/packages/gator-permissions-snap/src/core/existingpermissions/existingPermissionsContent.tsx +++ b/packages/gator-permissions-snap/src/core/existingpermissions/existingPermissionsContent.tsx @@ -1,17 +1,15 @@ import { Box, - Button, - Container, Section, - Footer, Heading, Text, Address, - Divider, - Bold, + Container, + Button, + Footer, Skeleton, + SnapElement, } from '@metamask/snaps-sdk/jsx'; -import type { SnapElement } from '@metamask/snaps-sdk/jsx'; import { Hex } from '@metamask/utils'; import { groupPermissionsByFromAddress } from './permissionFormatter'; @@ -23,15 +21,12 @@ import { t } from '../../utils/i18n'; export const EXISTING_PERMISSIONS_CONFIRM_BUTTON = 'existing-permissions-confirm'; -// Maximum number of permissions to display per account -const MAX_PERMISSIONS_PER_ACCOUNT = 3; - /** - * Builds a skeleton loading state for the existing permissions dialog. - * Shows placeholder UI while permissions are being fetched and formatted. + * Builds a skeleton loading UI for the existing permissions page. + * Displays placeholder content while permissions are being loaded and formatted. * - * @param config - The configuration for the existing permissions display (used for title/description). - * @returns The skeleton loading UI as a JSX.Element. + * @param config - Title, description, and button label keys (same as the full list view). + * @returns The skeleton UI as a JSX.Element. */ export function buildExistingPermissionsSkeletonContent( config: ExistingPermissionDisplayConfig, @@ -54,14 +49,12 @@ export function buildExistingPermissionsSkeletonContent( {t('accountLabel')} - {/* Show 2 skeleton permission cards */} {[0, 1].map((permIndex) => ( - {permIndex > 0 && } @@ -83,8 +76,40 @@ export function buildExistingPermissionsSkeletonContent( } /** - * Builds the existing permissions display content. - * Shows a comparison between an existing permission and what the user is about to grant. + * Fallback when loading or formatting the existing-permissions list fails. + * Keeps the confirm action enabled so the user can return to the main request. + * + * @param config - Title, description, and button label keys. + * @returns Fallback UI as a SnapElement. + */ +export function buildExistingPermissionsFallbackContent( + config: Pick< + ExistingPermissionDisplayConfig, + 'title' | 'description' | 'buttonLabel' + >, +): SnapElement { + const { title, description, buttonLabel } = config; + + return ( + + + + {t(title)} + {t(description)} + {t('existingPermissionsLoadError')} + + +
+ +
+
+ ); +} + +/** + * Builds the existing permissions display content: a grouped list of stored granted permissions for review. * * @param config - The configuration for the existing permissions display. * @returns The existing permissions UI as a JSX.Element. @@ -105,42 +130,20 @@ export function buildExistingPermissionsContent(
{Object.entries(grouped).map(([accountAddress, permissions]) => { - const displayedPermissions = permissions.slice( - 0, - MAX_PERMISSIONS_PER_ACCOUNT, - ); - const hasMorePermissions = - permissions.length > MAX_PERMISSIONS_PER_ACCOUNT; - const moreCount = permissions.length - MAX_PERMISSIONS_PER_ACCOUNT; - return ( -
- - - {t('accountLabel')} -
- - - {displayedPermissions.map((detail, index) => ( - - ))} - {hasMorePermissions && ( - - - - {moreCount === 1 - ? t('morePermissionsCountSingle') - : t('morePermissionsCountPlural', [String(moreCount)])} - {t('dappConnectionsLink')} - - - )} - -
+ +
+ {t('accountLabel')} +
+
+ {permissions.map((detail, index) => ( + + ))} +
); })}
diff --git a/packages/gator-permissions-snap/src/core/existingpermissions/existingPermissionsService.ts b/packages/gator-permissions-snap/src/core/existingpermissions/existingPermissionsService.ts index 9b69960d..52a2848c 100644 --- a/packages/gator-permissions-snap/src/core/existingpermissions/existingPermissionsService.ts +++ b/packages/gator-permissions-snap/src/core/existingpermissions/existingPermissionsService.ts @@ -1,10 +1,15 @@ -import { UserInputEventType } from '@metamask/snaps-sdk'; +import type { Permission } from '@metamask/7715-permissions-shared/types'; +import { + extractDescriptorName, + logger, +} from '@metamask/7715-permissions-shared/utils'; import { buildExistingPermissionsContent, + buildExistingPermissionsFallbackContent, buildExistingPermissionsSkeletonContent, - EXISTING_PERMISSIONS_CONFIRM_BUTTON, } from './existingPermissionsContent'; +import { ExistingPermissionsState } from './existingPermissionsState'; import { formatPermissionWithTokenMetadata } from './permissionFormatter'; import type { ExistingPermissionDisplayConfig } from './types'; import type { @@ -12,182 +17,219 @@ import type { StoredGrantedPermission, } from '../../profileSync/profileSync'; import type { TokenMetadataService } from '../../services/tokenMetadataService'; -import type { UserEventDispatcher } from '../../userEventDispatcher'; import type { DialogInterface } from '../dialogInterface'; +export { ExistingPermissionsState } from './existingPermissionsState'; + +/** + * Extracts the category (stream or periodic) from a permission type name. + * Uses suffix checks so unrelated names that merely contain "stream" as a substring are not misclassified. + * + * @param permissionTypeName - The permission type name to extract category from. + * @returns The category ('stream' or 'periodic') or null if unrecognized. + */ +function extractPermissionCategory( + permissionTypeName: string, +): 'stream' | 'periodic' | null { + if (permissionTypeName.endsWith('-periodic')) { + return 'periodic'; + } + if (permissionTypeName.endsWith('-stream')) { + return 'stream'; + } + return null; +} + /** - * Service for displaying existing permissions when a dApp requests new ones. - * Provides UI for showing the comparison between existing and requested permissions. + * Loads stored permissions for a site, classifies them against the current request for banner UI, + * and builds the full existing-permissions review screen. */ export class ExistingPermissionsService { readonly #profileSyncManager: ProfileSyncManager; - readonly #userEventDispatcher: UserEventDispatcher; - readonly #tokenMetadataService: TokenMetadataService; constructor({ profileSyncManager, - userEventDispatcher, tokenMetadataService, }: { profileSyncManager: ProfileSyncManager; - userEventDispatcher: UserEventDispatcher; tokenMetadataService: TokenMetadataService; }) { this.#profileSyncManager = profileSyncManager; - this.#userEventDispatcher = userEventDispatcher; this.#tokenMetadataService = tokenMetadataService; } /** - * Finds existing permissions matching the given origin. - * Returns all permissions granted to the origin across all chains (not limited to the requested chainId). - * Filters by isRevoked and siteOrigin only. + * Gets existing permissions matching the given origin. + * Entries without `permissionResponse.from` or `permissionResponse.chainId` are omitted. * * @param siteOrigin - The origin of the requesting dApp. - * @returns An array of matching stored permissions across all chains, or an empty array if not found. + * @returns Non-revoked stored granted permissions for the origin, or an empty array on failure or if none match. */ - async #findMatchingExistingPermission( + async getExistingPermissions( siteOrigin: string, ): Promise { - const allPermissions = - await this.#profileSyncManager.getAllGrantedPermissions(); - - // Return all non-revoked permissions for the origin across all chains - const matching = allPermissions.filter( - (permission) => - permission.revocationMetadata === undefined && - permission.siteOrigin.toLowerCase() === siteOrigin.toLowerCase(), + try { + const allPermissions = + await this.#profileSyncManager.getAllGrantedPermissions(); + + // Normalize origin once instead of on each iteration + const normalizedOrigin = siteOrigin.toLowerCase(); + + // Return all non-revoked permissions for the origin across all chains. + // A permission is considered valid if it has both 'from' (account) and 'chainId'. + const matching = allPermissions.filter( + (permission) => + permission.revocationMetadata === undefined && + permission.siteOrigin.toLowerCase() === normalizedOrigin && + permission.permissionResponse.from && + permission.permissionResponse.chainId, + ); + + return matching; + } catch (error) { + logger.error( + 'ExistingPermissionsService.getExistingPermissions() failed', + { + siteOrigin, + error: error instanceof Error ? error.message : error, + }, + ); + return []; + } + } + + /** + * Builds the full-screen list of stored permissions (formatted for display) and a confirm control. + * + * @param existingPermissions - Stored grants to render; typically from {@link getExistingPermissions}. + * @returns JSX for the existing-permissions review container. + */ + async createExistingPermissionsContent( + existingPermissions: StoredGrantedPermission[], + ): Promise { + const formattedPermissions = await Promise.all( + existingPermissions.map(async (stored) => + formatPermissionWithTokenMetadata( + stored.permissionResponse, + this.#tokenMetadataService, + ), + ), ); - return matching; + const config: ExistingPermissionDisplayConfig = { + existingPermissions: formattedPermissions, + title: 'existingPermissionsTitle', + description: 'existingPermissionsDescription', + buttonLabel: 'existingPermissionsConfirmButton', + }; + + return buildExistingPermissionsContent(config); } /** - * Gets existing permissions matching the given origin. - * @param siteOrigin - The origin of the requesting dApp. - * @returns An array of matching stored permissions, or an empty array if not found. + * Derives banner state from an in-memory snapshot of stored grants (no profile sync I/O). + * Use with {@link getExistingPermissions} once per permission request lifecycle. + * + * @param existingPermissions - Stored grants for the requesting origin (already filtered). + * @param requestedPermission - The permission the user is about to grant. + * @returns Banner-driving status, or {@link ExistingPermissionsState.None} if none stored or on error. */ - async getExistingPermissions( - siteOrigin: string, - ): Promise { + getExistingPermissionsStatusFromList( + existingPermissions: StoredGrantedPermission[], + requestedPermission: Permission, + ): ExistingPermissionsState { try { - return await this.#findMatchingExistingPermission(siteOrigin); - } catch { - return []; + if (existingPermissions.length === 0) { + return ExistingPermissionsState.None; + } + const requestedCategory = extractPermissionCategory( + extractDescriptorName(requestedPermission.type), + ); + // Only treat as similar when both have a recognized category (stream/periodic) and they match. + // Unrecognized types (e.g. revocation) return null; null === null would falsely mark them as similar. + const hasSimilar = + requestedCategory !== null && + existingPermissions.some((stored) => { + const storedCategory = extractPermissionCategory( + extractDescriptorName(stored.permissionResponse.permission.type), + ); + return ( + storedCategory !== null && storedCategory === requestedCategory + ); + }); + return hasSimilar + ? ExistingPermissionsState.SimilarPermissions + : ExistingPermissionsState.DissimilarPermissions; + } catch (error) { + logger.error( + 'ExistingPermissionsService.getExistingPermissionsStatusFromList() failed', + { + error: error instanceof Error ? error.message : error, + }, + ); + return ExistingPermissionsState.None; } } /** - * Shows the existing permissions dialog and waits for user acknowledgement. - * @param options - The options object. - * @param options.dialogInterface - The dialog interface to use for displaying content. - * @param options.existingPermissions - The existing permissions to display. - * @returns Object with wasCancelled flag indicating if user dismissed the dialog. + * Compares stored granted permissions for the site to the requested permission (stream vs periodic category). + * Fetches from profile sync. Prefer {@link getExistingPermissions} + {@link getExistingPermissionsStatusFromList} + * when you already have a snapshot for this origin. + * + * @param siteOrigin - The requesting dApp origin. + * @param requestedPermission - The permission the user is about to grant. + * @returns Banner-driving status, or {@link ExistingPermissionsState.None} if none stored or on error. */ - async showExistingPermissions({ - dialogInterface, - existingPermissions, - }: { - dialogInterface: DialogInterface; - existingPermissions: StoredGrantedPermission[] | undefined; - }): Promise<{ wasCancelled: boolean }> { - try { - if (!existingPermissions || existingPermissions.length === 0) { - return { wasCancelled: false }; - } + async getExistingPermissionsStatus( + siteOrigin: string, + requestedPermission: Permission, + ): Promise { + const existingPermissions = await this.getExistingPermissions(siteOrigin); + return this.getExistingPermissionsStatusFromList( + existingPermissions, + requestedPermission, + ); + } + + /** + * Shows existing permissions in a dialog with skeleton loading state. + * Uses a snapshot from {@link getExistingPermissions} so profile sync is not queried again. + * + * @param dialogInterface - The dialog interface to show content in. + * @param existingPermissions - Stored grants for this origin (same snapshot as status computation). + */ + async showExistingPermissions( + dialogInterface: DialogInterface, + existingPermissions: StoredGrantedPermission[], + ): Promise { + const skeletonConfig: ExistingPermissionDisplayConfig = { + existingPermissions: [], + title: 'existingPermissionsTitle', + description: 'existingPermissionsDescription', + buttonLabel: 'existingPermissionsConfirmButton', + }; - // Validate permissions before displaying them - // A permission is valid if it has both 'from' and 'chainId' fields - const validPermissions = existingPermissions.filter( - (stored) => - stored.permissionResponse.from && stored.permissionResponse.chainId, + try { + await dialogInterface.show( + buildExistingPermissionsSkeletonContent(skeletonConfig), ); - // If all permissions are invalid, treat as no existing permissions - if (validPermissions.length === 0) { - return { wasCancelled: false }; - } + const formattedContent = + await this.createExistingPermissionsContent(existingPermissions); - // Build configuration for the skeleton display (shown immediately) - const config: ExistingPermissionDisplayConfig = { - existingPermissions: [], - title: 'existingPermissionsTitle', - description: 'existingPermissionsDescription', - buttonLabel: 'existingPermissionsConfirmButton', - }; - - // Track unbind functions to clean up handlers - const unbindFunctions: (() => void)[] = []; - - // Helper to cleanup all event handlers - const unbindAll = (): void => { - unbindFunctions.forEach((fn) => fn()); - }; - - const wasConfirmed = await new Promise((resolve) => { - // Show skeleton immediately - const skeletonContent = buildExistingPermissionsSkeletonContent(config); - - dialogInterface - .show(skeletonContent, () => { - unbindAll(); - resolve(false); // User cancelled via X button - }) - .then(async (interfaceId) => { - try { - // Format permissions with token metadata (this may take time) - const formattedPermissions = await Promise.all( - validPermissions.map(async (stored) => - formatPermissionWithTokenMetadata( - stored.permissionResponse, - this.#tokenMetadataService, - ), - ), - ); - - // Build configuration for the actual permissions display - const actualConfig: ExistingPermissionDisplayConfig = { - existingPermissions: formattedPermissions, - title: 'existingPermissionsTitle', - description: 'existingPermissionsDescription', - buttonLabel: 'existingPermissionsConfirmButton', - }; - - // Update dialog with actual content - const actualContent = - buildExistingPermissionsContent(actualConfig); - await dialogInterface.show(actualContent); - } catch { - // If formatting fails, dialog still shows with skeleton - // This is acceptable - user can still interact with the dialog - } - - // Handler for acknowledge button - const { unbind: unbindConfirm } = this.#userEventDispatcher.on({ - elementName: EXISTING_PERMISSIONS_CONFIRM_BUTTON, - eventType: UserInputEventType.ButtonClickEvent, - interfaceId, - handler: async () => { - unbindAll(); - resolve(true); // User acknowledged - }, - }); - unbindFunctions.push(unbindConfirm); - - return undefined; - }) - .catch(() => { - unbindAll(); - resolve(false); // Error = treat as cancelled - }); - }); - - return { wasCancelled: !wasConfirmed }; - } catch { - // If anything goes wrong, just return false to continue with the flow - return { wasCancelled: false }; + await dialogInterface.show(formattedContent); + } catch (error) { + logger.error( + 'ExistingPermissionsService.showExistingPermissions() failed', + { + error: error instanceof Error ? error.message : error, + }, + ); + await dialogInterface.show( + buildExistingPermissionsFallbackContent(skeletonConfig), + ); } } } diff --git a/packages/gator-permissions-snap/src/core/existingpermissions/existingPermissionsState.ts b/packages/gator-permissions-snap/src/core/existingpermissions/existingPermissionsState.ts new file mode 100644 index 00000000..b45591af --- /dev/null +++ b/packages/gator-permissions-snap/src/core/existingpermissions/existingPermissionsState.ts @@ -0,0 +1,9 @@ +/** + * Status of existing permissions for a site with respect to the currently requested permission. + * Drives banner severity and whether the confirmation flow prefetches stored grants for this origin. + */ +export enum ExistingPermissionsState { + None = 'None', + DissimilarPermissions = 'DissimilarPermissions', + SimilarPermissions = 'SimilarPermissions', +} diff --git a/packages/gator-permissions-snap/src/core/existingpermissions/index.ts b/packages/gator-permissions-snap/src/core/existingpermissions/index.ts index a523b290..c066b959 100644 --- a/packages/gator-permissions-snap/src/core/existingpermissions/index.ts +++ b/packages/gator-permissions-snap/src/core/existingpermissions/index.ts @@ -1,6 +1,8 @@ +export { ExistingPermissionsState } from './existingPermissionsState'; export { ExistingPermissionsService } from './existingPermissionsService'; export { buildExistingPermissionsContent, + buildExistingPermissionsFallbackContent, buildExistingPermissionsSkeletonContent, EXISTING_PERMISSIONS_CONFIRM_BUTTON, } from './existingPermissionsContent'; @@ -8,7 +10,4 @@ export { formatPermissionWithTokenMetadata, groupPermissionsByFromAddress, } from './permissionFormatter'; -export type { - ExistingPermissionDisplayConfig, - FormattedPermissionForDisplay, -} from './types'; +export type { ExistingPermissionDisplayConfig } from './types'; diff --git a/packages/gator-permissions-snap/src/core/existingpermissions/permissionFormatter.ts b/packages/gator-permissions-snap/src/core/existingpermissions/permissionFormatter.ts index 846df782..b8df9ea5 100644 --- a/packages/gator-permissions-snap/src/core/existingpermissions/permissionFormatter.ts +++ b/packages/gator-permissions-snap/src/core/existingpermissions/permissionFormatter.ts @@ -1,9 +1,11 @@ import type { PermissionResponse } from '@metamask/7715-permissions-shared/types'; -import { extractDescriptorName } from '@metamask/7715-permissions-shared/utils'; +import { + extractDescriptorName, + logger, +} from '@metamask/7715-permissions-shared/utils'; import { hexToNumber } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; -import type { FormattedPermissionForDisplay } from './types'; import { DEFAULT_MAX_AMOUNT } from '../../permissions/erc20TokenStream/context'; import type { TokenMetadataService } from '../../services/tokenMetadataService'; import { t } from '../../utils/i18n'; @@ -15,7 +17,10 @@ import { nameAndExplorerUrlByChainId } from '../chainMetadata'; * Represents formatted permission details as an object. */ export type PermissionDetail = { - [key: string]: string; + [key: string]: { + label: string; + value: string; + }; }; /** @@ -45,14 +50,14 @@ function formatTokenAmountWithMetadata( /** * Extracts permission details into a display-friendly format. - * Converts a permission (response or display-formatted) into a key-value object for UI rendering. + * Converts a permission (response or display-formatted) into a structured object with translated labels and formatted values. * Note: Token amounts (periodAmount, maxAmount) should be pre-formatted with metadata by formatPermissionWithTokenMetadata. * * @param permission - The permission to extract details from (should be pre-formatted with token metadata). - * @returns Object of permission details for display. + * @returns Object of permission details with label, value, and key for each field. */ function extractPermissionDetails( - permission: FormattedPermissionForDisplay, + permission: PermissionResponse, ): PermissionDetail { const details: PermissionDetail = {}; @@ -61,11 +66,12 @@ function extractPermissionDetails( // Extract chain information const chainMetadata = nameAndExplorerUrlByChainId[hexToNumber(permission.chainId)]; - if (chainMetadata) { - details[t('chainLabel')] = chainMetadata.name; - } else { - details[t('chainLabel')] = permission.chainId; - } + const chainLabel = t('chainLabel'); + const chainValue = chainMetadata ? chainMetadata.name : permission.chainId; + details.chainId = { + label: chainLabel, + value: chainValue, + }; // Extract permission details based on permission type const permissionData = permission.permission.data; @@ -73,14 +79,22 @@ function extractPermissionDetails( if (permissionData && typeof permissionData === 'object') { // For revocation-type permissions if (permissionType === 'erc20-token-revocation') { - details[t('revokeTokenApprovalsLabel')] = t('allTokens'); + const revokeLabel = t('revokeTokenApprovalsLabel'); + const revokeValue = t('allTokens'); + details.tokenApprovals = { + label: revokeLabel, + value: revokeValue, + }; // Add justification if available if ('justification' in permissionData) { const { justification } = permissionData; if (justification !== undefined && justification !== null) { - // eslint-disable-next-line @typescript-eslint/no-base-to-string -- display value from permission data - details[t('justificationLabel')] = String(justification); + const justificationLabel = t('justificationLabel'); + details.justification = { + label: justificationLabel, + value: String(justification), + }; } } } @@ -92,14 +106,18 @@ function extractPermissionDetails( const { periodAmount, periodDuration, justification } = permissionData; if (periodAmount !== undefined && periodAmount !== null) { + const amountLabel = t('amountLabel'); // periodAmount is already formatted with token metadata by formatPermissionWithTokenMetadata - // eslint-disable-next-line @typescript-eslint/no-base-to-string -- display value from permission data - details[t('amountLabel')] = String(periodAmount); + details.periodAmount = { + label: amountLabel, + value: String(periodAmount), + }; } if (periodDuration !== undefined && periodDuration !== null) { const timePeriod = getClosestTimePeriod(Number(periodDuration)); - details[t('periodDurationLabel')] = t( + const durationLabel = t('periodDurationLabel'); + const durationValue = t( timePeriod.toLowerCase() as | 'hourly' | 'daily' @@ -108,11 +126,18 @@ function extractPermissionDetails( | 'monthly' | 'yearly', ); + details.periodDuration = { + label: durationLabel, + value: durationValue, + }; } if (justification !== undefined && justification !== null) { - // eslint-disable-next-line @typescript-eslint/no-base-to-string -- display value from permission data - details[t('justificationLabel')] = String(justification); + const justificationLabel = t('justificationLabel'); + details.justification = { + label: justificationLabel, + value: String(justification), + }; } } // For stream-type permissions @@ -123,21 +148,32 @@ function extractPermissionDetails( const { maxAmount, startTime, justification } = permissionData; if (maxAmount !== undefined && maxAmount !== null) { + const maxAmountLabel = t('maxAmountLabel'); // maxAmount is already formatted with token metadata by formatPermissionWithTokenMetadata - // eslint-disable-next-line @typescript-eslint/no-base-to-string -- display value from permission data - details[t('maxAmountLabel')] = String(maxAmount); + details.maxAmount = { + label: maxAmountLabel, + value: String(maxAmount), + }; } if (startTime !== undefined && startTime !== null) { + const startTimeLabel = t('startTimeLabel'); const date = new Date(Number(startTime) * 1000); - details[t('startTimeLabel')] = date.toLocaleString(undefined, { + const startTimeValue = date.toLocaleString(undefined, { timeZone: 'UTC', }); + details.startTime = { + label: startTimeLabel, + value: startTimeValue, + }; } if (justification !== undefined && justification !== null) { - // eslint-disable-next-line @typescript-eslint/no-base-to-string -- display value from permission data - details[t('justificationLabel')] = String(justification); + const justificationLabel = t('justificationLabel'); + details.justification = { + label: justificationLabel, + value: String(justification), + }; } } } @@ -146,14 +182,15 @@ function extractPermissionDetails( } /** - * Converts existingPermissions array to an object keyed by CAIP-10 from address. - * Groups already-formatted permissions by account address. + * Converts permissions to an object keyed by CAIP-10 `from` address. + * Callers should pass responses that have been through {@link formatPermissionWithTokenMetadata} + * when token amounts should appear human-readable in the UI. * - * @param permissions - The display-formatted permissions to group. + * @param permissions - Permission responses to group (entries without `from`/`chainId` are skipped). * @returns Object with CAIP-10 addresses as keys and arrays of permission details as values. */ export function groupPermissionsByFromAddress( - permissions: FormattedPermissionForDisplay[], + permissions: PermissionResponse[], ): Record { const result: Record = {}; @@ -183,12 +220,13 @@ export function groupPermissionsByFromAddress( * * @param permission - The permission response to format. * @param tokenMetadataService - Service for fetching token metadata. - * @returns The permission with display-formatted token amounts; typed as FormattedPermissionForDisplay to prevent misuse. + * @returns The same permission shape with display-formatted token amounts in `data`; still typed as + * `PermissionResponse`, so treat `data` as UI-only after this call (not raw hex for on-chain math). */ export async function formatPermissionWithTokenMetadata( permission: PermissionResponse, tokenMetadataService: TokenMetadataService, -): Promise { +): Promise { const permissionData = permission.permission.data as Record; if (!permissionData || typeof permissionData !== 'object') { @@ -260,8 +298,14 @@ export async function formatPermissionWithTokenMetadata( data: formattedData, }, }; - } catch { - // If token metadata fetch fails, return original permission + } catch (error) { + logger.debug( + 'formatPermissionWithTokenMetadata: token metadata fetch failed, using raw permission data', + { + chainId: permission.chainId, + error: error instanceof Error ? error.message : error, + }, + ); return permission; } } diff --git a/packages/gator-permissions-snap/src/core/existingpermissions/types.ts b/packages/gator-permissions-snap/src/core/existingpermissions/types.ts index f85790d7..c1e0cc53 100644 --- a/packages/gator-permissions-snap/src/core/existingpermissions/types.ts +++ b/packages/gator-permissions-snap/src/core/existingpermissions/types.ts @@ -2,25 +2,12 @@ import type { PermissionResponse } from '@metamask/7715-permissions-shared/types import type { MessageKey } from '../../utils/i18n'; -/** - * Permission response with display-formatted fields (e.g. `maxAmount` as "1.5 ETH" instead of Hex). - * Use only for UI display. Do not pass to code that expects raw Hex (e.g. formatUnitsFromHex, hexToNumber). - */ -export type FormattedPermissionForDisplay = Omit< - PermissionResponse, - 'permission' -> & { - permission: Omit & { - data: Record; - }; -}; - /** * Configuration for displaying existing permissions. */ export type ExistingPermissionDisplayConfig = { /** The existing permissions to display (with formatted values for UI only) */ - existingPermissions: FormattedPermissionForDisplay[]; + existingPermissions: PermissionResponse[]; /** The translation key for the dialog title */ title: MessageKey; /** The translation key for the comparison description */ diff --git a/packages/gator-permissions-snap/src/core/permissionHandler.ts b/packages/gator-permissions-snap/src/core/permissionHandler.ts index 8457db4a..b2a0402d 100644 --- a/packages/gator-permissions-snap/src/core/permissionHandler.ts +++ b/packages/gator-permissions-snap/src/core/permissionHandler.ts @@ -30,9 +30,11 @@ import type { AccountUpgradeStatus, } from './accountController'; import { getChainMetadata } from './chainMetadata'; +import { EXISTING_PERMISSIONS_CONFIRM_BUTTON } from './existingpermissions'; import { ACCOUNT_SELECTOR_NAME, PermissionHandlerContent, + SHOW_EXISTING_PERMISSIONS_BUTTON_NAME, SkeletonPermissionHandlerContent, } from './permissionHandlerContent'; import type { PermissionRequestLifecycleOrchestrator } from './permissionRequestLifecycleOrchestrator'; @@ -51,6 +53,7 @@ import { logger } from '../../../shared/src/utils/logger'; import { createCancellableOperation } from '../utils/cancellableOperation'; import type { MessageKey } from '../utils/i18n'; import { formatUnits } from '../utils/value'; +import type { ExistingPermissionsState } from './existingpermissions/existingPermissionsState'; export const JUSTIFICATION_SHOW_MORE_BUTTON_NAME = 'show-more-justification'; @@ -217,6 +220,8 @@ export class PermissionHandler< chainId, scanDappUrlResult, scanAddressResult, + existingPermissionsStatus, + isGrantDisabled, }: { context: TContext; metadata: TMetadata; @@ -224,6 +229,8 @@ export class PermissionHandler< chainId: number; scanDappUrlResult: ScanDappUrlResult | null; scanAddressResult: FetchAddressScanResult | null; + existingPermissionsStatus: ExistingPermissionsState; + isGrantDisabled: boolean; }): Promise => { const { name: networkName, explorerUrl } = getChainMetadata({ chainId }); @@ -264,6 +271,8 @@ export class PermissionHandler< chainId, explorerUrl, isAccountUpgraded: this.#accountUpgradeStatus.isUpgraded, + existingPermissionsStatus, + isGrantDisabled, }); }; @@ -418,6 +427,34 @@ export class PermissionHandler< }, }); + const { unbind: unbindShowExistingPermissionsButtonClick } = + this.#userEventDispatcher.on({ + elementName: SHOW_EXISTING_PERMISSIONS_BUTTON_NAME, + eventType: UserInputEventType.ButtonClickEvent, + interfaceId, + handler: async () => { + currentContext = { + ...currentContext, + showExistingPermissions: true, + }; + await rerender(); + }, + }); + + const { unbind: unbindExistingPermissionsConfirmButtonClick } = + this.#userEventDispatcher.on({ + elementName: EXISTING_PERMISSIONS_CONFIRM_BUTTON, + eventType: UserInputEventType.ButtonClickEvent, + interfaceId, + handler: async () => { + currentContext = { + ...currentContext, + showExistingPermissions: false, + }; + await rerender(); + }, + }); + const unbindRuleHandlers = bindRuleHandlers({ rules: this.#rules, userEventDispatcher: this.#userEventDispatcher, @@ -433,6 +470,8 @@ export class PermissionHandler< unbindRuleHandlers(); unbindShowMoreButtonClick(); unbindAccountSelected(); + unbindShowExistingPermissionsButtonClick(); + unbindExistingPermissionsConfirmButtonClick(); }; }; diff --git a/packages/gator-permissions-snap/src/core/permissionHandlerContent.tsx b/packages/gator-permissions-snap/src/core/permissionHandlerContent.tsx index c1928d7a..1231d2e3 100644 --- a/packages/gator-permissions-snap/src/core/permissionHandlerContent.tsx +++ b/packages/gator-permissions-snap/src/core/permissionHandlerContent.tsx @@ -7,9 +7,14 @@ import { Text, Skeleton, AccountSelector, + Banner, + Button, + Container, + Footer, } from '@metamask/snaps-sdk/jsx'; import { parseCaipAssetType } from '@metamask/utils'; +import { ConfirmationDialog } from './confirmation'; import { JUSTIFICATION_SHOW_MORE_BUTTON_NAME } from './permissionHandler'; import type { BaseContext, IconData } from './types'; import { @@ -31,8 +36,11 @@ import { } from '../ui/components'; import type { MessageKey } from '../utils/i18n'; import { t } from '../utils/i18n'; +import { ExistingPermissionsState } from './existingpermissions/existingPermissionsState'; export const ACCOUNT_SELECTOR_NAME = 'account-selector'; +export const SHOW_EXISTING_PERMISSIONS_BUTTON_NAME = + 'show-existing-permissions-button'; export type PermissionHandlerContentProps = { children: SnapElement; @@ -55,6 +63,9 @@ export type PermissionHandlerContentProps = { chainId: number; explorerUrl: string | undefined; isAccountUpgraded: boolean; + existingPermissionsStatus: ExistingPermissionsState; + /** When true, the primary grant button is not clickable. */ + isGrantDisabled: boolean; }; /** @@ -78,6 +89,8 @@ export type PermissionHandlerContentProps = { * @param options.chainId - The chain ID of the network. * @param options.explorerUrl - The URL of the block explorer for the token. * @param options.isAccountUpgraded - Whether the account is upgraded to a smart account. + * @param options.existingPermissionsStatus - Status of existing permissions for banner UI. + * @param options.isGrantDisabled - Whether the grant button should render disabled. * @returns The confirmation content. */ export const PermissionHandlerContent = ({ @@ -99,6 +112,8 @@ export const PermissionHandlerContent = ({ chainId, explorerUrl, isAccountUpgraded, + existingPermissionsStatus, + isGrantDisabled, }: PermissionHandlerContentProps): SnapElement => { const tokenBalanceComponent = TokenBalanceField({ tokenBalance, @@ -175,74 +190,106 @@ export const PermissionHandlerContent = ({ ); return ( - - - - {t(permissionTitle)} - {t(permissionSubtitle)} - -
- - + + + + + {t(permissionTitle)} + {t(permissionSubtitle)} + +
+ + + + {t('accountLabel')} + + + + + {!isAccountUpgraded && ( + + {t('accountUpgradeWarning')} + + )} + {hasAsset && ( + + {fiatBalanceComponent} + {tokenBalanceComponent} + + )} + +
+ {existingPermissionsStatus === + ExistingPermissionsState.SimilarPermissions && ( + + {t('existingPermissionsSimilarMessage')} + + + )} + {existingPermissionsStatus === + ExistingPermissionsState.DissimilarPermissions && ( + + {t('existingPermissionsExistingMessage')} + + + )} +
+ - {t('accountLabel')} - + {t('justificationLabel')} + + - +
+ {fromField} + {addressField} + - {!isAccountUpgraded && ( - - {t('accountUpgradeWarning')} - - )} {hasAsset && ( - - {fiatBalanceComponent} - {tokenBalanceComponent} - + )} - -
-
- - - {t('justificationLabel')} - - - - -
-
- {fromField} - {addressField} - - {hasAsset && ( - - )} -
- {children} +
+ {children} +
-
+
+ + +
+ ); }; @@ -254,49 +301,66 @@ export const SkeletonPermissionHandlerContent = ({ permissionSubtitle: MessageKey; }): JSX.Element => { return ( - - - - {t(permissionTitle)} - {t(permissionSubtitle)} - -
- - - - {t('accountLabel')} - + + + + + {t(permissionTitle)} + {t(permissionSubtitle)} + +
+ + + + {t('accountLabel')} + + + +
+
+ +
+
+ + + + +
+
- -
-
- -
-
- - - - -
-
- - -
+ +
+
-
+
+ + +
+ ); }; diff --git a/packages/gator-permissions-snap/src/core/permissionRequestLifecycleOrchestrator.ts b/packages/gator-permissions-snap/src/core/permissionRequestLifecycleOrchestrator.ts index 1c6cf294..ac026a26 100644 --- a/packages/gator-permissions-snap/src/core/permissionRequestLifecycleOrchestrator.ts +++ b/packages/gator-permissions-snap/src/core/permissionRequestLifecycleOrchestrator.ts @@ -43,6 +43,7 @@ import type { } from '../clients/trustSignalsClient'; import type { NonceCaveatService } from '../services/nonceCaveatService'; import type { SnapsMetricsService } from '../services/snapsMetricsService'; +import { ExistingPermissionsState } from './existingpermissions/existingPermissionsState'; /** * Orchestrator for the permission request lifecycle. @@ -161,11 +162,32 @@ export class PermissionRequestLifecycleOrchestrator { const dialogInterface = this.#dialogInterfaceFactory.createDialogInterface(); - // Start loading existing permissions in the background before showing introduction - // This way if the introduction is shown, the existing permissions will already be loaded - const existingPermissionsPromise = + // One profile-sync read per permission request: same snapshot drives the banner and the + // "review existing" list. getExistingPermissions() already resolves to [] on failure (never rejects). + const existingPermissionsForOriginPromise = this.#existingPermissionsService.getExistingPermissions(origin); + // Derive banner state from that snapshot in parallel with the introduction flow. + // Chain .catch() so the promise never rejects: if the user cancels at intro we return + // without awaiting it, and any processing error should not cause an unhandled rejection. + const existingPermissionsStatusPromise = existingPermissionsForOriginPromise + .then((list) => + this.#existingPermissionsService.getExistingPermissionsStatusFromList( + list, + validatedPermissionRequest.permission, + ), + ) + .catch((error: unknown) => { + logger.error( + 'PermissionRequestLifecycleOrchestrator: existing permissions status from snapshot failed', + { + origin, + error: error instanceof Error ? error.message : error, + }, + ); + return ExistingPermissionsState.None; + }); + // Check if we need to show introduction if ( await this.#permissionIntroductionService.shouldShowIntroduction( @@ -198,32 +220,6 @@ export class PermissionRequestLifecycleOrchestrator { ); } - // Await the existing permissions that were started loading earlier - const existingPermissions = await existingPermissionsPromise; - - if (existingPermissions?.length > 0) { - const { wasCancelled } = - await this.#existingPermissionsService.showExistingPermissions({ - dialogInterface, - existingPermissions, - }); - - // If user cancelled the existing permissions dialog, reject the permission request - if (wasCancelled) { - await this.#snapsMetricsService.trackPermissionRejected({ - origin, - permissionType, - chainId: permissionRequest.chainId, - permissionData: permissionRequest.permission.data, - }); - - return { - approved: false, - reason: 'Permission request denied at existing permissions screen', - }; - } - } - // only necessary when not pre-installed, to ensure that the account // permissions are requested before the confirmation dialog is shown. await this.#accountController.getAccountAddresses(); @@ -278,6 +274,9 @@ export class PermissionRequestLifecycleOrchestrator { let scanDappUrlResult: ScanDappUrlResult | null = null; let scanAddressResult: FetchAddressScanResult | null = null; + /** Avoid re-running skeleton + format when trust-signal refreshes fire while the subview is open. */ + let existingPermissionsSubviewActive = false; + const updateConfirmation = async ({ newContext, isGrantDisabled, @@ -292,19 +291,37 @@ export class PermissionRequestLifecycleOrchestrator { const metadata = await lifecycleHandlers.deriveMetadata({ context }); - const ui = await lifecycleHandlers.createConfirmationContent({ - context, - metadata, - origin, - chainId, - scanDappUrlResult, - scanAddressResult, - }); + const existingPermissionsStatus = + await existingPermissionsStatusPromise; + + const grantDisabled = isGrantDisabled || hasValidationErrors(metadata); + + if (context.showExistingPermissions) { + if (!existingPermissionsSubviewActive) { + // Set synchronously before awaits so eslint require-atomic-updates is satisfied; + // runUpdate calls are serialized via lastUpdateConfirmationPromise. + existingPermissionsSubviewActive = true; + const snapshot = await existingPermissionsForOriginPromise; + await this.#existingPermissionsService.showExistingPermissions( + dialogInterface, + snapshot, + ); + } + } else { + existingPermissionsSubviewActive = false; + const ui = await lifecycleHandlers.createConfirmationContent({ + context, + metadata, + origin, + chainId, + scanDappUrlResult, + scanAddressResult, + existingPermissionsStatus, + isGrantDisabled: grantDisabled, + }); - await confirmationDialog.updateContent({ - ui, - isGrantDisabled: isGrantDisabled || hasValidationErrors(metadata), - }); + await confirmationDialog.updateContent({ ui }); + } }; lastUpdateConfirmationPromise = diff --git a/packages/gator-permissions-snap/src/core/types.ts b/packages/gator-permissions-snap/src/core/types.ts index d8a4c981..5e0d713a 100644 --- a/packages/gator-permissions-snap/src/core/types.ts +++ b/packages/gator-permissions-snap/src/core/types.ts @@ -20,6 +20,7 @@ import type { PermissionRequestLifecycleOrchestrator } from './permissionRequest import type { TimeoutFactory } from './timeoutFactory'; import type { TokenPricesService } from '../services/tokenPricesService'; import type { MessageKey } from '../utils/i18n'; +import type { ExistingPermissionsState } from './existingpermissions/existingPermissionsState'; /** * Represents the result of a permission request. @@ -57,6 +58,11 @@ export type BaseContext = { symbol: string; iconDataBase64: string | null; }; + /** + * When true, the confirmation UI shows stored permissions for this site instead of the grant form. + * Omitted or falsey means the normal confirmation content. Set by the permission handler when the user opens the existing-permissions view. + */ + showExistingPermissions?: boolean | null; }; export type BaseMetadata = { @@ -144,6 +150,9 @@ export type LifecycleOrchestrationHandlers< chainId: number; scanDappUrlResult: ScanDappUrlResult | null; scanAddressResult: FetchAddressScanResult | null; + existingPermissionsStatus: ExistingPermissionsState; + /** Whether the grant control should render disabled (validation + caller intent). */ + isGrantDisabled: boolean; }) => Promise; applyContext: (args: { context: TContext; @@ -163,7 +172,6 @@ export type LifecycleOrchestrationHandlers< * @param confirmationCreatedArgs.interfaceId - The interface ID for the confirmation dialog * @param confirmationCreatedArgs.initialContext - The initial context for the confirmation dialog * @param confirmationCreatedArgs.updateContext - Function to update the context of the confirmation dialog - * @param confirmationCreatedArgs.isAdjustmentAllowed - Whether adjustments to the confirmation dialog are allowed */ onConfirmationCreated?: (confirmationCreatedArgs: { interfaceId: string; diff --git a/packages/gator-permissions-snap/src/index.ts b/packages/gator-permissions-snap/src/index.ts index 0ce451e9..74a922f7 100644 --- a/packages/gator-permissions-snap/src/index.ts +++ b/packages/gator-permissions-snap/src/index.ts @@ -191,7 +191,6 @@ const permissionIntroductionService = new PermissionIntroductionService({ const existingPermissionsService = new ExistingPermissionsService({ profileSyncManager, - userEventDispatcher, tokenMetadataService, }); diff --git a/packages/gator-permissions-snap/src/ui/components/PermissionCard.tsx b/packages/gator-permissions-snap/src/ui/components/PermissionCard.tsx index 734b1495..45b12483 100644 --- a/packages/gator-permissions-snap/src/ui/components/PermissionCard.tsx +++ b/packages/gator-permissions-snap/src/ui/components/PermissionCard.tsx @@ -1,4 +1,4 @@ -import { Box, Divider, Text } from '@metamask/snaps-sdk/jsx'; +import { Box, Section, Text } from '@metamask/snaps-sdk/jsx'; import type { PermissionDetail } from '../../core/existingpermissions/permissionFormatter'; @@ -23,18 +23,24 @@ export const PermissionCard = ({ index, }: PermissionCardProps): JSX.Element => { return ( - - {index > 0 && } - {Object.entries(detail).map(([label, value]) => ( - - {label}: - {value} - - ))} - +
+ {Object.entries(detail).map(([key, item]) => + key === 'justification' ? ( + + {item.label}: + {item.value} + + ) : ( + + {item.label}: + {item.value} + + ), + )} +
); }; diff --git a/packages/gator-permissions-snap/test/core/confirmation.test.ts b/packages/gator-permissions-snap/test/core/confirmation.test.ts index 69a07336..bf1fa223 100644 --- a/packages/gator-permissions-snap/test/core/confirmation.test.ts +++ b/packages/gator-permissions-snap/test/core/confirmation.test.ts @@ -324,7 +324,6 @@ describe('ConfirmationDialog', () => { await confirmationDialog.updateContent({ ui: updatedUi, - isGrantDisabled: false, }); expect(mockSnapsProvider.request).toHaveBeenCalledWith({ diff --git a/packages/gator-permissions-snap/test/core/existingPermissions/existingPermissionsService.test.ts b/packages/gator-permissions-snap/test/core/existingPermissions/existingPermissionsService.test.ts index c0c2fd20..dbf40719 100644 --- a/packages/gator-permissions-snap/test/core/existingPermissions/existingPermissionsService.test.ts +++ b/packages/gator-permissions-snap/test/core/existingPermissions/existingPermissionsService.test.ts @@ -1,11 +1,22 @@ -import { describe, it, beforeEach, expect, jest } from '@jest/globals'; -import { UserInputEventType } from '@metamask/snaps-sdk'; +import { + describe, + it, + beforeEach, + afterEach, + expect, + jest, +} from '@jest/globals'; +import type { Permission } from '@metamask/7715-permissions-shared/types'; +import { logger } from '@metamask/7715-permissions-shared/utils'; import type { Hex } from '@metamask/utils'; import { bytesToHex } from '@metamask/utils'; import type { TokenBalanceAndMetadata } from '../../../src/clients/types'; import type { DialogInterface } from '../../../src/core/dialogInterface'; -import { ExistingPermissionsService } from '../../../src/core/existingpermissions/existingPermissionsService'; +import { + ExistingPermissionsService, + ExistingPermissionsState, +} from '../../../src/core/existingpermissions/existingPermissionsService'; import type { ProfileSyncManager, StoredGrantedPermission, @@ -14,7 +25,6 @@ import type { TokenMetadata, TokenMetadataService, } from '../../../src/services/tokenMetadataService'; -import type { UserEventDispatcher } from '../../../src/userEventDispatcher'; // Helper to generate random addresses const randomAddress = (): Hex => { @@ -55,26 +65,17 @@ const createMockStoredPermission = ( describe('ExistingPermissionsService', () => { let service: ExistingPermissionsService; let mockProfileSyncManager: jest.Mocked; - let mockUserEventDispatcher: jest.Mocked; let mockTokenMetadataService: jest.Mocked; - let mockDialogInterface: jest.Mocked; beforeEach(() => { jest.clearAllMocks(); + jest.spyOn(logger, 'error').mockImplementation(() => undefined); // Setup mock ProfileSyncManager mockProfileSyncManager = { getAllGrantedPermissions: jest.fn(), } as unknown as jest.Mocked; - // Setup mock UserEventDispatcher - mockUserEventDispatcher = { - on: jest.fn().mockReturnValue({ - unbind: jest.fn(), - dispatcher: {} as any, - }), - } as unknown as jest.Mocked; - // Setup mock TokenMetadataService - formatPermissionWithTokenMetadata uses getTokenMetadata const tokenMetadata: TokenMetadata = { decimals: 18, symbol: 'ETH' }; mockTokenMetadataService = { @@ -89,20 +90,17 @@ describe('ExistingPermissionsService', () => { }), } as unknown as jest.Mocked; - // Setup mock DialogInterface - mockDialogInterface = { - show: jest.fn(async (_ui: any, _onClose?: () => void) => 'interface-id'), - interfaceId: 'interface-id', - } as unknown as jest.Mocked; - // Create service with mocks service = new ExistingPermissionsService({ profileSyncManager: mockProfileSyncManager, - userEventDispatcher: mockUserEventDispatcher, tokenMetadataService: mockTokenMetadataService, }); }); + afterEach(() => { + jest.restoreAllMocks(); + }); + describe('getExistingPermissions()', () => { it('should return all permissions for origin across all chains', async () => { // Setup: permissions on chains A, B, C @@ -188,205 +186,300 @@ describe('ExistingPermissionsService', () => { // Assert: returns empty array instead of throwing expect(result).toStrictEqual([]); + expect(logger.error).toHaveBeenCalledWith( + 'ExistingPermissionsService.getExistingPermissions() failed', + expect.objectContaining({ + siteOrigin: 'https://example.com', + error: 'Storage error', + }), + ); }); }); - describe('showExistingPermissions()', () => { - it('should return early if no existing permissions', async () => { - // Action - const result = await service.showExistingPermissions({ - dialogInterface: mockDialogInterface, - existingPermissions: undefined, - }); + describe('getExistingPermissionsStatus()', () => { + it('should return None when no existing permissions exist', async () => { + mockProfileSyncManager.getAllGrantedPermissions.mockResolvedValue([]); - // Assert - expect(result).toStrictEqual({ wasCancelled: false }); - expect(mockDialogInterface.show).not.toHaveBeenCalled(); - }); + const requestedPermission: Permission = { + type: 'erc20-token-stream', + data: {}, + isAdjustmentAllowed: true, + }; - it('should return early if empty permission array', async () => { - // Action - const result = await service.showExistingPermissions({ - dialogInterface: mockDialogInterface, - existingPermissions: [], - }); + const status = await service.getExistingPermissionsStatus( + 'https://example.com', + requestedPermission, + ); - // Assert - expect(result).toStrictEqual({ wasCancelled: false }); - expect(mockDialogInterface.show).not.toHaveBeenCalled(); + expect(status).toBe(ExistingPermissionsState.None); }); - it('should return early if all permissions are invalid', async () => { - const invalidPermission1 = createMockStoredPermission(); - invalidPermission1.permissionResponse.from = undefined as any; + it('should return SimilarPermissions when matching categories exist', async () => { + const permission = createMockStoredPermission(); + permission.permissionResponse.permission = { + type: 'erc20-token-stream', + data: {}, + isAdjustmentAllowed: true, + }; + + mockProfileSyncManager.getAllGrantedPermissions.mockResolvedValue([ + permission, + ]); - const invalidPermission2 = createMockStoredPermission(); - invalidPermission2.permissionResponse.chainId = undefined as any; + const requestedPermission: Permission = { + type: 'native-token-stream', + data: {}, + isAdjustmentAllowed: true, + }; - // Action - const result = await service.showExistingPermissions({ - dialogInterface: mockDialogInterface, - existingPermissions: [invalidPermission1, invalidPermission2], - }); - - // Assert: returns early without showing dialog - expect(result).toStrictEqual({ wasCancelled: false }); - expect(mockDialogInterface.show).not.toHaveBeenCalled(); + const status = await service.getExistingPermissionsStatus( + 'https://example.com', + requestedPermission, + ); + + expect(status).toBe(ExistingPermissionsState.SimilarPermissions); }); - it('should show skeleton immediately then update with real content', async () => { + it('should return DissimilarPermissions when categories do not match', async () => { const permission = createMockStoredPermission(); + permission.permissionResponse.permission = { + type: 'erc20-token-stream', + data: {}, + isAdjustmentAllowed: true, + }; - // Mock dialog.show to capture the callback and resolve immediately - mockDialogInterface.show.mockImplementation(async (_ui, _onClose) => { - return 'interface-id'; - }); + mockProfileSyncManager.getAllGrantedPermissions.mockResolvedValue([ + permission, + ]); - // Action - const resultPromise = service.showExistingPermissions({ - dialogInterface: mockDialogInterface, - existingPermissions: [permission], - }); + const requestedPermission: Permission = { + type: 'native-token-periodic', + data: {}, + isAdjustmentAllowed: true, + }; + + const status = await service.getExistingPermissionsStatus( + 'https://example.com', + requestedPermission, + ); - // Allow async operations to complete - await new Promise((resolve) => setTimeout(resolve, 50)); + expect(status).toBe(ExistingPermissionsState.DissimilarPermissions); + }); - // Assert: skeleton should be shown, then updated with real content - expect(mockDialogInterface.show).toHaveBeenCalledTimes(2); + it('should not treat types that merely contain "stream" as stream category', async () => { + const permission = createMockStoredPermission(); + permission.permissionResponse.permission = { + type: 'custom-streaming-permission', + data: {}, + isAdjustmentAllowed: true, + }; - // Get both calls - const firstCall = mockDialogInterface.show.mock.calls[0]; - const secondCall = mockDialogInterface.show.mock.calls[1]; + mockProfileSyncManager.getAllGrantedPermissions.mockResolvedValue([ + permission, + ]); - expect(firstCall).toBeDefined(); - expect(secondCall).toBeDefined(); + const requestedPermission: Permission = { + type: 'native-token-stream', + data: {}, + isAdjustmentAllowed: true, + }; - // Clean up: trigger dialog close to resolve the promise - const onCloseCallback = firstCall?.[1]; - onCloseCallback?.(); + const status = await service.getExistingPermissionsStatus( + 'https://example.com', + requestedPermission, + ); - await resultPromise; + expect(status).toBe(ExistingPermissionsState.DissimilarPermissions); }); - it('should call getTokenMetadata when permission has token amount fields', async () => { - const permission = createMockStoredPermission(); - permission.permissionResponse.permission = { + it('should return None on error', async () => { + mockProfileSyncManager.getAllGrantedPermissions.mockRejectedValue( + new Error('Storage error'), + ); + + const requestedPermission: Permission = { type: 'erc20-token-stream', - data: { - maxAmount: '0xde0b6b3a7640000' as Hex, // 1 ETH in hex - startTime: Math.floor(Date.now() / 1000), - }, + data: {}, isAdjustmentAllowed: true, }; - mockDialogInterface.show.mockResolvedValue('interface-id'); + const status = await service.getExistingPermissionsStatus( + 'https://example.com', + requestedPermission, + ); - const resultPromise = service.showExistingPermissions({ - dialogInterface: mockDialogInterface, - existingPermissions: [permission], - }); + expect(status).toBe(ExistingPermissionsState.None); + }); - await new Promise((resolve) => setTimeout(resolve, 50)); + it('should call profile sync only once', async () => { + mockProfileSyncManager.getAllGrantedPermissions.mockResolvedValue([]); - expect(mockTokenMetadataService.getTokenMetadata).toHaveBeenCalledWith( - expect.objectContaining({ - chainId: 1, - account: permission.permissionResponse.from, - }), - ); + const requestedPermission: Permission = { + type: 'erc20-token-stream', + data: {}, + isAdjustmentAllowed: true, + }; - const onCloseCallback = mockDialogInterface.show.mock.calls[0]?.[1]; - onCloseCallback?.(); + await service.getExistingPermissionsStatus( + 'https://example.com', + requestedPermission, + ); - await resultPromise; + expect( + mockProfileSyncManager.getAllGrantedPermissions, + ).toHaveBeenCalledTimes(1); }); + }); - it('should filter out invalid permissions from display', async () => { - const validPermission = createMockStoredPermission(); - const invalidPermissionNoFrom = { ...createMockStoredPermission() }; - invalidPermissionNoFrom.permissionResponse.from = undefined as any; + describe('getExistingPermissionsStatusFromList()', () => { + it('returns None when list is empty', () => { + const requestedPermission: Permission = { + type: 'erc20-token-stream', + data: {}, + isAdjustmentAllowed: true, + }; - mockDialogInterface.show.mockResolvedValue('interface-id'); + expect( + service.getExistingPermissionsStatusFromList([], requestedPermission), + ).toBe(ExistingPermissionsState.None); + }); - // Action - const resultPromise = service.showExistingPermissions({ - dialogInterface: mockDialogInterface, - existingPermissions: [validPermission, invalidPermissionNoFrom], - }); + it('returns SimilarPermissions when matching categories exist', () => { + const permission = createMockStoredPermission(); + permission.permissionResponse.permission = { + type: 'erc20-token-stream', + data: {}, + isAdjustmentAllowed: true, + }; - // Allow async operations - await new Promise((resolve) => setTimeout(resolve, 50)); + const requestedPermission: Permission = { + type: 'native-token-stream', + data: {}, + isAdjustmentAllowed: true, + }; - // Assert: dialog was shown (skeleton + real content attempt) - expect(mockDialogInterface.show).toHaveBeenCalled(); + expect( + service.getExistingPermissionsStatusFromList( + [permission], + requestedPermission, + ), + ).toBe(ExistingPermissionsState.SimilarPermissions); + }); - // Trigger dialog close - const onCloseCallback = mockDialogInterface.show.mock.calls[0]?.[1]; - onCloseCallback?.(); + it('returns DissimilarPermissions when categories do not match', () => { + const permission = createMockStoredPermission(); + permission.permissionResponse.permission = { + type: 'erc20-token-stream', + data: {}, + isAdjustmentAllowed: true, + }; - await resultPromise; + const requestedPermission: Permission = { + type: 'native-token-periodic', + data: {}, + isAdjustmentAllowed: true, + }; + + expect( + service.getExistingPermissionsStatusFromList( + [permission], + requestedPermission, + ), + ).toBe(ExistingPermissionsState.DissimilarPermissions); }); - it('should register button handler for user confirmation', async () => { + it('does not treat types that merely contain "stream" as stream category', () => { const permission = createMockStoredPermission(); + permission.permissionResponse.permission = { + type: 'custom-streaming-permission', + data: {}, + isAdjustmentAllowed: true, + }; - mockDialogInterface.show.mockResolvedValue('interface-id'); + const requestedPermission: Permission = { + type: 'native-token-stream', + data: {}, + isAdjustmentAllowed: true, + }; - // Action - const resultPromise = service.showExistingPermissions({ - dialogInterface: mockDialogInterface, - existingPermissions: [permission], - }); + expect( + service.getExistingPermissionsStatusFromList( + [permission], + requestedPermission, + ), + ).toBe(ExistingPermissionsState.DissimilarPermissions); + }); + }); - // Allow async operations - await new Promise((resolve) => setTimeout(resolve, 50)); + describe('showExistingPermissions()', () => { + it('shows skeleton then list without calling profile sync', async () => { + const show = jest.fn().mockResolvedValue(undefined); + const dialogInterface = { show } as unknown as DialogInterface; - // Assert: button handler was registered - expect(mockUserEventDispatcher.on).toHaveBeenCalledWith( - expect.objectContaining({ - eventType: UserInputEventType.ButtonClickEvent, - }), - ); + await service.showExistingPermissions(dialogInterface, [ + createMockStoredPermission(), + ]); - // Clean up - const onCloseCallback = mockDialogInterface.show.mock.calls[0]?.[1]; - onCloseCallback?.(); + expect(show).toHaveBeenCalledTimes(2); + expect( + mockProfileSyncManager.getAllGrantedPermissions, + ).not.toHaveBeenCalled(); + }); + + it('shows fallback UI when formatting fails', async () => { + const show = jest.fn().mockResolvedValue(undefined); + const dialogInterface = { show } as unknown as DialogInterface; + const spy = jest + .spyOn(service, 'createExistingPermissionsContent') + .mockRejectedValueOnce(new Error('format failed')); + + await service.showExistingPermissions(dialogInterface, [ + createMockStoredPermission(), + ]); - await resultPromise; + expect(show).toHaveBeenCalledTimes(2); + expect(logger.error).toHaveBeenCalled(); + spy.mockRestore(); }); + }); - it('should handle errors gracefully without crashing', async () => { + describe('createExistingPermissionsContent()', () => { + it('should format permissions and build content', async () => { const permission = createMockStoredPermission(); + + const content = await service.createExistingPermissionsContent([ + permission, + ]); + + expect(content).toBeDefined(); + expect(mockTokenMetadataService.getTokenMetadata).not.toHaveBeenCalled(); + }); + + it('should call getTokenMetadata when permission has token amount fields', async () => { + const from = randomAddress(); + const permission = createMockStoredPermission('0x1', from); permission.permissionResponse.permission = { type: 'erc20-token-stream', data: { - maxAmount: '0xde0b6b3a7640000' as Hex, - startTime: Math.floor(Date.now() / 1000), + maxAmount: '0xde0b6b3a7640000', + tokenAddress: randomAddress(), + justification: 'test', }, isAdjustmentAllowed: true, }; - mockTokenMetadataService.getTokenMetadata.mockRejectedValue( - new Error('Token metadata fetch failed'), - ); - mockDialogInterface.show.mockResolvedValue('interface-id'); - - // Action - should not throw when getTokenMetadata fails (dialog stays with skeleton) - const resultPromise = service.showExistingPermissions({ - dialogInterface: mockDialogInterface, - existingPermissions: [permission], - }); + const content = await service.createExistingPermissionsContent([ + permission, + ]); - // Allow async operations - await new Promise((resolve) => setTimeout(resolve, 50)); + expect(content).toBeDefined(); + expect(mockTokenMetadataService.getTokenMetadata).toHaveBeenCalled(); + }); - // Trigger dialog close - const onCloseCallback = mockDialogInterface.show.mock.calls[0]?.[1]; - onCloseCallback?.(); + it('should handle empty permission array', async () => { + const content = await service.createExistingPermissionsContent([]); - // Assert: should complete without throwing - const result = await resultPromise; - expect(result).toStrictEqual(expect.objectContaining({})); + expect(content).toBeDefined(); }); }); }); diff --git a/packages/gator-permissions-snap/test/core/existingPermissions/permissionFormatter.test.ts b/packages/gator-permissions-snap/test/core/existingPermissions/permissionFormatter.test.ts new file mode 100644 index 00000000..a3b7396e --- /dev/null +++ b/packages/gator-permissions-snap/test/core/existingPermissions/permissionFormatter.test.ts @@ -0,0 +1,211 @@ +import { + describe, + it, + expect, + jest, + beforeEach, + afterEach, +} from '@jest/globals'; +import type { PermissionResponse } from '@metamask/7715-permissions-shared/types'; +import { logger } from '@metamask/7715-permissions-shared/utils'; +import type { Hex } from '@metamask/utils'; + +import { + formatPermissionWithTokenMetadata, + groupPermissionsByFromAddress, +} from '../../../src/core/existingpermissions/permissionFormatter'; +import { DEFAULT_MAX_AMOUNT } from '../../../src/permissions/erc20TokenStream/context'; +import type { TokenMetadataService } from '../../../src/services/tokenMetadataService'; + +const fromA = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; +const fromB = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex; +const chainId = '0x1' as Hex; + +const basePermission = ( + overrides: Partial, +): PermissionResponse => + ({ + chainId, + from: fromA, + to: '0xcccccccccccccccccccccccccccccccccccccccc' as Hex, + context: '0x', + dependencies: [], + delegationManager: '0xdddddddddddddddddddddddddddddddddddddddd' as Hex, + permission: { + type: 'erc20-token-stream', + data: {}, + isAdjustmentAllowed: true, + }, + rules: [], + ...overrides, + }) as PermissionResponse; + +describe('groupPermissionsByFromAddress', () => { + it('groups permissions by CAIP-10 from address', () => { + const p1 = basePermission({ + permission: { + type: 'erc20-token-stream', + data: { justification: 'a' }, + isAdjustmentAllowed: true, + }, + }); + const p2 = basePermission({ + from: fromB, + permission: { + type: 'native-token-stream', + data: { justification: 'b' }, + isAdjustmentAllowed: true, + }, + }); + const p3 = basePermission({ + permission: { + type: 'erc20-token-periodic', + data: { justification: 'c' }, + isAdjustmentAllowed: true, + }, + }); + + const grouped = groupPermissionsByFromAddress([p1, p2, p3]); + + expect(Object.keys(grouped)).toHaveLength(2); + expect(grouped[fromA]).toHaveLength(2); + expect(grouped[fromB]).toHaveLength(1); + }); + + it('skips entries without from or chainId', () => { + const valid = basePermission({}); + const missingFrom = basePermission({ from: undefined as unknown as Hex }); + const missingChain = basePermission({ + chainId: undefined as unknown as Hex, + }); + + const grouped = groupPermissionsByFromAddress([ + valid, + missingFrom, + missingChain, + ]); + + expect(Object.keys(grouped)).toStrictEqual([fromA]); + }); +}); + +describe('formatPermissionWithTokenMetadata', () => { + let mockTokenMetadataService: jest.Mocked< + Pick + >; + + beforeEach(() => { + jest.spyOn(logger, 'debug').mockImplementation(() => undefined); + const getTokenMetadata = jest + .fn() + .mockResolvedValue({ decimals: 18, symbol: 'ETH' }); + mockTokenMetadataService = { + getTokenMetadata: getTokenMetadata as jest.MockedFunction< + TokenMetadataService['getTokenMetadata'] + >, + }; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns the permission unchanged when data has no token amount fields', async () => { + const permission = basePermission({ + permission: { + type: 'erc20-token-revocation', + data: { justification: 'x' }, + isAdjustmentAllowed: true, + }, + }); + + const result = await formatPermissionWithTokenMetadata( + permission, + mockTokenMetadataService as unknown as TokenMetadataService, + ); + + expect(result).toStrictEqual(permission); + expect(mockTokenMetadataService.getTokenMetadata).not.toHaveBeenCalled(); + }); + + it('formats maxAmount using token metadata', async () => { + const permission = basePermission({ + permission: { + type: 'erc20-token-stream', + data: { + maxAmount: '0x38d7ea4c68000', + tokenAddress: '0x0000000000000000000000000000000000000001', + justification: 'j', + }, + isAdjustmentAllowed: true, + }, + }); + + const result = await formatPermissionWithTokenMetadata( + permission, + mockTokenMetadataService as unknown as TokenMetadataService, + ); + + expect(mockTokenMetadataService.getTokenMetadata).toHaveBeenCalledWith( + expect.objectContaining({ + chainId: 1, + account: fromA, + assetAddress: '0x0000000000000000000000000000000000000001', + }), + ); + expect(result.permission.data).toMatchObject({ + maxAmount: expect.stringContaining('ETH') as unknown, + }); + }); + + it('maps DEFAULT_MAX_AMOUNT maxAmount to unlimited label', async () => { + const permission = basePermission({ + permission: { + type: 'erc20-token-stream', + data: { + maxAmount: DEFAULT_MAX_AMOUNT, + tokenAddress: '0x0000000000000000000000000000000000000001', + justification: 'j', + }, + isAdjustmentAllowed: true, + }, + }); + + const result = await formatPermissionWithTokenMetadata( + permission, + mockTokenMetadataService as unknown as TokenMetadataService, + ); + + expect(result.permission.data).toMatchObject({ + maxAmount: expect.any(String) as unknown, + }); + const { maxAmount } = result.permission.data as { maxAmount: string }; + expect(maxAmount.toLowerCase().startsWith('0x')).toBe(false); + }); + + it('returns original permission when metadata fetch fails', async () => { + mockTokenMetadataService.getTokenMetadata.mockRejectedValue( + new Error('rpc down'), + ); + + const permission = basePermission({ + permission: { + type: 'erc20-token-stream', + data: { + maxAmount: '0x1', + tokenAddress: '0x0000000000000000000000000000000000000001', + justification: 'j', + }, + isAdjustmentAllowed: true, + }, + }); + + const result = await formatPermissionWithTokenMetadata( + permission, + mockTokenMetadataService as unknown as TokenMetadataService, + ); + + expect(result).toStrictEqual(permission); + expect(logger.debug).toHaveBeenCalled(); + }); +}); diff --git a/packages/gator-permissions-snap/test/core/permissionHandler.test.ts b/packages/gator-permissions-snap/test/core/permissionHandler.test.ts index ffa1f26b..b48fe451 100644 --- a/packages/gator-permissions-snap/test/core/permissionHandler.test.ts +++ b/packages/gator-permissions-snap/test/core/permissionHandler.test.ts @@ -5,6 +5,7 @@ import { UserInputEventType } from '@metamask/snaps-sdk'; import { AddressScanResultType } from '../../src/clients/trustSignalsClient'; import type { TokenBalanceAndMetadata } from '../../src/clients/types'; import type { AccountController } from '../../src/core/accountController'; +import { ExistingPermissionsState } from '../../src/core/existingpermissions/existingPermissionsState'; import { PermissionHandler } from '../../src/core/permissionHandler'; import type { PermissionRequestLifecycleOrchestrator } from '../../src/core/permissionRequestLifecycleOrchestrator'; import type { @@ -414,6 +415,8 @@ describe('PermissionHandler', () => { chainId: 1, scanDappUrlResult: null, scanAddressResult: null, + existingPermissionsStatus: ExistingPermissionsState.None, + isGrantDisabled: false, }); expect(dependencies.createConfirmationContent).toHaveBeenCalledWith({ @@ -436,10 +439,12 @@ describe('PermissionHandler', () => { chainId: 1, scanDappUrlResult: null, scanAddressResult: null, + existingPermissionsStatus: ExistingPermissionsState.None, + isGrantDisabled: false, }); expect(result).toBeDefined(); - expect(result.type).toBe('Box'); + expect(result.type).toBe('Container'); }); it('uses translated fallback for address warning when scanAddressResult.label is empty', async () => { @@ -459,6 +464,8 @@ describe('PermissionHandler', () => { resultType: AddressScanResultType.Malicious, label: '', }, + existingPermissionsStatus: ExistingPermissionsState.None, + isGrantDisabled: false, }); // When label is empty, permissionHandlerContent should use t('maliciousAddressLabel') @@ -495,7 +502,6 @@ describe('PermissionHandler', () => { interfaceId: mockInterfaceId, initialContext: mockContext, updateContext, - isAdjustmentAllowed: true, }); const accountSelectorBoundEvent = getBoundEvent({ @@ -530,7 +536,6 @@ describe('PermissionHandler', () => { interfaceId: mockInterfaceId, initialContext: mockContext, updateContext, - isAdjustmentAllowed: true, }); expect( @@ -558,7 +563,6 @@ describe('PermissionHandler', () => { interfaceId: mockInterfaceId, initialContext: mockContext, updateContext, - isAdjustmentAllowed: true, }); const accountSelectorChangeHandler = getBoundEvent({ @@ -607,7 +611,6 @@ describe('PermissionHandler', () => { interfaceId: mockInterfaceId, initialContext: mockContext, updateContext, - isAdjustmentAllowed: true, }); const accountSelectorChangeHandler = getBoundEvent({ @@ -654,7 +657,6 @@ describe('PermissionHandler', () => { interfaceId: mockInterfaceId, initialContext: mockContext, updateContext, - isAdjustmentAllowed: true, }); const confirmationContent = @@ -665,212 +667,215 @@ describe('PermissionHandler', () => { chainId: 1, scanDappUrlResult: null, scanAddressResult: null, + existingPermissionsStatus: ExistingPermissionsState.None, + isGrantDisabled: false, }); expect(confirmationContent).toMatchInlineSnapshot(` { "key": null, "props": { - "children": { - "key": null, - "props": { - "children": [ - { + "children": [ + { + "key": null, + "props": { + "children": { "key": null, "props": { - "center": true, "children": [ { "key": null, "props": { - "children": "Permission request", - "size": "lg", + "center": true, + "children": [ + { + "key": null, + "props": { + "children": "Permission request", + "size": "lg", + }, + "type": "Heading", + }, + { + "key": null, + "props": { + "children": "This site wants permissions to spend your tokens.", + }, + "type": "Text", + }, + ], }, - "type": "Heading", + "type": "Box", }, { "key": null, "props": { - "children": "This site wants permissions to spend your tokens.", - }, - "type": "Text", - }, - ], - }, - "type": "Box", - }, - { - "key": null, - "props": { - "children": { - "key": null, - "props": { - "children": [ - { + "children": { "key": null, "props": { - "alignment": "space-between", - "children": { - "key": null, - "props": { - "children": [ - { - "key": null, - "props": { - "children": "Account", - }, - "type": "Text", - }, - { + "children": [ + { + "key": null, + "props": { + "alignment": "space-between", + "children": { "key": null, "props": { - "children": { - "key": null, - "props": { - "color": "muted", - "name": "question", - "size": "inherit", + "children": [ + { + "key": null, + "props": { + "children": "Account", + }, + "type": "Text", }, - "type": "Icon", - }, - "content": { - "key": null, - "props": { - "children": "The account from which the permission is being granted.", + { + "key": null, + "props": { + "children": { + "key": null, + "props": { + "color": "muted", + "name": "question", + "size": "inherit", + }, + "type": "Icon", + }, + "content": { + "key": null, + "props": { + "children": "The account from which the permission is being granted.", + }, + "type": "Text", + }, + }, + "type": "Tooltip", }, - "type": "Text", - }, + ], + "direction": "horizontal", }, - "type": "Tooltip", + "type": "Box", }, - ], - "direction": "horizontal", + "direction": "horizontal", + }, + "type": "Box", }, - "type": "Box", - }, - "direction": "horizontal", - }, - "type": "Box", - }, - { - "key": null, - "props": { - "chainIds": [ - "eip155:1", - ], - "name": "account-selector", - "switchGlobalAccount": false, - "value": "eip155:1:0x1234567890123456789012345678901234567890", - }, - "type": "AccountSelector", - }, - false, - { - "key": null, - "props": { - "alignment": "end", - "children": [ { "key": null, - "props": {}, - "type": "Skeleton", + "props": { + "chainIds": [ + "eip155:1", + ], + "name": "account-selector", + "switchGlobalAccount": false, + "value": "eip155:1:0x1234567890123456789012345678901234567890", + }, + "type": "AccountSelector", }, + false, { "key": null, - "props": {}, - "type": "Skeleton", + "props": { + "alignment": "end", + "children": [ + { + "key": null, + "props": {}, + "type": "Skeleton", + }, + { + "key": null, + "props": {}, + "type": "Skeleton", + }, + ], + "direction": "horizontal", + }, + "type": "Box", }, ], - "direction": "horizontal", + "direction": "vertical", }, "type": "Box", }, - ], - "direction": "vertical", + }, + "type": "Section", }, - "type": "Box", - }, - }, - "type": "Section", - }, - { - "key": null, - "props": { - "children": { - "key": null, - "props": { - "alignment": "space-between", - "children": [ - { + false, + false, + { + "key": null, + "props": { + "children": { "key": null, "props": { + "alignment": "space-between", "children": [ { "key": null, "props": { - "children": "Justification", - }, - "type": "Text", - }, - { - "key": null, - "props": { - "children": { - "key": null, - "props": { - "color": "muted", - "name": "question", - "size": "inherit", + "children": [ + { + "key": null, + "props": { + "children": "Justification", + }, + "type": "Text", }, - "type": "Icon", - }, - "content": { - "key": null, - "props": { - "children": "Justification given by the recipient for requesting this permission.", + { + "key": null, + "props": { + "children": { + "key": null, + "props": { + "color": "muted", + "name": "question", + "size": "inherit", + }, + "type": "Icon", + }, + "content": { + "key": null, + "props": { + "children": "Justification given by the recipient for requesting this permission.", + }, + "type": "Text", + }, + }, + "type": "Tooltip", }, - "type": "Text", - }, + ], + "direction": "horizontal", }, - "type": "Tooltip", + "type": "Box", }, - ], - "direction": "horizontal", - }, - "type": "Box", - }, - { - "key": null, - "props": { - "children": [ { "key": null, "props": { - "children": "Test justification text that is longer than twenty characters", + "children": [ + { + "key": null, + "props": { + "children": "Test justification text that is longer than twenty characters", + }, + "type": "Text", + }, + null, + ], + "direction": "vertical", }, - "type": "Text", + "type": "Box", }, - null, ], "direction": "vertical", }, "type": "Box", }, - ], - "direction": "vertical", + }, + "type": "Section", }, - "type": "Box", - }, - }, - "type": "Section", - }, - { - "key": null, - "props": { - "children": [ { "key": null, "props": { - "alignment": "space-between", "children": [ { "key": null, @@ -880,75 +885,75 @@ describe('PermissionHandler', () => { { "key": null, "props": { + "alignment": "space-between", "children": [ { "key": null, "props": { - "children": "Request from", - }, - "type": "Text", - }, - { - "key": null, - "props": { - "children": { - "key": null, - "props": { - "color": "muted", - "name": "question", - "size": "inherit", + "children": [ + { + "key": null, + "props": { + "children": "Request from", + }, + "type": "Text", }, - "type": "Icon", - }, - "content": { - "key": null, - "props": { - "children": "The site requesting the permission", + { + "key": null, + "props": { + "children": { + "key": null, + "props": { + "color": "muted", + "name": "question", + "size": "inherit", + }, + "type": "Icon", + }, + "content": { + "key": null, + "props": { + "children": "The site requesting the permission", + }, + "type": "Text", + }, + }, + "type": "Tooltip", }, - "type": "Text", - }, + ], + "direction": "horizontal", }, - "type": "Tooltip", + "type": "Box", }, + null, ], "direction": "horizontal", }, "type": "Box", }, - null, - ], - "direction": "horizontal", - }, - "type": "Box", - }, - { - "key": null, - "props": { - "children": [ - null, { "key": null, "props": { - "alignment": "end", - "children": "https://example.com", + "children": [ + null, + { + "key": null, + "props": { + "alignment": "end", + "children": "https://example.com", + }, + "type": "Text", + }, + ], + "direction": "horizontal", }, - "type": "Text", + "type": "Box", }, ], "direction": "horizontal", }, "type": "Box", }, - ], - "direction": "horizontal", - }, - "type": "Box", - }, - { - "key": null, - "props": { - "alignment": "space-between", - "children": [ { "key": null, "props": { @@ -957,33 +962,68 @@ describe('PermissionHandler', () => { { "key": null, "props": { + "alignment": "space-between", "children": [ { "key": null, "props": { - "children": "Recipient", + "children": [ + { + "key": null, + "props": { + "children": "Recipient", + }, + "type": "Text", + }, + { + "key": null, + "props": { + "children": { + "key": null, + "props": { + "color": "muted", + "name": "question", + "size": "inherit", + }, + "type": "Icon", + }, + "content": { + "key": null, + "props": { + "children": "The site requesting the permission", + }, + "type": "Text", + }, + }, + "type": "Tooltip", + }, + ], + "direction": "horizontal", }, - "type": "Text", + "type": "Box", }, + null, + ], + "direction": "horizontal", + }, + "type": "Box", + }, + { + "key": null, + "props": { + "children": [ + null, { "key": null, "props": { "children": { "key": null, "props": { - "color": "muted", - "name": "question", - "size": "inherit", - }, - "type": "Icon", - }, - "content": { - "key": null, - "props": { - "children": "The site requesting the permission", + "children": "0x12345...67890", }, "type": "Text", }, + "content": "0x1234567890123456789012345678901234567890", }, "type": "Tooltip", }, @@ -992,46 +1032,11 @@ describe('PermissionHandler', () => { }, "type": "Box", }, - null, ], "direction": "horizontal", }, "type": "Box", }, - { - "key": null, - "props": { - "children": [ - null, - { - "key": null, - "props": { - "children": { - "key": null, - "props": { - "children": "0x12345...67890", - }, - "type": "Text", - }, - "content": "0x1234567890123456789012345678901234567890", - }, - "type": "Tooltip", - }, - ], - "direction": "horizontal", - }, - "type": "Box", - }, - ], - "direction": "horizontal", - }, - "type": "Box", - }, - { - "key": null, - "props": { - "alignment": "space-between", - "children": [ { "key": null, "props": { @@ -1040,75 +1045,75 @@ describe('PermissionHandler', () => { { "key": null, "props": { + "alignment": "space-between", "children": [ { "key": null, "props": { - "children": "Network", - }, - "type": "Text", - }, - { - "key": null, - "props": { - "children": { - "key": null, - "props": { - "color": "muted", - "name": "question", - "size": "inherit", + "children": [ + { + "key": null, + "props": { + "children": "Network", + }, + "type": "Text", }, - "type": "Icon", - }, - "content": { - "key": null, - "props": { - "children": "The network on which the permission is being requested", + { + "key": null, + "props": { + "children": { + "key": null, + "props": { + "color": "muted", + "name": "question", + "size": "inherit", + }, + "type": "Icon", + }, + "content": { + "key": null, + "props": { + "children": "The network on which the permission is being requested", + }, + "type": "Text", + }, + }, + "type": "Tooltip", }, - "type": "Text", - }, + ], + "direction": "horizontal", }, - "type": "Tooltip", + "type": "Box", }, + null, ], "direction": "horizontal", }, "type": "Box", }, - null, - ], - "direction": "horizontal", - }, - "type": "Box", - }, - { - "key": null, - "props": { - "children": [ - null, { "key": null, "props": { - "alignment": "end", - "children": "Ethereum Mainnet", + "children": [ + null, + { + "key": null, + "props": { + "alignment": "end", + "children": "Ethereum Mainnet", + }, + "type": "Text", + }, + ], + "direction": "horizontal", }, - "type": "Text", + "type": "Box", }, ], "direction": "horizontal", }, "type": "Box", }, - ], - "direction": "horizontal", - }, - "type": "Box", - }, - { - "key": null, - "props": { - "alignment": "space-between", - "children": [ { "key": null, "props": { @@ -1117,66 +1122,76 @@ describe('PermissionHandler', () => { { "key": null, "props": { + "alignment": "space-between", "children": [ { "key": null, "props": { - "children": "Token", - }, - "type": "Text", - }, - { - "key": null, - "props": { - "children": { - "key": null, - "props": { - "color": "muted", - "name": "question", - "size": "inherit", + "children": [ + { + "key": null, + "props": { + "children": "Token", + }, + "type": "Text", }, - "type": "Icon", - }, - "content": { - "key": null, - "props": { - "children": "The token being requested", + { + "key": null, + "props": { + "children": { + "key": null, + "props": { + "color": "muted", + "name": "question", + "size": "inherit", + }, + "type": "Icon", + }, + "content": { + "key": null, + "props": { + "children": "The token being requested", + }, + "type": "Text", + }, + }, + "type": "Tooltip", }, - "type": "Text", - }, + ], + "direction": "horizontal", }, - "type": "Tooltip", + "type": "Box", }, + null, ], "direction": "horizontal", }, "type": "Box", }, - null, - ], - "direction": "horizontal", - }, - "type": "Box", - }, - { - "key": null, - "props": { - "children": [ - null, { "key": null, "props": { - "children": { - "key": null, - "props": { - "children": "ETH", - "href": "https://etherscan.io/address/0x38c4A4F071d33d6Cf83e2e81F12D9B5D30E611F3", + "children": [ + null, + { + "key": null, + "props": { + "children": { + "key": null, + "props": { + "children": "ETH", + "href": "https://etherscan.io/address/0x38c4A4F071d33d6Cf83e2e81F12D9B5D30E611F3", + }, + "type": "Link", + }, + "content": "0x38c4A...611F3", + }, + "type": "Tooltip", }, - "type": "Link", - }, - "content": "0x38c4A...611F3", + ], + "direction": "horizontal", }, - "type": "Tooltip", + "type": "Box", }, ], "direction": "horizontal", @@ -1184,22 +1199,48 @@ describe('PermissionHandler', () => { "type": "Box", }, ], - "direction": "horizontal", }, - "type": "Box", + "type": "Section", }, + undefined, ], + "direction": "vertical", }, - "type": "Section", + "type": "Box", }, - undefined, - ], - "direction": "vertical", + }, + "type": "Box", }, - "type": "Box", - }, + { + "key": null, + "props": { + "children": [ + { + "key": null, + "props": { + "children": "Cancel", + "name": "cancel-button", + "variant": "destructive", + }, + "type": "Button", + }, + { + "key": null, + "props": { + "children": "Grant", + "disabled": false, + "name": "grant-button", + "variant": "primary", + }, + "type": "Button", + }, + ], + }, + "type": "Footer", + }, + ], }, - "type": "Box", + "type": "Container", } `); }); @@ -1221,7 +1262,6 @@ describe('PermissionHandler', () => { interfaceId: mockInterfaceId, initialContext: mockContext, updateContext, - isAdjustmentAllowed: true, }); const accountSelectorChangeHandler = getBoundEvent({ @@ -1253,227 +1293,230 @@ describe('PermissionHandler', () => { chainId: 1, scanDappUrlResult: null, scanAddressResult: null, + existingPermissionsStatus: ExistingPermissionsState.None, + isGrantDisabled: false, }); expect(confirmationContent).toMatchInlineSnapshot(` { "key": null, "props": { - "children": { - "key": null, - "props": { - "children": [ - { + "children": [ + { + "key": null, + "props": { + "children": { "key": null, "props": { - "center": true, "children": [ { "key": null, "props": { - "children": "Permission request", - "size": "lg", + "center": true, + "children": [ + { + "key": null, + "props": { + "children": "Permission request", + "size": "lg", + }, + "type": "Heading", + }, + { + "key": null, + "props": { + "children": "This site wants permissions to spend your tokens.", + }, + "type": "Text", + }, + ], }, - "type": "Heading", + "type": "Box", }, { "key": null, "props": { - "children": "This site wants permissions to spend your tokens.", - }, - "type": "Text", - }, - ], - }, - "type": "Box", - }, - { - "key": null, - "props": { - "children": { - "key": null, - "props": { - "children": [ - { + "children": { "key": null, "props": { - "alignment": "space-between", - "children": { - "key": null, - "props": { - "children": [ - { - "key": null, - "props": { - "children": "Account", - }, - "type": "Text", - }, - { + "children": [ + { + "key": null, + "props": { + "alignment": "space-between", + "children": { "key": null, "props": { - "children": { - "key": null, - "props": { - "color": "muted", - "name": "question", - "size": "inherit", + "children": [ + { + "key": null, + "props": { + "children": "Account", + }, + "type": "Text", }, - "type": "Icon", - }, - "content": { - "key": null, - "props": { - "children": "The account from which the permission is being granted.", + { + "key": null, + "props": { + "children": { + "key": null, + "props": { + "color": "muted", + "name": "question", + "size": "inherit", + }, + "type": "Icon", + }, + "content": { + "key": null, + "props": { + "children": "The account from which the permission is being granted.", + }, + "type": "Text", + }, + }, + "type": "Tooltip", }, - "type": "Text", - }, + ], + "direction": "horizontal", }, - "type": "Tooltip", + "type": "Box", }, - ], - "direction": "horizontal", - }, - "type": "Box", - }, - "direction": "horizontal", - }, - "type": "Box", - }, - { - "key": null, - "props": { - "chainIds": [ - "eip155:1", - ], - "name": "account-selector", - "switchGlobalAccount": false, - "value": "eip155:1:0x1234567890123456789012345678901234567890", - }, - "type": "AccountSelector", - }, - { - "key": null, - "props": { - "children": "This account will be upgraded to a smart account to complete this permission.", - "color": "warning", - "size": "sm", - }, - "type": "Text", - }, - { - "key": null, - "props": { - "alignment": "end", - "children": [ - { - "key": null, - "props": {}, - "type": "Skeleton", + "direction": "horizontal", + }, + "type": "Box", }, { "key": null, "props": { - "children": [ - "1", - " ", - "available", + "chainIds": [ + "eip155:1", ], + "name": "account-selector", + "switchGlobalAccount": false, + "value": "eip155:1:0x1234567890123456789012345678901234567890", }, - "type": "Text", + "type": "AccountSelector", }, - ], - "direction": "horizontal", - }, - "type": "Box", - }, - ], - "direction": "vertical", - }, - "type": "Box", - }, - }, - "type": "Section", - }, - { - "key": null, - "props": { - "children": { - "key": null, - "props": { - "alignment": "space-between", - "children": [ - { - "key": null, - "props": { - "children": [ { "key": null, "props": { - "children": "Justification", + "children": "This account will be upgraded to a smart account to complete this permission.", + "color": "warning", + "size": "sm", }, "type": "Text", }, { "key": null, "props": { - "children": { - "key": null, - "props": { - "color": "muted", - "name": "question", - "size": "inherit", + "alignment": "end", + "children": [ + { + "key": null, + "props": {}, + "type": "Skeleton", }, - "type": "Icon", - }, - "content": { - "key": null, - "props": { - "children": "Justification given by the recipient for requesting this permission.", + { + "key": null, + "props": { + "children": [ + "1", + " ", + "available", + ], + }, + "type": "Text", }, - "type": "Text", - }, + ], + "direction": "horizontal", }, - "type": "Tooltip", + "type": "Box", }, ], - "direction": "horizontal", + "direction": "vertical", }, "type": "Box", }, - { + }, + "type": "Section", + }, + false, + false, + { + "key": null, + "props": { + "children": { "key": null, "props": { + "alignment": "space-between", "children": [ { "key": null, "props": { - "children": "Test justification text that is longer than twenty characters", + "children": [ + { + "key": null, + "props": { + "children": "Justification", + }, + "type": "Text", + }, + { + "key": null, + "props": { + "children": { + "key": null, + "props": { + "color": "muted", + "name": "question", + "size": "inherit", + }, + "type": "Icon", + }, + "content": { + "key": null, + "props": { + "children": "Justification given by the recipient for requesting this permission.", + }, + "type": "Text", + }, + }, + "type": "Tooltip", + }, + ], + "direction": "horizontal", + }, + "type": "Box", + }, + { + "key": null, + "props": { + "children": [ + { + "key": null, + "props": { + "children": "Test justification text that is longer than twenty characters", + }, + "type": "Text", + }, + null, + ], + "direction": "vertical", }, - "type": "Text", + "type": "Box", }, - null, ], "direction": "vertical", }, "type": "Box", }, - ], - "direction": "vertical", + }, + "type": "Section", }, - "type": "Box", - }, - }, - "type": "Section", - }, - { - "key": null, - "props": { - "children": [ { "key": null, "props": { - "alignment": "space-between", "children": [ { "key": null, @@ -1483,75 +1526,75 @@ describe('PermissionHandler', () => { { "key": null, "props": { + "alignment": "space-between", "children": [ { "key": null, "props": { - "children": "Request from", - }, - "type": "Text", - }, - { - "key": null, - "props": { - "children": { - "key": null, - "props": { - "color": "muted", - "name": "question", - "size": "inherit", + "children": [ + { + "key": null, + "props": { + "children": "Request from", + }, + "type": "Text", }, - "type": "Icon", - }, - "content": { - "key": null, - "props": { - "children": "The site requesting the permission", + { + "key": null, + "props": { + "children": { + "key": null, + "props": { + "color": "muted", + "name": "question", + "size": "inherit", + }, + "type": "Icon", + }, + "content": { + "key": null, + "props": { + "children": "The site requesting the permission", + }, + "type": "Text", + }, + }, + "type": "Tooltip", }, - "type": "Text", - }, + ], + "direction": "horizontal", }, - "type": "Tooltip", + "type": "Box", }, + null, ], "direction": "horizontal", }, "type": "Box", }, - null, - ], - "direction": "horizontal", - }, - "type": "Box", - }, - { - "key": null, - "props": { - "children": [ - null, { "key": null, "props": { - "alignment": "end", - "children": "https://example.com", + "children": [ + null, + { + "key": null, + "props": { + "alignment": "end", + "children": "https://example.com", + }, + "type": "Text", + }, + ], + "direction": "horizontal", }, - "type": "Text", + "type": "Box", }, ], "direction": "horizontal", }, "type": "Box", }, - ], - "direction": "horizontal", - }, - "type": "Box", - }, - { - "key": null, - "props": { - "alignment": "space-between", - "children": [ { "key": null, "props": { @@ -1560,116 +1603,68 @@ describe('PermissionHandler', () => { { "key": null, "props": { + "alignment": "space-between", "children": [ { "key": null, "props": { - "children": "Recipient", - }, - "type": "Text", - }, - { - "key": null, - "props": { - "children": { - "key": null, - "props": { - "color": "muted", - "name": "question", - "size": "inherit", + "children": [ + { + "key": null, + "props": { + "children": "Recipient", + }, + "type": "Text", }, - "type": "Icon", - }, - "content": { - "key": null, - "props": { - "children": "The site requesting the permission", + { + "key": null, + "props": { + "children": { + "key": null, + "props": { + "color": "muted", + "name": "question", + "size": "inherit", + }, + "type": "Icon", + }, + "content": { + "key": null, + "props": { + "children": "The site requesting the permission", + }, + "type": "Text", + }, + }, + "type": "Tooltip", }, - "type": "Text", - }, + ], + "direction": "horizontal", }, - "type": "Tooltip", + "type": "Box", }, + null, ], "direction": "horizontal", }, "type": "Box", }, - null, - ], - "direction": "horizontal", - }, - "type": "Box", - }, - { - "key": null, - "props": { - "children": [ - null, - { - "key": null, - "props": { - "children": { - "key": null, - "props": { - "children": "0x12345...67890", - }, - "type": "Text", - }, - "content": "0x1234567890123456789012345678901234567890", - }, - "type": "Tooltip", - }, - ], - "direction": "horizontal", - }, - "type": "Box", - }, - ], - "direction": "horizontal", - }, - "type": "Box", - }, - { - "key": null, - "props": { - "alignment": "space-between", - "children": [ - { - "key": null, - "props": { - "alignment": "space-between", - "children": [ { "key": null, "props": { "children": [ + null, { "key": null, "props": { - "children": "Network", - }, - "type": "Text", - }, - { - "key": null, - "props": { - "children": { - "key": null, - "props": { - "color": "muted", - "name": "question", - "size": "inherit", - }, - "type": "Icon", - }, - "content": { + "children": { "key": null, "props": { - "children": "The network on which the permission is being requested", + "children": "0x12345...67890", }, "type": "Text", }, + "content": "0x1234567890123456789012345678901234567890", }, "type": "Tooltip", }, @@ -1678,7 +1673,6 @@ describe('PermissionHandler', () => { }, "type": "Box", }, - null, ], "direction": "horizontal", }, @@ -1687,31 +1681,80 @@ describe('PermissionHandler', () => { { "key": null, "props": { + "alignment": "space-between", "children": [ - null, { "key": null, "props": { - "alignment": "end", - "children": "Ethereum Mainnet", + "alignment": "space-between", + "children": [ + { + "key": null, + "props": { + "children": [ + { + "key": null, + "props": { + "children": "Network", + }, + "type": "Text", + }, + { + "key": null, + "props": { + "children": { + "key": null, + "props": { + "color": "muted", + "name": "question", + "size": "inherit", + }, + "type": "Icon", + }, + "content": { + "key": null, + "props": { + "children": "The network on which the permission is being requested", + }, + "type": "Text", + }, + }, + "type": "Tooltip", + }, + ], + "direction": "horizontal", + }, + "type": "Box", + }, + null, + ], + "direction": "horizontal", + }, + "type": "Box", + }, + { + "key": null, + "props": { + "children": [ + null, + { + "key": null, + "props": { + "alignment": "end", + "children": "Ethereum Mainnet", + }, + "type": "Text", + }, + ], + "direction": "horizontal", }, - "type": "Text", + "type": "Box", }, ], "direction": "horizontal", }, "type": "Box", }, - ], - "direction": "horizontal", - }, - "type": "Box", - }, - { - "key": null, - "props": { - "alignment": "space-between", - "children": [ { "key": null, "props": { @@ -1720,33 +1763,69 @@ describe('PermissionHandler', () => { { "key": null, "props": { + "alignment": "space-between", "children": [ { "key": null, "props": { - "children": "Token", + "children": [ + { + "key": null, + "props": { + "children": "Token", + }, + "type": "Text", + }, + { + "key": null, + "props": { + "children": { + "key": null, + "props": { + "color": "muted", + "name": "question", + "size": "inherit", + }, + "type": "Icon", + }, + "content": { + "key": null, + "props": { + "children": "The token being requested", + }, + "type": "Text", + }, + }, + "type": "Tooltip", + }, + ], + "direction": "horizontal", }, - "type": "Text", + "type": "Box", }, + null, + ], + "direction": "horizontal", + }, + "type": "Box", + }, + { + "key": null, + "props": { + "children": [ + null, { "key": null, "props": { "children": { "key": null, "props": { - "color": "muted", - "name": "question", - "size": "inherit", + "children": "ETH", + "href": "https://etherscan.io/address/0x38c4A4F071d33d6Cf83e2e81F12D9B5D30E611F3", }, - "type": "Icon", - }, - "content": { - "key": null, - "props": { - "children": "The token being requested", - }, - "type": "Text", + "type": "Link", }, + "content": "0x38c4A...611F3", }, "type": "Tooltip", }, @@ -1755,54 +1834,54 @@ describe('PermissionHandler', () => { }, "type": "Box", }, - null, - ], - "direction": "horizontal", - }, - "type": "Box", - }, - { - "key": null, - "props": { - "children": [ - null, - { - "key": null, - "props": { - "children": { - "key": null, - "props": { - "children": "ETH", - "href": "https://etherscan.io/address/0x38c4A4F071d33d6Cf83e2e81F12D9B5D30E611F3", - }, - "type": "Link", - }, - "content": "0x38c4A...611F3", - }, - "type": "Tooltip", - }, ], "direction": "horizontal", }, "type": "Box", }, ], - "direction": "horizontal", }, - "type": "Box", + "type": "Section", }, + undefined, ], + "direction": "vertical", }, - "type": "Section", + "type": "Box", }, - undefined, - ], - "direction": "vertical", + }, + "type": "Box", }, - "type": "Box", - }, + { + "key": null, + "props": { + "children": [ + { + "key": null, + "props": { + "children": "Cancel", + "name": "cancel-button", + "variant": "destructive", + }, + "type": "Button", + }, + { + "key": null, + "props": { + "children": "Grant", + "disabled": false, + "name": "grant-button", + "variant": "primary", + }, + "type": "Button", + }, + ], + }, + "type": "Footer", + }, + ], }, - "type": "Box", + "type": "Container", } `); }); @@ -1849,7 +1928,6 @@ describe('PermissionHandler', () => { interfaceId: mockInterfaceId, initialContext: mockContext, updateContext, - isAdjustmentAllowed: true, }); const accountSelectorChangeHandler = getBoundEvent({ @@ -1890,6 +1968,8 @@ describe('PermissionHandler', () => { chainId: 1, scanDappUrlResult: null, scanAddressResult: null, + existingPermissionsStatus: ExistingPermissionsState.None, + isGrantDisabled: false, }); // skeletons in place of both the token balance and fiat balance @@ -1897,292 +1977,216 @@ describe('PermissionHandler', () => { { "key": null, "props": { - "children": { - "key": null, - "props": { - "children": [ - { + "children": [ + { + "key": null, + "props": { + "children": { "key": null, "props": { - "center": true, "children": [ { "key": null, "props": { - "children": "Permission request", - "size": "lg", + "center": true, + "children": [ + { + "key": null, + "props": { + "children": "Permission request", + "size": "lg", + }, + "type": "Heading", + }, + { + "key": null, + "props": { + "children": "This site wants permissions to spend your tokens.", + }, + "type": "Text", + }, + ], }, - "type": "Heading", + "type": "Box", }, { "key": null, "props": { - "children": "This site wants permissions to spend your tokens.", - }, - "type": "Text", - }, - ], - }, - "type": "Box", - }, - { - "key": null, - "props": { - "children": { - "key": null, - "props": { - "children": [ - { + "children": { "key": null, "props": { - "alignment": "space-between", - "children": { - "key": null, - "props": { - "children": [ - { - "key": null, - "props": { - "children": "Account", - }, - "type": "Text", - }, - { + "children": [ + { + "key": null, + "props": { + "alignment": "space-between", + "children": { "key": null, "props": { - "children": { - "key": null, - "props": { - "color": "muted", - "name": "question", - "size": "inherit", + "children": [ + { + "key": null, + "props": { + "children": "Account", + }, + "type": "Text", }, - "type": "Icon", - }, - "content": { - "key": null, - "props": { - "children": "The account from which the permission is being granted.", + { + "key": null, + "props": { + "children": { + "key": null, + "props": { + "color": "muted", + "name": "question", + "size": "inherit", + }, + "type": "Icon", + }, + "content": { + "key": null, + "props": { + "children": "The account from which the permission is being granted.", + }, + "type": "Text", + }, + }, + "type": "Tooltip", }, - "type": "Text", - }, + ], + "direction": "horizontal", }, - "type": "Tooltip", + "type": "Box", }, - ], - "direction": "horizontal", - }, - "type": "Box", - }, - "direction": "horizontal", - }, - "type": "Box", - }, - { - "key": null, - "props": { - "chainIds": [ - "eip155:1", - ], - "name": "account-selector", - "switchGlobalAccount": false, - "value": "eip155:1:0x1234567890123456789012345678901234567890", - }, - "type": "AccountSelector", - }, - { - "key": null, - "props": { - "children": "This account will be upgraded to a smart account to complete this permission.", - "color": "warning", - "size": "sm", - }, - "type": "Text", - }, - { - "key": null, - "props": { - "alignment": "end", - "children": [ - { - "key": null, - "props": {}, - "type": "Skeleton", + "direction": "horizontal", + }, + "type": "Box", }, { "key": null, - "props": {}, - "type": "Skeleton", + "props": { + "chainIds": [ + "eip155:1", + ], + "name": "account-selector", + "switchGlobalAccount": false, + "value": "eip155:1:0x1234567890123456789012345678901234567890", + }, + "type": "AccountSelector", }, - ], - "direction": "horizontal", - }, - "type": "Box", - }, - ], - "direction": "vertical", - }, - "type": "Box", - }, - }, - "type": "Section", - }, - { - "key": null, - "props": { - "children": { - "key": null, - "props": { - "alignment": "space-between", - "children": [ - { - "key": null, - "props": { - "children": [ { "key": null, "props": { - "children": "Justification", + "children": "This account will be upgraded to a smart account to complete this permission.", + "color": "warning", + "size": "sm", }, "type": "Text", }, { "key": null, "props": { - "children": { - "key": null, - "props": { - "color": "muted", - "name": "question", - "size": "inherit", + "alignment": "end", + "children": [ + { + "key": null, + "props": {}, + "type": "Skeleton", }, - "type": "Icon", - }, - "content": { - "key": null, - "props": { - "children": "Justification given by the recipient for requesting this permission.", + { + "key": null, + "props": {}, + "type": "Skeleton", }, - "type": "Text", - }, + ], + "direction": "horizontal", }, - "type": "Tooltip", + "type": "Box", }, ], - "direction": "horizontal", + "direction": "vertical", }, "type": "Box", }, - { + }, + "type": "Section", + }, + false, + false, + { + "key": null, + "props": { + "children": { "key": null, "props": { + "alignment": "space-between", "children": [ { "key": null, "props": { - "children": "Test justification text that is longer than twenty characters", - }, - "type": "Text", - }, - null, - ], - "direction": "vertical", - }, - "type": "Box", - }, - ], - "direction": "vertical", - }, - "type": "Box", - }, - }, - "type": "Section", - }, - { - "key": null, - "props": { - "children": [ - { - "key": null, - "props": { - "alignment": "space-between", - "children": [ - { - "key": null, - "props": { - "alignment": "space-between", - "children": [ - { - "key": null, - "props": { - "children": [ - { - "key": null, - "props": { - "children": "Request from", - }, - "type": "Text", + "children": [ + { + "key": null, + "props": { + "children": "Justification", }, - { - "key": null, - "props": { - "children": { - "key": null, - "props": { - "color": "muted", - "name": "question", - "size": "inherit", - }, - "type": "Icon", + "type": "Text", + }, + { + "key": null, + "props": { + "children": { + "key": null, + "props": { + "color": "muted", + "name": "question", + "size": "inherit", }, - "content": { - "key": null, - "props": { - "children": "The site requesting the permission", - }, - "type": "Text", + "type": "Icon", + }, + "content": { + "key": null, + "props": { + "children": "Justification given by the recipient for requesting this permission.", }, + "type": "Text", }, - "type": "Tooltip", }, - ], - "direction": "horizontal", - }, - "type": "Box", + "type": "Tooltip", + }, + ], + "direction": "horizontal", }, - null, - ], - "direction": "horizontal", - }, - "type": "Box", - }, - { - "key": null, - "props": { - "children": [ - null, - { - "key": null, - "props": { - "alignment": "end", - "children": "https://example.com", - }, - "type": "Text", + "type": "Box", + }, + { + "key": null, + "props": { + "children": [ + { + "key": null, + "props": { + "children": "Test justification text that is longer than twenty characters", + }, + "type": "Text", + }, + null, + ], + "direction": "vertical", }, - ], - "direction": "horizontal", - }, - "type": "Box", + "type": "Box", + }, + ], + "direction": "vertical", }, - ], - "direction": "horizontal", + "type": "Box", + }, }, - "type": "Box", + "type": "Section", }, { "key": null, "props": { - "alignment": "space-between", "children": [ { "key": null, @@ -2192,81 +2196,75 @@ describe('PermissionHandler', () => { { "key": null, "props": { + "alignment": "space-between", "children": [ { "key": null, "props": { - "children": "Recipient", - }, - "type": "Text", - }, - { - "key": null, - "props": { - "children": { - "key": null, - "props": { - "color": "muted", - "name": "question", - "size": "inherit", + "children": [ + { + "key": null, + "props": { + "children": "Request from", + }, + "type": "Text", }, - "type": "Icon", - }, - "content": { - "key": null, - "props": { - "children": "The site requesting the permission", + { + "key": null, + "props": { + "children": { + "key": null, + "props": { + "color": "muted", + "name": "question", + "size": "inherit", + }, + "type": "Icon", + }, + "content": { + "key": null, + "props": { + "children": "The site requesting the permission", + }, + "type": "Text", + }, + }, + "type": "Tooltip", }, - "type": "Text", - }, + ], + "direction": "horizontal", }, - "type": "Tooltip", + "type": "Box", }, + null, ], "direction": "horizontal", }, "type": "Box", }, - null, - ], - "direction": "horizontal", - }, - "type": "Box", - }, - { - "key": null, - "props": { - "children": [ - null, { "key": null, "props": { - "children": { - "key": null, - "props": { - "children": "0x12345...67890", + "children": [ + null, + { + "key": null, + "props": { + "alignment": "end", + "children": "https://example.com", + }, + "type": "Text", }, - "type": "Text", - }, - "content": "0x1234567890123456789012345678901234567890", + ], + "direction": "horizontal", }, - "type": "Tooltip", + "type": "Box", }, ], "direction": "horizontal", }, "type": "Box", }, - ], - "direction": "horizontal", - }, - "type": "Box", - }, - { - "key": null, - "props": { - "alignment": "space-between", - "children": [ { "key": null, "props": { @@ -2275,33 +2273,68 @@ describe('PermissionHandler', () => { { "key": null, "props": { + "alignment": "space-between", "children": [ { "key": null, "props": { - "children": "Network", + "children": [ + { + "key": null, + "props": { + "children": "Recipient", + }, + "type": "Text", + }, + { + "key": null, + "props": { + "children": { + "key": null, + "props": { + "color": "muted", + "name": "question", + "size": "inherit", + }, + "type": "Icon", + }, + "content": { + "key": null, + "props": { + "children": "The site requesting the permission", + }, + "type": "Text", + }, + }, + "type": "Tooltip", + }, + ], + "direction": "horizontal", }, - "type": "Text", + "type": "Box", }, + null, + ], + "direction": "horizontal", + }, + "type": "Box", + }, + { + "key": null, + "props": { + "children": [ + null, { "key": null, "props": { "children": { "key": null, "props": { - "color": "muted", - "name": "question", - "size": "inherit", - }, - "type": "Icon", - }, - "content": { - "key": null, - "props": { - "children": "The network on which the permission is being requested", + "children": "0x12345...67890", }, "type": "Text", }, + "content": "0x1234567890123456789012345678901234567890", }, "type": "Tooltip", }, @@ -2310,7 +2343,6 @@ describe('PermissionHandler', () => { }, "type": "Box", }, - null, ], "direction": "horizontal", }, @@ -2319,31 +2351,80 @@ describe('PermissionHandler', () => { { "key": null, "props": { + "alignment": "space-between", "children": [ - null, { "key": null, "props": { - "alignment": "end", - "children": "Ethereum Mainnet", + "alignment": "space-between", + "children": [ + { + "key": null, + "props": { + "children": [ + { + "key": null, + "props": { + "children": "Network", + }, + "type": "Text", + }, + { + "key": null, + "props": { + "children": { + "key": null, + "props": { + "color": "muted", + "name": "question", + "size": "inherit", + }, + "type": "Icon", + }, + "content": { + "key": null, + "props": { + "children": "The network on which the permission is being requested", + }, + "type": "Text", + }, + }, + "type": "Tooltip", + }, + ], + "direction": "horizontal", + }, + "type": "Box", + }, + null, + ], + "direction": "horizontal", + }, + "type": "Box", + }, + { + "key": null, + "props": { + "children": [ + null, + { + "key": null, + "props": { + "alignment": "end", + "children": "Ethereum Mainnet", + }, + "type": "Text", + }, + ], + "direction": "horizontal", }, - "type": "Text", + "type": "Box", }, ], "direction": "horizontal", }, "type": "Box", }, - ], - "direction": "horizontal", - }, - "type": "Box", - }, - { - "key": null, - "props": { - "alignment": "space-between", - "children": [ { "key": null, "props": { @@ -2352,33 +2433,69 @@ describe('PermissionHandler', () => { { "key": null, "props": { + "alignment": "space-between", "children": [ { "key": null, "props": { - "children": "Token", + "children": [ + { + "key": null, + "props": { + "children": "Token", + }, + "type": "Text", + }, + { + "key": null, + "props": { + "children": { + "key": null, + "props": { + "color": "muted", + "name": "question", + "size": "inherit", + }, + "type": "Icon", + }, + "content": { + "key": null, + "props": { + "children": "The token being requested", + }, + "type": "Text", + }, + }, + "type": "Tooltip", + }, + ], + "direction": "horizontal", }, - "type": "Text", + "type": "Box", }, + null, + ], + "direction": "horizontal", + }, + "type": "Box", + }, + { + "key": null, + "props": { + "children": [ + null, { "key": null, "props": { "children": { "key": null, "props": { - "color": "muted", - "name": "question", - "size": "inherit", - }, - "type": "Icon", - }, - "content": { - "key": null, - "props": { - "children": "The token being requested", + "children": "ETH", + "href": "https://etherscan.io/address/0x38c4A4F071d33d6Cf83e2e81F12D9B5D30E611F3", }, - "type": "Text", + "type": "Link", }, + "content": "0x38c4A...611F3", }, "type": "Tooltip", }, @@ -2387,54 +2504,54 @@ describe('PermissionHandler', () => { }, "type": "Box", }, - null, - ], - "direction": "horizontal", - }, - "type": "Box", - }, - { - "key": null, - "props": { - "children": [ - null, - { - "key": null, - "props": { - "children": { - "key": null, - "props": { - "children": "ETH", - "href": "https://etherscan.io/address/0x38c4A4F071d33d6Cf83e2e81F12D9B5D30E611F3", - }, - "type": "Link", - }, - "content": "0x38c4A...611F3", - }, - "type": "Tooltip", - }, ], "direction": "horizontal", }, "type": "Box", }, ], - "direction": "horizontal", }, - "type": "Box", + "type": "Section", }, + undefined, ], + "direction": "vertical", }, - "type": "Section", + "type": "Box", }, - undefined, - ], - "direction": "vertical", + }, + "type": "Box", }, - "type": "Box", - }, + { + "key": null, + "props": { + "children": [ + { + "key": null, + "props": { + "children": "Cancel", + "name": "cancel-button", + "variant": "destructive", + }, + "type": "Button", + }, + { + "key": null, + "props": { + "children": "Grant", + "disabled": false, + "name": "grant-button", + "variant": "primary", + }, + "type": "Button", + }, + ], + }, + "type": "Footer", + }, + ], }, - "type": "Box", + "type": "Container", } `); @@ -2452,6 +2569,8 @@ describe('PermissionHandler', () => { chainId: 1, scanDappUrlResult: null, scanAddressResult: null, + existingPermissionsStatus: ExistingPermissionsState.None, + isGrantDisabled: false, }); // concrete token balance, skeleton for fiat balance @@ -2459,221 +2578,222 @@ describe('PermissionHandler', () => { { "key": null, "props": { - "children": { - "key": null, - "props": { - "children": [ - { + "children": [ + { + "key": null, + "props": { + "children": { "key": null, "props": { - "center": true, "children": [ { "key": null, "props": { - "children": "Permission request", - "size": "lg", + "center": true, + "children": [ + { + "key": null, + "props": { + "children": "Permission request", + "size": "lg", + }, + "type": "Heading", + }, + { + "key": null, + "props": { + "children": "This site wants permissions to spend your tokens.", + }, + "type": "Text", + }, + ], }, - "type": "Heading", + "type": "Box", }, { "key": null, "props": { - "children": "This site wants permissions to spend your tokens.", - }, - "type": "Text", - }, - ], - }, - "type": "Box", - }, - { - "key": null, - "props": { - "children": { - "key": null, - "props": { - "children": [ - { + "children": { "key": null, "props": { - "alignment": "space-between", - "children": { - "key": null, - "props": { - "children": [ - { - "key": null, - "props": { - "children": "Account", - }, - "type": "Text", - }, - { + "children": [ + { + "key": null, + "props": { + "alignment": "space-between", + "children": { "key": null, "props": { - "children": { - "key": null, - "props": { - "color": "muted", - "name": "question", - "size": "inherit", + "children": [ + { + "key": null, + "props": { + "children": "Account", + }, + "type": "Text", }, - "type": "Icon", - }, - "content": { - "key": null, - "props": { - "children": "The account from which the permission is being granted.", + { + "key": null, + "props": { + "children": { + "key": null, + "props": { + "color": "muted", + "name": "question", + "size": "inherit", + }, + "type": "Icon", + }, + "content": { + "key": null, + "props": { + "children": "The account from which the permission is being granted.", + }, + "type": "Text", + }, + }, + "type": "Tooltip", }, - "type": "Text", - }, + ], + "direction": "horizontal", }, - "type": "Tooltip", + "type": "Box", }, - ], - "direction": "horizontal", + "direction": "horizontal", + }, + "type": "Box", + }, + { + "key": null, + "props": { + "chainIds": [ + "eip155:1", + ], + "name": "account-selector", + "switchGlobalAccount": false, + "value": "eip155:1:0x1234567890123456789012345678901234567890", + }, + "type": "AccountSelector", }, - "type": "Box", - }, - "direction": "horizontal", - }, - "type": "Box", - }, - { - "key": null, - "props": { - "chainIds": [ - "eip155:1", - ], - "name": "account-selector", - "switchGlobalAccount": false, - "value": "eip155:1:0x1234567890123456789012345678901234567890", - }, - "type": "AccountSelector", - }, - { - "key": null, - "props": { - "children": "This account will be upgraded to a smart account to complete this permission.", - "color": "warning", - "size": "sm", - }, - "type": "Text", - }, - { - "key": null, - "props": { - "alignment": "end", - "children": [ { "key": null, - "props": {}, - "type": "Skeleton", + "props": { + "children": "This account will be upgraded to a smart account to complete this permission.", + "color": "warning", + "size": "sm", + }, + "type": "Text", }, { "key": null, "props": { + "alignment": "end", "children": [ - "1", - " ", - "available", + { + "key": null, + "props": {}, + "type": "Skeleton", + }, + { + "key": null, + "props": { + "children": [ + "1", + " ", + "available", + ], + }, + "type": "Text", + }, ], + "direction": "horizontal", }, - "type": "Text", + "type": "Box", }, ], - "direction": "horizontal", + "direction": "vertical", }, "type": "Box", }, - ], - "direction": "vertical", + }, + "type": "Section", }, - "type": "Box", - }, - }, - "type": "Section", - }, - { - "key": null, - "props": { - "children": { - "key": null, - "props": { - "alignment": "space-between", - "children": [ - { + false, + false, + { + "key": null, + "props": { + "children": { "key": null, "props": { + "alignment": "space-between", "children": [ { "key": null, "props": { - "children": "Justification", - }, - "type": "Text", - }, - { - "key": null, - "props": { - "children": { - "key": null, - "props": { - "color": "muted", - "name": "question", - "size": "inherit", + "children": [ + { + "key": null, + "props": { + "children": "Justification", + }, + "type": "Text", }, - "type": "Icon", - }, - "content": { - "key": null, - "props": { - "children": "Justification given by the recipient for requesting this permission.", + { + "key": null, + "props": { + "children": { + "key": null, + "props": { + "color": "muted", + "name": "question", + "size": "inherit", + }, + "type": "Icon", + }, + "content": { + "key": null, + "props": { + "children": "Justification given by the recipient for requesting this permission.", + }, + "type": "Text", + }, + }, + "type": "Tooltip", }, - "type": "Text", - }, + ], + "direction": "horizontal", }, - "type": "Tooltip", + "type": "Box", }, - ], - "direction": "horizontal", - }, - "type": "Box", - }, - { - "key": null, - "props": { - "children": [ { "key": null, "props": { - "children": "Test justification text that is longer than twenty characters", + "children": [ + { + "key": null, + "props": { + "children": "Test justification text that is longer than twenty characters", + }, + "type": "Text", + }, + null, + ], + "direction": "vertical", }, - "type": "Text", + "type": "Box", }, - null, ], "direction": "vertical", }, "type": "Box", }, - ], - "direction": "vertical", + }, + "type": "Section", }, - "type": "Box", - }, - }, - "type": "Section", - }, - { - "key": null, - "props": { - "children": [ { "key": null, "props": { - "alignment": "space-between", "children": [ { "key": null, @@ -2683,75 +2803,75 @@ describe('PermissionHandler', () => { { "key": null, "props": { + "alignment": "space-between", "children": [ { "key": null, "props": { - "children": "Request from", - }, - "type": "Text", - }, - { - "key": null, - "props": { - "children": { - "key": null, - "props": { - "color": "muted", - "name": "question", - "size": "inherit", + "children": [ + { + "key": null, + "props": { + "children": "Request from", + }, + "type": "Text", }, - "type": "Icon", - }, - "content": { - "key": null, - "props": { - "children": "The site requesting the permission", + { + "key": null, + "props": { + "children": { + "key": null, + "props": { + "color": "muted", + "name": "question", + "size": "inherit", + }, + "type": "Icon", + }, + "content": { + "key": null, + "props": { + "children": "The site requesting the permission", + }, + "type": "Text", + }, + }, + "type": "Tooltip", }, - "type": "Text", - }, + ], + "direction": "horizontal", }, - "type": "Tooltip", + "type": "Box", }, + null, ], "direction": "horizontal", }, "type": "Box", }, - null, - ], - "direction": "horizontal", - }, - "type": "Box", - }, - { - "key": null, - "props": { - "children": [ - null, { "key": null, "props": { - "alignment": "end", - "children": "https://example.com", + "children": [ + null, + { + "key": null, + "props": { + "alignment": "end", + "children": "https://example.com", + }, + "type": "Text", + }, + ], + "direction": "horizontal", }, - "type": "Text", + "type": "Box", }, ], "direction": "horizontal", }, "type": "Box", }, - ], - "direction": "horizontal", - }, - "type": "Box", - }, - { - "key": null, - "props": { - "alignment": "space-between", - "children": [ { "key": null, "props": { @@ -2760,33 +2880,68 @@ describe('PermissionHandler', () => { { "key": null, "props": { + "alignment": "space-between", "children": [ { "key": null, "props": { - "children": "Recipient", + "children": [ + { + "key": null, + "props": { + "children": "Recipient", + }, + "type": "Text", + }, + { + "key": null, + "props": { + "children": { + "key": null, + "props": { + "color": "muted", + "name": "question", + "size": "inherit", + }, + "type": "Icon", + }, + "content": { + "key": null, + "props": { + "children": "The site requesting the permission", + }, + "type": "Text", + }, + }, + "type": "Tooltip", + }, + ], + "direction": "horizontal", }, - "type": "Text", + "type": "Box", }, + null, + ], + "direction": "horizontal", + }, + "type": "Box", + }, + { + "key": null, + "props": { + "children": [ + null, { "key": null, "props": { "children": { "key": null, "props": { - "color": "muted", - "name": "question", - "size": "inherit", - }, - "type": "Icon", - }, - "content": { - "key": null, - "props": { - "children": "The site requesting the permission", + "children": "0x12345...67890", }, "type": "Text", }, + "content": "0x1234567890123456789012345678901234567890", }, "type": "Tooltip", }, @@ -2795,46 +2950,11 @@ describe('PermissionHandler', () => { }, "type": "Box", }, - null, - ], - "direction": "horizontal", - }, - "type": "Box", - }, - { - "key": null, - "props": { - "children": [ - null, - { - "key": null, - "props": { - "children": { - "key": null, - "props": { - "children": "0x12345...67890", - }, - "type": "Text", - }, - "content": "0x1234567890123456789012345678901234567890", - }, - "type": "Tooltip", - }, ], "direction": "horizontal", }, "type": "Box", }, - ], - "direction": "horizontal", - }, - "type": "Box", - }, - { - "key": null, - "props": { - "alignment": "space-between", - "children": [ { "key": null, "props": { @@ -2843,75 +2963,75 @@ describe('PermissionHandler', () => { { "key": null, "props": { + "alignment": "space-between", "children": [ { "key": null, "props": { - "children": "Network", - }, - "type": "Text", - }, - { - "key": null, - "props": { - "children": { - "key": null, - "props": { - "color": "muted", - "name": "question", - "size": "inherit", + "children": [ + { + "key": null, + "props": { + "children": "Network", + }, + "type": "Text", }, - "type": "Icon", - }, - "content": { - "key": null, - "props": { - "children": "The network on which the permission is being requested", + { + "key": null, + "props": { + "children": { + "key": null, + "props": { + "color": "muted", + "name": "question", + "size": "inherit", + }, + "type": "Icon", + }, + "content": { + "key": null, + "props": { + "children": "The network on which the permission is being requested", + }, + "type": "Text", + }, + }, + "type": "Tooltip", }, - "type": "Text", - }, + ], + "direction": "horizontal", }, - "type": "Tooltip", + "type": "Box", }, + null, ], "direction": "horizontal", }, "type": "Box", }, - null, - ], - "direction": "horizontal", - }, - "type": "Box", - }, - { - "key": null, - "props": { - "children": [ - null, { "key": null, "props": { - "alignment": "end", - "children": "Ethereum Mainnet", + "children": [ + null, + { + "key": null, + "props": { + "alignment": "end", + "children": "Ethereum Mainnet", + }, + "type": "Text", + }, + ], + "direction": "horizontal", }, - "type": "Text", + "type": "Box", }, ], "direction": "horizontal", }, "type": "Box", }, - ], - "direction": "horizontal", - }, - "type": "Box", - }, - { - "key": null, - "props": { - "alignment": "space-between", - "children": [ { "key": null, "props": { @@ -2920,33 +3040,69 @@ describe('PermissionHandler', () => { { "key": null, "props": { + "alignment": "space-between", "children": [ { "key": null, "props": { - "children": "Token", + "children": [ + { + "key": null, + "props": { + "children": "Token", + }, + "type": "Text", + }, + { + "key": null, + "props": { + "children": { + "key": null, + "props": { + "color": "muted", + "name": "question", + "size": "inherit", + }, + "type": "Icon", + }, + "content": { + "key": null, + "props": { + "children": "The token being requested", + }, + "type": "Text", + }, + }, + "type": "Tooltip", + }, + ], + "direction": "horizontal", }, - "type": "Text", + "type": "Box", }, + null, + ], + "direction": "horizontal", + }, + "type": "Box", + }, + { + "key": null, + "props": { + "children": [ + null, { "key": null, "props": { "children": { "key": null, "props": { - "color": "muted", - "name": "question", - "size": "inherit", - }, - "type": "Icon", - }, - "content": { - "key": null, - "props": { - "children": "The token being requested", + "children": "ETH", + "href": "https://etherscan.io/address/0x38c4A4F071d33d6Cf83e2e81F12D9B5D30E611F3", }, - "type": "Text", + "type": "Link", }, + "content": "0x38c4A...611F3", }, "type": "Tooltip", }, @@ -2955,54 +3111,54 @@ describe('PermissionHandler', () => { }, "type": "Box", }, - null, - ], - "direction": "horizontal", - }, - "type": "Box", - }, - { - "key": null, - "props": { - "children": [ - null, - { - "key": null, - "props": { - "children": { - "key": null, - "props": { - "children": "ETH", - "href": "https://etherscan.io/address/0x38c4A4F071d33d6Cf83e2e81F12D9B5D30E611F3", - }, - "type": "Link", - }, - "content": "0x38c4A...611F3", - }, - "type": "Tooltip", - }, ], "direction": "horizontal", }, "type": "Box", }, ], - "direction": "horizontal", }, - "type": "Box", + "type": "Section", }, + undefined, ], + "direction": "vertical", }, - "type": "Section", + "type": "Box", }, - undefined, - ], - "direction": "vertical", + }, + "type": "Box", }, - "type": "Box", - }, + { + "key": null, + "props": { + "children": [ + { + "key": null, + "props": { + "children": "Cancel", + "name": "cancel-button", + "variant": "destructive", + }, + "type": "Button", + }, + { + "key": null, + "props": { + "children": "Grant", + "disabled": false, + "name": "grant-button", + "variant": "primary", + }, + "type": "Button", + }, + ], + }, + "type": "Footer", + }, + ], }, - "type": "Box", + "type": "Container", } `); @@ -3020,6 +3176,8 @@ describe('PermissionHandler', () => { chainId: 1, scanDappUrlResult: null, scanAddressResult: null, + existingPermissionsStatus: ExistingPermissionsState.None, + isGrantDisabled: false, }); // concrete token balance, concrete fiat balance @@ -3027,223 +3185,224 @@ describe('PermissionHandler', () => { { "key": null, "props": { - "children": { - "key": null, - "props": { - "children": [ - { + "children": [ + { + "key": null, + "props": { + "children": { "key": null, "props": { - "center": true, "children": [ { "key": null, "props": { - "children": "Permission request", - "size": "lg", + "center": true, + "children": [ + { + "key": null, + "props": { + "children": "Permission request", + "size": "lg", + }, + "type": "Heading", + }, + { + "key": null, + "props": { + "children": "This site wants permissions to spend your tokens.", + }, + "type": "Text", + }, + ], }, - "type": "Heading", + "type": "Box", }, { "key": null, "props": { - "children": "This site wants permissions to spend your tokens.", - }, - "type": "Text", - }, - ], - }, - "type": "Box", - }, - { - "key": null, - "props": { - "children": { - "key": null, - "props": { - "children": [ - { - "key": null, - "props": { - "alignment": "space-between", - "children": { - "key": null, - "props": { - "children": [ - { - "key": null, - "props": { - "children": "Account", - }, - "type": "Text", - }, - { - "key": null, - "props": { - "children": { - "key": null, - "props": { - "color": "muted", - "name": "question", - "size": "inherit", - }, - "type": "Icon", - }, - "content": { - "key": null, - "props": { - "children": "The account from which the permission is being granted.", - }, - "type": "Text", - }, - }, - "type": "Tooltip", - }, - ], - "direction": "horizontal", - }, - "type": "Box", - }, - "direction": "horizontal", - }, - "type": "Box", - }, - { - "key": null, - "props": { - "chainIds": [ - "eip155:1", - ], - "name": "account-selector", - "switchGlobalAccount": false, - "value": "eip155:1:0x1234567890123456789012345678901234567890", - }, - "type": "AccountSelector", - }, - { - "key": null, - "props": { - "children": "This account will be upgraded to a smart account to complete this permission.", - "color": "warning", - "size": "sm", - }, - "type": "Text", - }, - { + "children": { "key": null, "props": { - "alignment": "end", "children": [ { "key": null, "props": { - "children": "$1000", + "alignment": "space-between", + "children": { + "key": null, + "props": { + "children": [ + { + "key": null, + "props": { + "children": "Account", + }, + "type": "Text", + }, + { + "key": null, + "props": { + "children": { + "key": null, + "props": { + "color": "muted", + "name": "question", + "size": "inherit", + }, + "type": "Icon", + }, + "content": { + "key": null, + "props": { + "children": "The account from which the permission is being granted.", + }, + "type": "Text", + }, + }, + "type": "Tooltip", + }, + ], + "direction": "horizontal", + }, + "type": "Box", + }, + "direction": "horizontal", }, - "type": "Text", + "type": "Box", }, { "key": null, "props": { - "children": [ - "1", - " ", - "available", + "chainIds": [ + "eip155:1", ], + "name": "account-selector", + "switchGlobalAccount": false, + "value": "eip155:1:0x1234567890123456789012345678901234567890", }, - "type": "Text", + "type": "AccountSelector", }, - ], - "direction": "horizontal", - }, - "type": "Box", - }, - ], - "direction": "vertical", - }, - "type": "Box", - }, - }, - "type": "Section", - }, - { - "key": null, - "props": { - "children": { - "key": null, - "props": { - "alignment": "space-between", - "children": [ - { - "key": null, - "props": { - "children": [ { "key": null, "props": { - "children": "Justification", + "children": "This account will be upgraded to a smart account to complete this permission.", + "color": "warning", + "size": "sm", }, "type": "Text", }, { "key": null, "props": { - "children": { - "key": null, - "props": { - "color": "muted", - "name": "question", - "size": "inherit", + "alignment": "end", + "children": [ + { + "key": null, + "props": { + "children": "$1000", + }, + "type": "Text", }, - "type": "Icon", - }, - "content": { - "key": null, - "props": { - "children": "Justification given by the recipient for requesting this permission.", + { + "key": null, + "props": { + "children": [ + "1", + " ", + "available", + ], + }, + "type": "Text", }, - "type": "Text", - }, + ], + "direction": "horizontal", }, - "type": "Tooltip", + "type": "Box", }, ], - "direction": "horizontal", + "direction": "vertical", }, "type": "Box", }, - { + }, + "type": "Section", + }, + false, + false, + { + "key": null, + "props": { + "children": { "key": null, "props": { + "alignment": "space-between", "children": [ { "key": null, "props": { - "children": "Test justification text that is longer than twenty characters", + "children": [ + { + "key": null, + "props": { + "children": "Justification", + }, + "type": "Text", + }, + { + "key": null, + "props": { + "children": { + "key": null, + "props": { + "color": "muted", + "name": "question", + "size": "inherit", + }, + "type": "Icon", + }, + "content": { + "key": null, + "props": { + "children": "Justification given by the recipient for requesting this permission.", + }, + "type": "Text", + }, + }, + "type": "Tooltip", + }, + ], + "direction": "horizontal", + }, + "type": "Box", + }, + { + "key": null, + "props": { + "children": [ + { + "key": null, + "props": { + "children": "Test justification text that is longer than twenty characters", + }, + "type": "Text", + }, + null, + ], + "direction": "vertical", }, - "type": "Text", + "type": "Box", }, - null, ], "direction": "vertical", }, "type": "Box", }, - ], - "direction": "vertical", + }, + "type": "Section", }, - "type": "Box", - }, - }, - "type": "Section", - }, - { - "key": null, - "props": { - "children": [ { "key": null, "props": { - "alignment": "space-between", "children": [ { "key": null, @@ -3253,75 +3412,75 @@ describe('PermissionHandler', () => { { "key": null, "props": { + "alignment": "space-between", "children": [ { "key": null, "props": { - "children": "Request from", - }, - "type": "Text", - }, - { - "key": null, - "props": { - "children": { - "key": null, - "props": { - "color": "muted", - "name": "question", - "size": "inherit", + "children": [ + { + "key": null, + "props": { + "children": "Request from", + }, + "type": "Text", }, - "type": "Icon", - }, - "content": { - "key": null, - "props": { - "children": "The site requesting the permission", + { + "key": null, + "props": { + "children": { + "key": null, + "props": { + "color": "muted", + "name": "question", + "size": "inherit", + }, + "type": "Icon", + }, + "content": { + "key": null, + "props": { + "children": "The site requesting the permission", + }, + "type": "Text", + }, + }, + "type": "Tooltip", }, - "type": "Text", - }, + ], + "direction": "horizontal", }, - "type": "Tooltip", + "type": "Box", }, + null, ], "direction": "horizontal", }, "type": "Box", }, - null, - ], - "direction": "horizontal", - }, - "type": "Box", - }, - { - "key": null, - "props": { - "children": [ - null, { "key": null, "props": { - "alignment": "end", - "children": "https://example.com", + "children": [ + null, + { + "key": null, + "props": { + "alignment": "end", + "children": "https://example.com", + }, + "type": "Text", + }, + ], + "direction": "horizontal", }, - "type": "Text", + "type": "Box", }, ], "direction": "horizontal", }, "type": "Box", }, - ], - "direction": "horizontal", - }, - "type": "Box", - }, - { - "key": null, - "props": { - "alignment": "space-between", - "children": [ { "key": null, "props": { @@ -3330,33 +3489,68 @@ describe('PermissionHandler', () => { { "key": null, "props": { + "alignment": "space-between", "children": [ { "key": null, "props": { - "children": "Recipient", + "children": [ + { + "key": null, + "props": { + "children": "Recipient", + }, + "type": "Text", + }, + { + "key": null, + "props": { + "children": { + "key": null, + "props": { + "color": "muted", + "name": "question", + "size": "inherit", + }, + "type": "Icon", + }, + "content": { + "key": null, + "props": { + "children": "The site requesting the permission", + }, + "type": "Text", + }, + }, + "type": "Tooltip", + }, + ], + "direction": "horizontal", }, - "type": "Text", + "type": "Box", }, + null, + ], + "direction": "horizontal", + }, + "type": "Box", + }, + { + "key": null, + "props": { + "children": [ + null, { "key": null, "props": { "children": { "key": null, "props": { - "color": "muted", - "name": "question", - "size": "inherit", - }, - "type": "Icon", - }, - "content": { - "key": null, - "props": { - "children": "The site requesting the permission", + "children": "0x12345...67890", }, "type": "Text", }, + "content": "0x1234567890123456789012345678901234567890", }, "type": "Tooltip", }, @@ -3365,46 +3559,11 @@ describe('PermissionHandler', () => { }, "type": "Box", }, - null, - ], - "direction": "horizontal", - }, - "type": "Box", - }, - { - "key": null, - "props": { - "children": [ - null, - { - "key": null, - "props": { - "children": { - "key": null, - "props": { - "children": "0x12345...67890", - }, - "type": "Text", - }, - "content": "0x1234567890123456789012345678901234567890", - }, - "type": "Tooltip", - }, ], "direction": "horizontal", }, "type": "Box", }, - ], - "direction": "horizontal", - }, - "type": "Box", - }, - { - "key": null, - "props": { - "alignment": "space-between", - "children": [ { "key": null, "props": { @@ -3413,75 +3572,75 @@ describe('PermissionHandler', () => { { "key": null, "props": { + "alignment": "space-between", "children": [ { "key": null, "props": { - "children": "Network", - }, - "type": "Text", - }, - { - "key": null, - "props": { - "children": { - "key": null, - "props": { - "color": "muted", - "name": "question", - "size": "inherit", + "children": [ + { + "key": null, + "props": { + "children": "Network", + }, + "type": "Text", }, - "type": "Icon", - }, - "content": { - "key": null, - "props": { - "children": "The network on which the permission is being requested", + { + "key": null, + "props": { + "children": { + "key": null, + "props": { + "color": "muted", + "name": "question", + "size": "inherit", + }, + "type": "Icon", + }, + "content": { + "key": null, + "props": { + "children": "The network on which the permission is being requested", + }, + "type": "Text", + }, + }, + "type": "Tooltip", }, - "type": "Text", - }, + ], + "direction": "horizontal", }, - "type": "Tooltip", + "type": "Box", }, + null, ], "direction": "horizontal", }, "type": "Box", }, - null, - ], - "direction": "horizontal", - }, - "type": "Box", - }, - { - "key": null, - "props": { - "children": [ - null, { "key": null, "props": { - "alignment": "end", - "children": "Ethereum Mainnet", + "children": [ + null, + { + "key": null, + "props": { + "alignment": "end", + "children": "Ethereum Mainnet", + }, + "type": "Text", + }, + ], + "direction": "horizontal", }, - "type": "Text", + "type": "Box", }, ], "direction": "horizontal", }, "type": "Box", }, - ], - "direction": "horizontal", - }, - "type": "Box", - }, - { - "key": null, - "props": { - "alignment": "space-between", - "children": [ { "key": null, "props": { @@ -3490,33 +3649,69 @@ describe('PermissionHandler', () => { { "key": null, "props": { + "alignment": "space-between", "children": [ { "key": null, "props": { - "children": "Token", + "children": [ + { + "key": null, + "props": { + "children": "Token", + }, + "type": "Text", + }, + { + "key": null, + "props": { + "children": { + "key": null, + "props": { + "color": "muted", + "name": "question", + "size": "inherit", + }, + "type": "Icon", + }, + "content": { + "key": null, + "props": { + "children": "The token being requested", + }, + "type": "Text", + }, + }, + "type": "Tooltip", + }, + ], + "direction": "horizontal", }, - "type": "Text", + "type": "Box", }, + null, + ], + "direction": "horizontal", + }, + "type": "Box", + }, + { + "key": null, + "props": { + "children": [ + null, { "key": null, "props": { "children": { "key": null, "props": { - "color": "muted", - "name": "question", - "size": "inherit", - }, - "type": "Icon", - }, - "content": { - "key": null, - "props": { - "children": "The token being requested", + "children": "ETH", + "href": "https://etherscan.io/address/0x38c4A4F071d33d6Cf83e2e81F12D9B5D30E611F3", }, - "type": "Text", + "type": "Link", }, + "content": "0x38c4A...611F3", }, "type": "Tooltip", }, @@ -3525,54 +3720,54 @@ describe('PermissionHandler', () => { }, "type": "Box", }, - null, - ], - "direction": "horizontal", - }, - "type": "Box", - }, - { - "key": null, - "props": { - "children": [ - null, - { - "key": null, - "props": { - "children": { - "key": null, - "props": { - "children": "ETH", - "href": "https://etherscan.io/address/0x38c4A4F071d33d6Cf83e2e81F12D9B5D30E611F3", - }, - "type": "Link", - }, - "content": "0x38c4A...611F3", - }, - "type": "Tooltip", - }, ], "direction": "horizontal", }, "type": "Box", }, ], - "direction": "horizontal", }, - "type": "Box", + "type": "Section", }, + undefined, ], + "direction": "vertical", }, - "type": "Section", + "type": "Box", }, - undefined, - ], - "direction": "vertical", + }, + "type": "Box", }, - "type": "Box", - }, + { + "key": null, + "props": { + "children": [ + { + "key": null, + "props": { + "children": "Cancel", + "name": "cancel-button", + "variant": "destructive", + }, + "type": "Button", + }, + { + "key": null, + "props": { + "children": "Grant", + "disabled": false, + "name": "grant-button", + "variant": "primary", + }, + "type": "Button", + }, + ], + }, + "type": "Footer", + }, + ], }, - "type": "Box", + "type": "Container", } `); }); @@ -3608,7 +3803,6 @@ describe('PermissionHandler', () => { interfaceId: mockInterfaceId, initialContext: mockContext, updateContext, - isAdjustmentAllowed: true, }); const accountSelectorChangeHandler = getBoundEvent({ @@ -3662,7 +3856,6 @@ describe('PermissionHandler', () => { interfaceId: mockInterfaceId, initialContext: mockContext, updateContext, - isAdjustmentAllowed: true, }); const accountSelectorBoundEvent = getBoundEvent({ @@ -3725,7 +3918,6 @@ describe('PermissionHandler', () => { interfaceId: mockInterfaceId, initialContext: mockContext, updateContext, - isAdjustmentAllowed: false, // Adjustment not allowed }); // Try to get a rule input handler - it should still be bound diff --git a/packages/gator-permissions-snap/test/core/permissionRequestLifecycleOrchestrator.test.ts b/packages/gator-permissions-snap/test/core/permissionRequestLifecycleOrchestrator.test.ts index 94223561..9ff46353 100644 --- a/packages/gator-permissions-snap/test/core/permissionRequestLifecycleOrchestrator.test.ts +++ b/packages/gator-permissions-snap/test/core/permissionRequestLifecycleOrchestrator.test.ts @@ -20,12 +20,17 @@ import { getChainMetadata } from '../../src/core/chainMetadata'; import type { ConfirmationDialog } from '../../src/core/confirmation'; import type { ConfirmationDialogFactory } from '../../src/core/confirmationFactory'; import type { DialogInterfaceFactory } from '../../src/core/dialogInterfaceFactory'; +import { + ExistingPermissionsService, + ExistingPermissionsState, +} from '../../src/core/existingpermissions/existingPermissionsService'; import type { PermissionIntroductionService } from '../../src/core/permissionIntroduction'; import { PermissionRequestLifecycleOrchestrator } from '../../src/core/permissionRequestLifecycleOrchestrator'; import type { BaseContext } from '../../src/core/types'; +import type { ProfileSyncManager } from '../../src/profileSync/profileSync'; +import type { NonceCaveatService } from '../../src/services/nonceCaveatService'; import type { SnapsMetricsService } from '../../src/services/snapsMetricsService'; -import { ExistingPermissionsService } from 'src/core/existingpermissions/existingPermissionsService'; -import type { NonceCaveatService } from 'src/services/nonceCaveatService'; +import type { TokenMetadataService } from '../../src/services/tokenMetadataService'; const randomAddress = (): Hex => { const randomBytes = new Uint8Array(20); @@ -136,11 +141,32 @@ const mockPermissionIntroductionService = { showIntroduction: jest.fn().mockResolvedValue({ wasCancelled: false }), } as unknown as jest.Mocked; +const existingPermissionsStatusHelper = new ExistingPermissionsService({ + profileSyncManager: { + getAllGrantedPermissions: jest.fn(), + } as unknown as ProfileSyncManager, + tokenMetadataService: {} as unknown as TokenMetadataService, +}); + const mockExistingPermissionsService = { - getExistingPermissions: jest.fn().mockResolvedValue([]), - showExistingPermissions: jest.fn().mockResolvedValue({ wasCancelled: false }), + getExistingPermissions: jest.fn(), + getExistingPermissionsStatusFromList: jest.fn(), + showExistingPermissions: jest.fn(), } as unknown as jest.Mocked; +// Set default mock implementations for existing permissions service +mockExistingPermissionsService.getExistingPermissions.mockResolvedValue([]); +mockExistingPermissionsService.getExistingPermissionsStatusFromList.mockImplementation( + (list, perm) => + existingPermissionsStatusHelper.getExistingPermissionsStatusFromList( + list, + perm, + ), +); +mockExistingPermissionsService.showExistingPermissions.mockResolvedValue( + undefined, +); + const mockScanAddressResult: FetchAddressScanResult = { resultType: AddressScanResultType.Benign, label: '', @@ -171,6 +197,19 @@ describe('PermissionRequestLifecycleOrchestrator', () => { beforeEach(() => { jest.clearAllMocks(); + // Reset existing permissions service mocks after clearing + mockExistingPermissionsService.getExistingPermissions.mockResolvedValue([]); + mockExistingPermissionsService.getExistingPermissionsStatusFromList.mockImplementation( + (list, perm) => + existingPermissionsStatusHelper.getExistingPermissionsStatusFromList( + list, + perm, + ), + ); + mockExistingPermissionsService.showExistingPermissions.mockResolvedValue( + undefined, + ); + lifecycleHandlerMocks = { parseAndValidatePermission: jest.fn().mockImplementation((req) => req), buildContext: jest.fn().mockResolvedValue(mockContext), @@ -277,6 +316,21 @@ describe('PermissionRequestLifecycleOrchestrator', () => { }); }); + it('loads existing permissions from profile sync only once per request', async () => { + await permissionRequestLifecycleOrchestrator.orchestrate( + 'test-origin', + mockPermissionRequest, + lifecycleHandlerMocks, + ); + + expect( + mockExistingPermissionsService.getExistingPermissions, + ).toHaveBeenCalledTimes(1); + expect( + mockExistingPermissionsService.getExistingPermissions, + ).toHaveBeenCalledWith('test-origin'); + }); + it('creates a skeleton confirmation before the context is resolved', async () => { // this never resolves, because we are testing the behavior _before_ the context is returned. const contextPromise = new Promise((_resolve) => { @@ -345,7 +399,6 @@ describe('PermissionRequestLifecycleOrchestrator', () => { expect(mockConfirmationDialog.updateContent).toHaveBeenCalledWith({ ui: mockUiContent, - isGrantDisabled: false, }); }); @@ -738,7 +791,7 @@ describe('PermissionRequestLifecycleOrchestrator', () => { }); it('serializes consecutive updateConfirmation calls so they run in order and do not overwrite each other', async () => { - // updateConfirmation is called with only newContext and isGrantDisabled; scan results are read from closure variables. + // updateConfirmation is called with optional newContext; scan results are read from closure variables. mockTrustSignalsClient.scanDappUrl.mockImplementationOnce( async (): Promise => new Promise(() => {}), @@ -1007,6 +1060,8 @@ describe('PermissionRequestLifecycleOrchestrator', () => { chainId: 1, scanDappUrlResult: null, scanAddressResult: null, + existingPermissionsStatus: ExistingPermissionsState.None, + isGrantDisabled: false, }); // Final call has both scan results from closure (after both background scans resolve) @@ -1019,6 +1074,8 @@ describe('PermissionRequestLifecycleOrchestrator', () => { chainId: 1, scanDappUrlResult: { isComplete: false }, scanAddressResult: mockScanAddressResult, + existingPermissionsStatus: ExistingPermissionsState.None, + isGrantDisabled: false, }); expect(mockTrustSignalsClient.fetchAddressScan).toHaveBeenCalledWith( @@ -1028,7 +1085,6 @@ describe('PermissionRequestLifecycleOrchestrator', () => { expect(mockConfirmationDialog.updateContent).toHaveBeenCalledWith({ ui: mockUiContent, - isGrantDisabled: false, }); });