diff --git a/packages/gator-permissions-snap/src/core/accountController.ts b/packages/gator-permissions-snap/src/core/accountController.ts index e293ce63..d64ec5c8 100644 --- a/packages/gator-permissions-snap/src/core/accountController.ts +++ b/packages/gator-permissions-snap/src/core/accountController.ts @@ -2,6 +2,7 @@ import { logger } from '@metamask/7715-permissions-shared/utils'; import { type Hex, type Delegation } from '@metamask/delegation-core'; import { ChainDisconnectedError, + InternalError, ResourceNotFoundError, ResourceUnavailableError, type SnapsEthereumProvider, @@ -12,6 +13,19 @@ import { bigIntToHex, hexToNumber, numberToHex } from '@metamask/utils'; import { getChainMetadata } from './chainMetadata'; import type { SignDelegationOptions } from './types'; +export type AccountUpgradeStatus = { + isUpgraded: boolean; +}; + +export type AccountUpgradeResult = { + transactionHash: string; +}; + +export type AccountUpgradeParams = { + account: string; + chainId: Hex; +}; + /** * Controls EOA account operations including address retrieval, delegation signing, and balance queries. */ @@ -168,4 +182,75 @@ export class AccountController { return { domain, types, primaryType, message, metadata }; } + + /** + * Checks if the account is already upgraded to a smart account. + * @param params - The account and chain ID to check. + * @returns Promise resolving to the upgrade status. + */ + public async getAccountUpgradeStatus( + params: AccountUpgradeParams, + ): Promise { + logger.debug('AccountController:getAccountUpgradeStatus()', params); + + try { + const result = (await this.#ethereumProvider.request({ + method: 'wallet_getAccountUpgradeStatus', + params: [params], + })) as { isUpgraded: boolean; upgradedAddress: Hex | null }; + + logger.debug('Account upgrade status result', result); + + const { + contracts: { eip7702StatelessDeleGatorImpl }, + } = getChainMetadata({ chainId: hexToNumber(params.chainId) }); + + return { + isUpgraded: + result.isUpgraded && + result.upgradedAddress?.toLowerCase() === + eip7702StatelessDeleGatorImpl.toLowerCase(), + }; + } catch (error) { + logger.error('Failed to check account upgrade status', error); + throw new InternalError('Failed to check account upgrade status'); + } + } + + /** + * Upgrades the account to a smart account. + * @param params - The account and chain ID to upgrade. + * @returns Promise resolving to the upgrade result with transaction hash. + */ + public async upgradeAccount( + params: AccountUpgradeParams, + ): Promise { + logger.debug('AccountController:upgradeAccount()', params); + + try { + const result = await this.#ethereumProvider.request({ + method: 'wallet_upgradeAccount', + params: [params], + }); + + logger.debug('Account upgrade result', result); + + // The result should contain a transaction hash + if ( + typeof result === 'object' && + result !== null && + 'transactionHash' in result + ) { + return { + transactionHash: (result as { transactionHash: string }) + .transactionHash, + }; + } + + throw new Error('Invalid upgrade result: missing transaction hash'); + } catch (error) { + logger.error('Failed to upgrade account', error); + throw new InternalError('Failed to upgrade account'); + } + } } diff --git a/packages/gator-permissions-snap/src/core/chainMetadata.ts b/packages/gator-permissions-snap/src/core/chainMetadata.ts index 0edf8deb..e60f77e0 100644 --- a/packages/gator-permissions-snap/src/core/chainMetadata.ts +++ b/packages/gator-permissions-snap/src/core/chainMetadata.ts @@ -3,6 +3,7 @@ import { numberToHex } from '@metamask/utils'; export type DelegationContracts = { delegationManager: Hex; + eip7702StatelessDeleGatorImpl: Hex; // Enforcers: limitedCallsEnforcer: Hex; @@ -18,6 +19,7 @@ export type DelegationContracts = { const contracts: DelegationContracts = { delegationManager: '0xdb9B1e94B5b69Df7e401DDbedE43491141047dB3', + eip7702StatelessDeleGatorImpl: '0x63c0c19a282a1B52b07dD5a65b58948A07DAE32B', limitedCallsEnforcer: '0x04658B29F6b82ed55274221a06Fc97D318E25416', erc20StreamingEnforcer: '0x56c97aE02f233B29fa03502Ecc0457266d9be00e', erc20PeriodTransferEnforcer: '0x474e3Ae7E169e940607cC624Da8A15Eb120139aB', diff --git a/packages/gator-permissions-snap/src/core/permissionHandler.ts b/packages/gator-permissions-snap/src/core/permissionHandler.ts index ed047852..c61e04de 100644 --- a/packages/gator-permissions-snap/src/core/permissionHandler.ts +++ b/packages/gator-permissions-snap/src/core/permissionHandler.ts @@ -8,6 +8,7 @@ import type { Hex } from '@metamask/utils'; import { bigIntToHex, isStrictHexString, + numberToHex, parseCaipAccountId, parseCaipAssetType, } from '@metamask/utils'; @@ -211,8 +212,17 @@ export class PermissionHandler< const { justification, tokenMetadata: { symbol: tokenSymbol }, + accountAddressCaip10, } = context; + // Check account upgrade status + const { address } = parseCaipAccountId(accountAddressCaip10); + const accountUpgradeStatus = + await this.#accountController.getAccountUpgradeStatus({ + account: address, + chainId: numberToHex(chainId), + }); + return PermissionHandlerContent({ origin, justification, @@ -227,6 +237,7 @@ export class PermissionHandler< tokenBalanceFiat: this.#tokenBalanceFiat, chainId, explorerUrl, + isAccountUpgraded: accountUpgradeStatus.isUpgraded, }); }; diff --git a/packages/gator-permissions-snap/src/core/permissionHandlerContent.tsx b/packages/gator-permissions-snap/src/core/permissionHandlerContent.tsx index 70265f98..c6a7df6e 100644 --- a/packages/gator-permissions-snap/src/core/permissionHandlerContent.tsx +++ b/packages/gator-permissions-snap/src/core/permissionHandlerContent.tsx @@ -47,6 +47,7 @@ export type PermissionHandlerContentProps = { tokenBalanceFiat: string | null; chainId: number; explorerUrl: string | undefined; + isAccountUpgraded: boolean; }; /** @@ -65,6 +66,7 @@ export type PermissionHandlerContentProps = { * @param options.tokenBalanceFiat - The formatted fiat balance of the token. * @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. * @returns The confirmation content. */ export const PermissionHandlerContent = ({ @@ -81,8 +83,11 @@ export const PermissionHandlerContent = ({ tokenBalanceFiat, chainId, explorerUrl, + isAccountUpgraded, }: PermissionHandlerContentProps): SnapElement => { - const tokenBalanceComponent = TokenBalanceField({ tokenBalance }); + const tokenBalanceComponent = TokenBalanceField({ + tokenBalance: tokenBalance ?? undefined, + }); const fiatBalanceComponent = tokenBalanceFiat ? ( {tokenBalanceFiat} @@ -155,6 +160,12 @@ export const PermissionHandlerContent = ({ switchGlobalAccount={false} value={context.accountAddressCaip10} /> + {!isAccountUpgraded && ( + + This account will be upgraded to a smart account to complete + this permission. + + )} {fiatBalanceComponent} {tokenBalanceComponent} diff --git a/packages/gator-permissions-snap/src/core/permissionRequestLifecycleOrchestrator.ts b/packages/gator-permissions-snap/src/core/permissionRequestLifecycleOrchestrator.ts index 271cca4c..800f9d50 100644 --- a/packages/gator-permissions-snap/src/core/permissionRequestLifecycleOrchestrator.ts +++ b/packages/gator-permissions-snap/src/core/permissionRequestLifecycleOrchestrator.ts @@ -20,6 +20,7 @@ import { bytesToHex, hexToNumber, numberToHex, + parseCaipAccountId, } from '@metamask/utils'; import type { NonceCaveatService } from 'src/services/nonceCaveatService'; @@ -220,6 +221,24 @@ export class PermissionRequestLifecycleOrchestrator { // all user input has been processed await this.#userEventDispatcher.waitForPendingHandlers(); + // Check if account needs to be upgraded before processing the permission + // We check again because the account could have been upgraded in the time since permission request was created + // especially if we consider a scenario where we have a permission batch with the same account. + const { address } = parseCaipAccountId(context.accountAddressCaip10); + const upgradeStatus = + await this.#accountController.getAccountUpgradeStatus({ + account: address, + chainId: numberToHex(chainId), + }); + + if (!upgradeStatus.isUpgraded) { + // Trigger account upgrade + await this.#accountController.upgradeAccount({ + account: address, + chainId: numberToHex(chainId), + }); + } + const response = await this.#resolveResponse({ originalRequest: validatedPermissionRequest, modifiedContext: context, diff --git a/packages/gator-permissions-snap/test/core/accountController.test.ts b/packages/gator-permissions-snap/test/core/accountController.test.ts index 41a3c33b..b5ba7ea9 100644 --- a/packages/gator-permissions-snap/test/core/accountController.test.ts +++ b/packages/gator-permissions-snap/test/core/accountController.test.ts @@ -91,6 +91,41 @@ describe('AccountController', () => { }); }); + describe('getAccountUpgradeStatus()', () => { + it('should return upgrade status', async () => { + mockEthereumProvider.request.mockResolvedValueOnce({ + isUpgraded: true, + upgradedAddress: '0x63c0c19a282a1B52b07dD5a65b58948A07DAE32B', // eip7702StatelessDeleGatorImpl address + }); + + const result = await accountController.getAccountUpgradeStatus({ + account: mockAddress, + chainId: sepolia, + }); + + expect(result).toStrictEqual({ isUpgraded: true }); + }); + }); + + describe('upgradeAccount()', () => { + it('should upgrade account and return transaction hash', async () => { + const mockTransactionHash = + '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; + mockEthereumProvider.request.mockResolvedValueOnce({ + transactionHash: mockTransactionHash, + }); + + const result = await accountController.upgradeAccount({ + account: mockAddress, + chainId: sepolia, + }); + + expect(result).toStrictEqual({ + transactionHash: mockTransactionHash, + }); + }); + }); + describe('signDelegation()', () => { const unsignedDelegation: Omit = { delegate: '0x1234567890abcdef1234567890abcdef12345678', diff --git a/packages/gator-permissions-snap/test/core/chainMetadata.test.ts b/packages/gator-permissions-snap/test/core/chainMetadata.test.ts index 4503d783..b4bbcef2 100644 --- a/packages/gator-permissions-snap/test/core/chainMetadata.test.ts +++ b/packages/gator-permissions-snap/test/core/chainMetadata.test.ts @@ -22,6 +22,7 @@ describe('chainMetadata', () => { const metadataSchema = { contracts: { delegationManager: expect.any(String), + eip7702StatelessDeleGatorImpl: expect.any(String), limitedCallsEnforcer: expect.any(String), erc20StreamingEnforcer: expect.any(String), erc20PeriodTransferEnforcer: expect.any(String), diff --git a/packages/gator-permissions-snap/test/core/permissionHandler.test.ts b/packages/gator-permissions-snap/test/core/permissionHandler.test.ts index 11db2f63..de7ccc18 100644 --- a/packages/gator-permissions-snap/test/core/permissionHandler.test.ts +++ b/packages/gator-permissions-snap/test/core/permissionHandler.test.ts @@ -42,7 +42,6 @@ type TestLifecycleHandlersType = LifecycleOrchestrationHandlers< const mockPermissionRequest: PermissionRequest = { chainId: '0x1', - expiry: 1234567890, signer: { type: 'account', data: { @@ -69,7 +68,10 @@ const mockContext: TestContextType = { }, accountAddressCaip10: `eip155:1:${mockAddress}`, tokenAddressCaip19: `eip155:1/erc20:${mockAssetAddress}`, - expiry: '1234567890', + expiry: { + timestamp: 1234567890, + isAdjustmentAllowed: false, + }, isAdjustmentAllowed: false, }; @@ -135,8 +137,14 @@ const setupTest = () => { const accountController = { signDelegation: jest.fn(), getAccountAddresses: jest.fn(), + getAccountUpgradeStatus: jest.fn(), + upgradeAccount: jest.fn(), } as unknown as jest.Mocked; + accountController.getAccountUpgradeStatus.mockResolvedValue({ + isUpgraded: false, + }); + userEventDispatcher = { on: jest.fn(bindEvent), off: jest.fn(), @@ -1031,6 +1039,15 @@ describe('PermissionHandler', () => { }, "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": { @@ -1539,6 +1556,15 @@ describe('PermissionHandler', () => { }, "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": { @@ -2085,6 +2111,15 @@ describe('PermissionHandler', () => { }, "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": { @@ -2559,6 +2594,15 @@ describe('PermissionHandler', () => { }, "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": { @@ -3038,6 +3082,15 @@ describe('PermissionHandler', () => { }, "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": { diff --git a/packages/gator-permissions-snap/test/core/permissionRequestLifecycleOrchestrator.test.ts b/packages/gator-permissions-snap/test/core/permissionRequestLifecycleOrchestrator.test.ts index 4ce6be47..8c73de6e 100644 --- a/packages/gator-permissions-snap/test/core/permissionRequestLifecycleOrchestrator.test.ts +++ b/packages/gator-permissions-snap/test/core/permissionRequestLifecycleOrchestrator.test.ts @@ -20,18 +20,23 @@ import type { UserEventDispatcher } from '../../src/userEventDispatcher'; const randomAddress = () => { /* eslint-disable no-restricted-globals */ - const randomBytes = crypto.getRandomValues(new Uint8Array(20)); + const randomBytes = new Uint8Array(20); + for (let i = 0; i < 20; i++) { + randomBytes[i] = Math.floor(Math.random() * 256); + } return bytesToHex(randomBytes); }; const mockSignature = '0x1234'; const mockInterfaceId = 'test-interface-id'; const grantingAccountAddress = randomAddress(); +const fixedCaip10Address = `eip155:1:${grantingAccountAddress}`; const mockContext = { expiry: '2024-12-31', isAdjustmentAllowed: true, address: grantingAccountAddress, + accountAddressCaip10: fixedCaip10Address, }; const mockMetadata = { @@ -89,6 +94,8 @@ const mockPopulatedPermission = { const mockAccountController = { signDelegation: jest.fn(), getAccountAddresses: jest.fn(), + getAccountUpgradeStatus: jest.fn(async () => ({ isUpgraded: false })), + upgradeAccount: jest.fn().mockResolvedValue(undefined), } as unknown as jest.Mocked; const mockConfirmationDialog = { @@ -155,6 +162,10 @@ describe('PermissionRequestLifecycleOrchestrator', () => { }), ); + mockAccountController.getAccountUpgradeStatus.mockImplementation( + async () => ({ isUpgraded: false }), + ); + mockConfirmationDialogFactory.createConfirmation.mockReturnValue( mockConfirmationDialog, ); @@ -483,12 +494,57 @@ describe('PermissionRequestLifecycleOrchestrator', () => { 'Expiry rule not found. An expiry is required on all permissions.', ); }); + it('checks account upgrade status and triggers upgrade when needed', async () => { + mockAccountController.getAccountUpgradeStatus.mockResolvedValueOnce({ + isUpgraded: false, + }); + + const result = await permissionRequestLifecycleOrchestrator.orchestrate( + 'test-origin', + mockPermissionRequest, + lifecycleHandlerMocks, + ); + + expect( + mockAccountController.getAccountUpgradeStatus, + ).toHaveBeenCalledWith({ + account: grantingAccountAddress, + chainId: 1, + }); + expect(mockAccountController.upgradeAccount).toHaveBeenCalledWith({ + account: grantingAccountAddress, + chainId: 1, + }); + expect(result.approved).toBe(true); + }); + + it('does not trigger upgrade when account is already upgraded', async () => { + mockAccountController.getAccountUpgradeStatus.mockResolvedValueOnce({ + isUpgraded: true, + }); + + const result = await permissionRequestLifecycleOrchestrator.orchestrate( + 'test-origin', + mockPermissionRequest, + lifecycleHandlerMocks, + ); + + expect( + mockAccountController.getAccountUpgradeStatus, + ).toHaveBeenCalledWith({ + account: grantingAccountAddress, + chainId: 1, + }); + expect(mockAccountController.upgradeAccount).not.toHaveBeenCalled(); + expect(result.approved).toBe(true); + }); + it('correctly sets up the onConfirmationCreated hook to update the context', async () => { const initialContext = { foo: 'original', expiry: '2024-12-31', isAdjustmentAllowed: true, - accountAddressCaip10: grantingAccountAddress, + accountAddressCaip10: fixedCaip10Address, tokenAddressCaip19: 'eip155:1:0x1234', tokenMetadata: { decimals: 18, @@ -500,7 +556,7 @@ describe('PermissionRequestLifecycleOrchestrator', () => { foo: 'updated', expiry: '2025-01-01', isAdjustmentAllowed: true, - accountAddressCaip10: grantingAccountAddress, + accountAddressCaip10: fixedCaip10Address, tokenAddressCaip19: 'eip155:1:0x1234', tokenMetadata: { decimals: 18, @@ -567,6 +623,7 @@ describe('PermissionRequestLifecycleOrchestrator', () => { foo: 'bar', expiry: '2024-12-31', isAdjustmentAllowed: false, // Adjustment not allowed + accountAddressCaip10: fixedCaip10Address, }; const mockPermissionRequestWithAdjustmentNotAllowed = { @@ -625,7 +682,8 @@ describe('PermissionRequestLifecycleOrchestrator', () => { * 4. Applies context to resolve the permission request. * 5. Populates the permission with required values. * 6. Appends caveats to the permission. - * 7. Signs the delegation for the permission. + * 7. Checks and upgrades account if necessary. + * 8. Signs the delegation for the permission. */ beforeEach(async () => { @@ -726,7 +784,19 @@ describe('PermissionRequestLifecycleOrchestrator', () => { }); /* - * 7. Signs the delegation for the permission. + * 7. Checks and upgrades account if necessary. + */ + it('checks account upgrade status before processing permission', async () => { + expect( + mockAccountController.getAccountUpgradeStatus, + ).toHaveBeenCalledWith({ + account: grantingAccountAddress, + chainId: 1, + }); + }); + + /* + * 8. Signs the delegation for the permission. */ it('signs the delegation for the permission', async () => { expect(mockAccountController.signDelegation).toHaveBeenCalledWith(