diff --git a/packages/delegation-toolkit/src/actions/index.ts b/packages/delegation-toolkit/src/actions/index.ts index 5c8bd93b..e7578b77 100644 --- a/packages/delegation-toolkit/src/actions/index.ts +++ b/packages/delegation-toolkit/src/actions/index.ts @@ -24,3 +24,5 @@ export { type PeriodTransferResult, type StreamingResult, } from './getCaveatAvailableAmount'; + +export { isValid7702Implementation } from './isValid7702Implementation'; diff --git a/packages/delegation-toolkit/src/actions/isValid7702Implementation.ts b/packages/delegation-toolkit/src/actions/isValid7702Implementation.ts new file mode 100644 index 00000000..9b72b50d --- /dev/null +++ b/packages/delegation-toolkit/src/actions/isValid7702Implementation.ts @@ -0,0 +1,102 @@ +import type { Client, Address, Hex } from 'viem'; +import { isAddressEqual } from 'viem'; +import { getCode } from 'viem/actions'; + +import type { DeleGatorEnvironment } from '../types'; + +// EIP-7702 delegation prefix (0xef0100) +const DELEGATION_PREFIX = '0xef0100' as const; + +/** + * Parameters for checking if an account is delegated to the EIP-7702 implementation. + */ +export type IsValid7702ImplementationParameters = { + /** The client to use for the query. */ + client: Client; + /** The address to check for proper delegation. */ + accountAddress: Address; + /** The DeleGator environment containing contract addresses. */ + environment: DeleGatorEnvironment; +}; + +/** + * Extracts the delegated contract address from EIP-7702 delegation code. + * + * @param code - The code returned from getCode for a delegated account. + * @returns The delegated contract address or null if not a valid delegation. + */ +function extractDelegatedAddress(code: Hex | undefined): Address | null { + if (code?.length !== 48) { + // 0x (2 chars) + ef0100 (6 chars) + address (40 chars) = 48 chars + return null; + } + + if (!code.toLowerCase().startsWith(DELEGATION_PREFIX.toLowerCase())) { + return null; + } + + // Extract the 20-byte address after the delegation prefix + const addressHex = code.slice(8); // Remove '0xef0100' prefix (8 chars) + return `0x${addressHex}`; +} + +/** + * Checks if an account is properly delegated to the EIP-7702 implementation. + * + * This function validates EIP-7702 delegations by checking if the EOA has a 7702 + * contract assigned to it and comparing the delegated address against the 7702 + * implementation found in the environment. + * + * @param params - The parameters for checking the delegation. + * @param params.client - The client to use for the query. + * @param params.accountAddress - The address to check for proper delegation. + * @param params.environment - The DeleGator environment containing contract addresses. + * @returns A promise that resolves to true if the account is properly delegated to the 7702 implementation, false otherwise. + * @example + * ```typescript + * const isValid = await isValid7702Implementation({ + * client: publicClient, + * accountAddress: '0x...', + * environment: delegatorEnvironment, + * }); + * + * if (isValid) { + * console.log('Account is properly delegated to EIP-7702 implementation'); + * } else { + * console.log('Account is not properly delegated'); + * } + * ``` + */ +export async function isValid7702Implementation({ + client, + accountAddress, + environment, +}: IsValid7702ImplementationParameters): Promise { + try { + // Get the code at the account address + const code = await getCode(client, { + address: accountAddress, + }); + + // Extract the delegated contract address from the EIP-7702 delegation code + const delegatedAddress = extractDelegatedAddress(code); + + // If no valid delegation found, return false + if (!delegatedAddress) { + return false; + } + + // Compare the delegated address with the 7702 implementation in the environment + const expectedImplementation = + environment.implementations.EIP7702StatelessDeleGatorImpl; + if (!expectedImplementation) { + return false; + } + + return isAddressEqual(delegatedAddress, expectedImplementation); + } catch (error) { + // If the call fails (e.g., no code at address, network error), + // then it's not properly delegated to our implementation + return false; + } +} diff --git a/packages/delegation-toolkit/src/toMetaMaskSmartAccount.ts b/packages/delegation-toolkit/src/toMetaMaskSmartAccount.ts index 8eaef924..f75bda2a 100644 --- a/packages/delegation-toolkit/src/toMetaMaskSmartAccount.ts +++ b/packages/delegation-toolkit/src/toMetaMaskSmartAccount.ts @@ -10,6 +10,7 @@ import { toSmartAccount, } from 'viem/account-abstraction'; +import { isValid7702Implementation } from './actions/isValid7702Implementation'; import { Implementation } from './constants'; import { getCounterfactualAccountData } from './counterfactualAccountData'; import { @@ -197,5 +198,19 @@ export async function toMetaMaskSmartAccount< ...signatory, }); + // Override isDeployed only for EIP-7702 implementation to check proper delegation code + if (implementation === Implementation.Stateless7702) { + return { + ...smartAccount, + isDeployed: async () => + isValid7702Implementation({ + client, + accountAddress: address, + environment, + }), + }; + } + + // For other implementations, use the default isDeployed behavior return smartAccount; } diff --git a/packages/delegation-toolkit/test/actions/isValid7702Implementation.test.ts b/packages/delegation-toolkit/test/actions/isValid7702Implementation.test.ts new file mode 100644 index 00000000..bb3fb3e3 --- /dev/null +++ b/packages/delegation-toolkit/test/actions/isValid7702Implementation.test.ts @@ -0,0 +1,431 @@ +import type { PublicClient, Hex } from 'viem'; +import { createPublicClient, http } from 'viem'; +import { getCode } from 'viem/actions'; +import { foundry } from 'viem/chains'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { isValid7702Implementation } from '../../src/actions/isValid7702Implementation'; +import type { DeleGatorEnvironment } from '../../src/types'; + +// Mock the getCode function from viem +vi.mock('viem/actions', () => ({ + getCode: vi.fn(), +})); +const mockGetCode = vi.mocked(getCode); + +describe('isValid7702Implementation', () => { + let publicClient: PublicClient; + let mockEnvironment: DeleGatorEnvironment; + + beforeEach(() => { + publicClient = createPublicClient({ + chain: foundry, + transport: http(), + }); + + mockEnvironment = { + DelegationManager: '0x1000000000000000000000000000000000000000', + EntryPoint: '0x2000000000000000000000000000000000000000', + SimpleFactory: '0x3000000000000000000000000000000000000000', + implementations: { + EIP7702StatelessDeleGatorImpl: + '0x4000000000000000000000000000000000000000', + HybridDeleGatorImpl: '0x5000000000000000000000000000000000000000', + MultiSigDeleGatorImpl: '0x6000000000000000000000000000000000000000', + }, + caveatEnforcers: {}, + }; + + // Reset all mocks before each test + vi.clearAllMocks(); + }); + + describe('Success Cases', () => { + it('should return true when account has valid EIP-7702 delegation to correct implementation', async () => { + const testAddress = '0x1234567890123456789012345678901234567890'; + const delegationCode = '0xef01004000000000000000000000000000000000000000'; + mockGetCode.mockResolvedValue(delegationCode); + + const result = await isValid7702Implementation({ + client: publicClient, + accountAddress: testAddress, + environment: mockEnvironment, + }); + + expect(result).toBe(true); + expect(mockGetCode).toHaveBeenCalledWith(publicClient, { + address: testAddress, + }); + }); + + it('should return true when delegation code uses different case but matches implementation', async () => { + const testAddress = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; + const delegationCode = '0xef0100abcdef1234567890abcdef1234567890abcdef00'; + mockGetCode.mockResolvedValue(delegationCode); + + // Update environment with uppercase address to test case insensitivity + mockEnvironment.implementations.EIP7702StatelessDeleGatorImpl = + '0xABCDEF1234567890ABCDEF1234567890ABCDEF00'; + + const result = await isValid7702Implementation({ + client: publicClient, + accountAddress: testAddress, + environment: mockEnvironment, + }); + + expect(result).toBe(true); + }); + + it('should return true when delegation prefix uses different case (case-insensitive prefix)', async () => { + const testAddress = '0xdddddddddddddddddddddddddddddddddddddddd'; + // Use uppercase delegation prefix - this should now pass because we handle case-insensitivity + const delegationCode = '0xEF01004000000000000000000000000000000000000000'; + mockGetCode.mockResolvedValue(delegationCode); + + const result = await isValid7702Implementation({ + client: publicClient, + accountAddress: testAddress, + environment: mockEnvironment, + }); + + expect(result).toBe(true); + }); + + it('should return true when delegation prefix uses mixed case', async () => { + const testAddress = '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'; + // Use mixed case delegation prefix to test case-insensitivity thoroughly + const delegationCode = '0xEf01004000000000000000000000000000000000000000'; + mockGetCode.mockResolvedValue(delegationCode); + + const result = await isValid7702Implementation({ + client: publicClient, + accountAddress: testAddress, + environment: mockEnvironment, + }); + + expect(result).toBe(true); + }); + }); + + describe('Failure Cases', () => { + it('should return false when getCode throws an error (no code)', async () => { + const testAddress = '0x2222222222222222222222222222222222222222'; + mockGetCode.mockRejectedValue(new Error('No code at address')); + + const result = await isValid7702Implementation({ + client: publicClient, + accountAddress: testAddress, + environment: mockEnvironment, + }); + + expect(result).toBe(false); + }); + + it('should return false when account has no code', async () => { + const testAddress = '0x3333333333333333333333333333333333333333'; + mockGetCode.mockResolvedValue(undefined); + + const result = await isValid7702Implementation({ + client: publicClient, + accountAddress: testAddress, + environment: mockEnvironment, + }); + + expect(result).toBe(false); + }); + + it('should return false when code does not start with EIP-7702 prefix', async () => { + const testAddress = '0x4444444444444444444444444444444444444444'; + const regularCode = '0x608060405234801561001057600080fd5b50'; + mockGetCode.mockResolvedValue(regularCode); + + const result = await isValid7702Implementation({ + client: publicClient, + accountAddress: testAddress, + environment: mockEnvironment, + }); + + expect(result).toBe(false); + }); + + it('should return false when delegation code has wrong length', async () => { + const testAddress = '0x5555555555555555555555555555555555555555'; + const shortCode = '0xef0100'; + mockGetCode.mockResolvedValue(shortCode); + + const result = await isValid7702Implementation({ + client: publicClient, + accountAddress: testAddress, + environment: mockEnvironment, + }); + + expect(result).toBe(false); + }); + + it('should return false when delegated address does not match environment implementation', async () => { + const testAddress = '0x6666666666666666666666666666666666666666'; + const delegationCode = '0xef01009999999999999999999999999999999999999999'; // Wrong address + mockGetCode.mockResolvedValue(delegationCode); + + const result = await isValid7702Implementation({ + client: publicClient, + accountAddress: testAddress, + environment: mockEnvironment, + }); + + expect(result).toBe(false); + }); + + it('should return false when environment has no EIP7702StatelessDeleGatorImpl', async () => { + const testAddress = '0x7777777777777777777777777777777777777777'; + const delegationCode = '0xef01004000000000000000000000000000000000000000'; + mockGetCode.mockResolvedValue(delegationCode); + + // Remove the implementation from environment + delete mockEnvironment.implementations.EIP7702StatelessDeleGatorImpl; + + const result = await isValid7702Implementation({ + client: publicClient, + accountAddress: testAddress, + environment: mockEnvironment, + }); + + expect(result).toBe(false); + }); + + it('should return false when code has correct prefix but wrong total length', async () => { + const testAddress = '0x8888888888888888888888888888888888888888'; + const malformedCode = + '0xef010040000000000000000000000000000000000000000000' as Hex; // Too long + mockGetCode.mockResolvedValue(malformedCode); + + const result = await isValid7702Implementation({ + client: publicClient, + accountAddress: testAddress, + environment: mockEnvironment, + }); + + expect(result).toBe(false); + }); + + it('should return false when code is empty string', async () => { + const testAddress = '0x9999999999999999999999999999999999999999'; + const emptyCode = '' as Hex; + mockGetCode.mockResolvedValue(emptyCode); + + const result = await isValid7702Implementation({ + client: publicClient, + accountAddress: testAddress, + environment: mockEnvironment, + }); + + expect(result).toBe(false); + }); + + it('should return false when code is boundary length 47 chars', async () => { + const testAddress = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const shortByOneCode = + '0xef0100400000000000000000000000000000000000000' as Hex; // 47 chars + mockGetCode.mockResolvedValue(shortByOneCode); + + const result = await isValid7702Implementation({ + client: publicClient, + accountAddress: testAddress, + environment: mockEnvironment, + }); + + expect(result).toBe(false); + }); + + it('should return false when code is boundary length 49 chars', async () => { + const testAddress = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + const longByOneCode = + '0xef01004000000000000000000000000000000000000000a' as Hex; // 49 chars + mockGetCode.mockResolvedValue(longByOneCode); + + const result = await isValid7702Implementation({ + client: publicClient, + accountAddress: testAddress, + environment: mockEnvironment, + }); + + expect(result).toBe(false); + }); + }); + + describe('Edge Cases', () => { + it('should handle different case addresses correctly', async () => { + const testAddress = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const delegationCode = '0xef01004000000000000000000000000000000000000000'; + mockGetCode.mockResolvedValue(delegationCode); + + // Environment has uppercase address + mockEnvironment.implementations.EIP7702StatelessDeleGatorImpl = + '0x4000000000000000000000000000000000000000'; + + const result = await isValid7702Implementation({ + client: publicClient, + accountAddress: testAddress, + environment: mockEnvironment, + }); + + expect(result).toBe(true); + }); + + it('should extract address correctly from delegation code', async () => { + const testAddress = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + const targetImpl = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; + const delegationCode = `0xef0100${targetImpl.slice(2)}` as Hex; // Remove 0x prefix + mockGetCode.mockResolvedValue(delegationCode); + + mockEnvironment.implementations.EIP7702StatelessDeleGatorImpl = + targetImpl; + + const result = await isValid7702Implementation({ + client: publicClient, + accountAddress: testAddress, + environment: mockEnvironment, + }); + + expect(result).toBe(true); + }); + + it('should verify the correct parameters are passed to getCode', async () => { + const testAddress = '0xcccccccccccccccccccccccccccccccccccccccc'; + const delegationCode = '0xef01004000000000000000000000000000000000000000'; + mockGetCode.mockResolvedValue(delegationCode); + + await isValid7702Implementation({ + client: publicClient, + accountAddress: testAddress, + environment: mockEnvironment, + }); + + expect(mockGetCode).toHaveBeenCalledWith(publicClient, { + address: testAddress, + }); + }); + + it('should handle network errors gracefully', async () => { + const testAddress = '0xdddddddddddddddddddddddddddddddddddddddd'; + mockGetCode.mockRejectedValue(new Error('Network error')); + + const result = await isValid7702Implementation({ + client: publicClient, + accountAddress: testAddress, + environment: mockEnvironment, + }); + + expect(result).toBe(false); + }); + + it('should handle contract call errors gracefully', async () => { + const testAddress = '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'; + mockGetCode.mockRejectedValue(new Error('Contract call failed')); + + const result = await isValid7702Implementation({ + client: publicClient, + accountAddress: testAddress, + environment: mockEnvironment, + }); + + expect(result).toBe(false); + }); + + it('should call getCode exactly once per invocation', async () => { + const testAddress = '0xffffffffffffffffffffffffffffffffffffffff'; + const delegationCode = '0xef01004000000000000000000000000000000000000000'; + mockGetCode.mockResolvedValue(delegationCode); + + await isValid7702Implementation({ + client: publicClient, + accountAddress: testAddress, + environment: mockEnvironment, + }); + + expect(mockGetCode).toHaveBeenCalledTimes(1); + expect(mockGetCode).toHaveBeenCalledExactlyOnceWith(publicClient, { + address: testAddress, + }); + }); + }); + + describe('EIP-7702 Delegation Format', () => { + it('should correctly parse valid delegation prefix', async () => { + const testAddress = '0x1111111111111111111111111111111111111111'; + const validPrefix = '0xef0100'; + const targetAddress = '4000000000000000000000000000000000000000'; + const fullCode = (validPrefix + targetAddress) as Hex; + mockGetCode.mockResolvedValue(fullCode); + + const result = await isValid7702Implementation({ + client: publicClient, + accountAddress: testAddress, + environment: mockEnvironment, + }); + + expect(result).toBe(true); + }); + + it('should reject invalid delegation prefix', async () => { + const testAddress = '0x2222222222222222222222222222222222222222'; + const invalidPrefix = '0xef0101'; // Wrong prefix + const targetAddress = '4000000000000000000000000000000000000000'; + const fullCode = (invalidPrefix + targetAddress) as Hex; + mockGetCode.mockResolvedValue(fullCode); + + const result = await isValid7702Implementation({ + client: publicClient, + accountAddress: testAddress, + environment: mockEnvironment, + }); + + expect(result).toBe(false); + }); + + it('should validate the exact EIP-7702 delegation prefix format', async () => { + const testAddress = '0x3333333333333333333333333333333333333333'; + // Test with the exact prefix that should be used (0xef0100) + const correctPrefix = '0xef0100'; + const targetAddress = '4000000000000000000000000000000000000000'; + const fullCode = (correctPrefix + targetAddress) as Hex; + mockGetCode.mockResolvedValue(fullCode); + + const result = await isValid7702Implementation({ + client: publicClient, + accountAddress: testAddress, + environment: mockEnvironment, + }); + + expect(result).toBe(true); + }); + + it('should handle extreme case differences in addresses', async () => { + const testAddress = '0x4444444444444444444444444444444444444444'; + // Use the same base address as the default environment but test case sensitivity + const baseAddress = '4000000000000000000000000000000000000000'; + const delegationCode = `0xef0100${baseAddress}` as Hex; + mockGetCode.mockResolvedValue(delegationCode); + + // Create uppercase implementation address - properly typed + const uppercaseImplementation = `0x${baseAddress.toUpperCase()}` as const; + + // Create a fresh environment with uppercase implementation address + const caseTestEnvironment = { + ...mockEnvironment, + implementations: { + ...mockEnvironment.implementations, + EIP7702StatelessDeleGatorImpl: uppercaseImplementation, + }, + }; + + const result = await isValid7702Implementation({ + client: publicClient, + accountAddress: testAddress, + environment: caseTestEnvironment, + }); + + expect(result).toBe(true); + }); + }); +}); diff --git a/packages/delegator-e2e/test/execute-stateless7702.test.ts b/packages/delegator-e2e/test/execute-stateless7702.test.ts index 3fc4f0f6..dbc1f651 100644 --- a/packages/delegator-e2e/test/execute-stateless7702.test.ts +++ b/packages/delegator-e2e/test/execute-stateless7702.test.ts @@ -15,6 +15,7 @@ import { toMetaMaskSmartAccount, MetaMaskSmartAccount, } from '@metamask/delegation-toolkit'; +import { isValid7702Implementation } from '@metamask/delegation-toolkit/actions'; import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; import { createClient, encodeFunctionData, parseEther } from 'viem'; import { chain } from '../src/config'; @@ -401,3 +402,113 @@ test('Alice can check the contract version and name', async () => { expect(domainVersion, 'Domain version should be 1').toBe('1'); }); + +test('isDeployed() method correctly identifies EIP7702 delegation for Stateless7702 accounts', async () => { + // Test that Alice's smart account's isDeployed() method returns true + // because her EOA is properly delegated to EIP7702StatelessDeleGator + const isAliceDeployed = await aliceSmartAccount.isDeployed(); + + expect( + isAliceDeployed, + 'Alice smart account should report as deployed when properly delegated to EIP7702StatelessDeleGator', + ).toBe(true); + + // Create a smart account for a non-delegated EOA + const nonDelegatedAccount = privateKeyToAccount(generatePrivateKey()); + const client = createClient({ transport, chain }); + + const nonDelegatedSmartAccount = await toMetaMaskSmartAccount({ + client, + implementation: Implementation.Stateless7702, + address: nonDelegatedAccount.address, + signatory: { account: nonDelegatedAccount }, + }); + + // Test that a non-delegated account's isDeployed() method returns false + const isNonDelegatedDeployed = await nonDelegatedSmartAccount.isDeployed(); + + expect( + isNonDelegatedDeployed, + 'Non-delegated smart account should report as not deployed', + ).toBe(false); + + // Verify that the non-delegated account has no code at all + const code = await publicClient.getCode({ + address: nonDelegatedAccount.address, + }); + expect(code, 'Non-delegated account should have no code').toBeUndefined(); +}); + +test('isDeployed() returns false for addresses with code that are not delegated to EIP7702StatelessDeleGator', async () => { + // Deploy a regular contract to get an address with code + const counterContract = await deployCounter(aliceSmartAccount.address); + + // Verify the contract has code + const contractCode = await publicClient.getCode({ + address: counterContract.address, + }); + expect(contractCode, 'Contract should have code deployed').toBeDefined(); + expect( + contractCode!.length, + 'Contract code should not be empty', + ).toBeGreaterThan(2); // More than just '0x' + + // Create a Stateless7702 smart account pointing to this contract address + const contractAccount = privateKeyToAccount(generatePrivateKey()); + + const contractSmartAccount = await toMetaMaskSmartAccount({ + client: publicClient, + implementation: Implementation.Stateless7702, + address: counterContract.address, // Point to the contract address + signatory: { account: contractAccount }, + }); + + // Test that isDeployed() returns false even though there is code at the address + // because the code is not an EIP7702StatelessDeleGator delegation + const isContractDeployed = await contractSmartAccount.isDeployed(); + + expect( + isContractDeployed, + 'Smart account should report as not deployed when address has non-EIP7702StatelessDeleGator code', + ).toBe(false); + + // Also test with the standalone function to show it would return false too + + const isContractDelegated = await isValid7702Implementation({ + client: publicClient, + accountAddress: counterContract.address, + environment: aliceSmartAccount.environment, + }); + + expect( + isContractDelegated, + 'Contract address should not be identified as EIP7702StatelessDeleGator delegation', + ).toBe(false); +}); + +test('isValid7702Implementation works with EIP-7702 delegations', async () => { + // Test that Alice's account (which is delegated to EIP7702StatelessDeleGator) returns true + const isValidStateless = await isValid7702Implementation({ + client: publicClient, + accountAddress: aliceSmartAccount.address, + environment: aliceSmartAccount.environment, + }); + + expect( + isValidStateless, + 'Alice account should be valid for EIP-7702 implementation', + ).toBe(true); + + // Test with a random non-delegated address + const randomAddress = '0x1234567890123456789012345678901234567890'; + const isRandomValid = await isValid7702Implementation({ + client: publicClient, + accountAddress: randomAddress, + environment: aliceSmartAccount.environment, + }); + + expect( + isRandomValid, + 'Random address should not be valid for EIP-7702 implementation', + ).toBe(false); +});