diff --git a/packages/delegation-toolkit/src/actions/index.ts b/packages/delegation-toolkit/src/actions/index.ts index e2efd1b9..83cb612e 100644 --- a/packages/delegation-toolkit/src/actions/index.ts +++ b/packages/delegation-toolkit/src/actions/index.ts @@ -1,4 +1 @@ -/* eslint-disable */ -// experimental actions will be moved here once they have stabilized - -// signDelegation action will be added here +export { isEip7702StatelessDelegatedAccount } from './isEip7702StatelessDelegatedAccount'; diff --git a/packages/delegation-toolkit/src/actions/isEip7702StatelessDelegatedAccount.ts b/packages/delegation-toolkit/src/actions/isEip7702StatelessDelegatedAccount.ts new file mode 100644 index 00000000..663a54f4 --- /dev/null +++ b/packages/delegation-toolkit/src/actions/isEip7702StatelessDelegatedAccount.ts @@ -0,0 +1,57 @@ +import { EIP7702StatelessDeleGator } from '@metamask/delegation-abis'; +import type { Client, Address } from 'viem'; +import { readContract } from 'viem/actions'; + +/** + * Parameters for checking if an EOA is delegated to the EIP7702StatelessDeleGator. + */ +export type IsEip7702StatelessDelegatedAccountParameters = { + /** The client to use for the query. */ + client: Client; + /** The address to check for EIP7702 delegation to StatelessDeleGator. */ + accountAddress: Address; +}; + +/** + * Checks if an EOA is specifically delegated to the EIP7702StatelessDeleGator implementation. + * + * This function goes beyond simply checking if code exists at the address (which could be any contract + * or any EIP7702 delegation). It specifically verifies that the account is delegated to the + * EIP7702StatelessDeleGator by calling the NAME() function and ensuring it returns "EIP7702StatelessDeleGator". + * + * @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 EIP7702 delegation. + * @returns A promise that resolves to true if the account is delegated to EIP7702StatelessDeleGator, false otherwise. + * @example + * ```typescript + * const isDelegated = await isEip7702StatelessDelegatedAccount({ + * client: publicClient, + * accountAddress: '0x...', + * }); + * + * if (isDelegated) { + * console.log('Account is delegated to EIP7702StatelessDeleGator'); + * } else { + * console.log('Account is not delegated to EIP7702StatelessDeleGator'); + * } + * ``` + */ +export async function isEip7702StatelessDelegatedAccount({ + client, + accountAddress, +}: IsEip7702StatelessDelegatedAccountParameters): Promise { + try { + const contractName = await readContract(client, { + address: accountAddress, + abi: EIP7702StatelessDeleGator.abi, + functionName: 'NAME', + }); + + return contractName === 'EIP7702StatelessDeleGator'; + } catch (error) { + // If the call fails (e.g., no code at address, or NAME() function doesn't exist), + // then it's not delegated to our implementation + return false; + } +} diff --git a/packages/delegation-toolkit/src/index.ts b/packages/delegation-toolkit/src/index.ts index 8f4d21af..8da5f01b 100644 --- a/packages/delegation-toolkit/src/index.ts +++ b/packages/delegation-toolkit/src/index.ts @@ -57,3 +57,5 @@ export type { AggregateSignatureParams } from './signatures'; export { signUserOperation } from './userOp'; export { redeemDelegations } from './write'; + +export { isEip7702StatelessDelegatedAccount } from './actions'; diff --git a/packages/delegation-toolkit/src/toMetaMaskSmartAccount.ts b/packages/delegation-toolkit/src/toMetaMaskSmartAccount.ts index 8eaef924..62ed5fd4 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 { isEip7702StatelessDelegatedAccount } from './actions/isEip7702StatelessDelegatedAccount'; import { Implementation } from './constants'; import { getCounterfactualAccountData } from './counterfactualAccountData'; import { @@ -197,5 +198,17 @@ export async function toMetaMaskSmartAccount< ...signatory, }); + // For Stateless7702, override isDeployed to check specific delegation + if (implementation === Implementation.Stateless7702) { + return { + ...smartAccount, + isDeployed: async () => + isEip7702StatelessDelegatedAccount({ + client, + accountAddress: address, + }), + }; + } + return smartAccount; } diff --git a/packages/delegation-toolkit/test/actions/isEip7702StatelessDelegatedAccount.test.ts b/packages/delegation-toolkit/test/actions/isEip7702StatelessDelegatedAccount.test.ts new file mode 100644 index 00000000..08cabb96 --- /dev/null +++ b/packages/delegation-toolkit/test/actions/isEip7702StatelessDelegatedAccount.test.ts @@ -0,0 +1,60 @@ +import type { PublicClient } from 'viem'; +import { createPublicClient, http } from 'viem'; +import { foundry } from 'viem/chains'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { isEip7702StatelessDelegatedAccount } from '../../src/actions/isEip7702StatelessDelegatedAccount'; + +describe('isEip7702StatelessDelegatedAccount', () => { + let publicClient: PublicClient; + + beforeEach(() => { + publicClient = createPublicClient({ + chain: foundry, + transport: http(), + }); + }); + + it('should return false for an address with no code', async () => { + const randomAddress = '0x1234567890123456789012345678901234567890'; + + const result = await isEip7702StatelessDelegatedAccount({ + client: publicClient, + accountAddress: randomAddress, + }); + + expect(result).toBe(false); + }); + + it('should return false for an address that does not implement NAME() function', async () => { + // Use an address that we know has code but doesn't implement NAME() + // In this case, we'll use the zero address which should fail + const zeroAddress = '0x0000000000000000000000000000000000000000'; + + const result = await isEip7702StatelessDelegatedAccount({ + client: publicClient, + accountAddress: zeroAddress, + }); + + expect(result).toBe(false); + }); + + it('should return false for a contract that implements NAME() but returns a different name', async () => { + // This test would need a mock contract that implements NAME() but returns something else + // For now, we'll just test the error handling path with an invalid address + const invalidAddress = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; + + const result = await isEip7702StatelessDelegatedAccount({ + client: publicClient, + accountAddress: invalidAddress, + }); + + expect(result).toBe(false); + }); + + // Note: A full integration test would require: + // 1. Deploying the EIP7702StatelessDeleGator contract + // 2. Creating an EOA and delegating it to that contract using EIP-7702 + // 3. Testing that the function returns true for that delegated account + // This would be better suited for the e2e test suite +}); diff --git a/packages/delegator-e2e/test/execute-stateless7702.test.ts b/packages/delegator-e2e/test/execute-stateless7702.test.ts index 3fc4f0f6..d7d5424c 100644 --- a/packages/delegator-e2e/test/execute-stateless7702.test.ts +++ b/packages/delegator-e2e/test/execute-stateless7702.test.ts @@ -401,3 +401,117 @@ test('Alice can check the contract version and name', async () => { expect(domainVersion, 'Domain version should be 1').toBe('1'); }); + +test('isEip7702StatelessDelegatedAccount correctly identifies delegated accounts', async () => { + const { isEip7702StatelessDelegatedAccount } = await import( + '@metamask/delegation-toolkit' + ); + + // Test that Alice's account (which is delegated to EIP7702StatelessDeleGator) returns true + const isDelegated = await isEip7702StatelessDelegatedAccount({ + client: publicClient, + accountAddress: aliceSmartAccount.address, + }); + + expect( + isDelegated, + 'Alice account should be delegated to EIP7702StatelessDeleGator', + ).toBe(true); + + // Test with a random non-delegated address + const randomAddress = '0x1234567890123456789012345678901234567890'; + const isRandomDelegated = await isEip7702StatelessDelegatedAccount({ + client: publicClient, + accountAddress: randomAddress, + }); + + expect( + isRandomDelegated, + 'Random address should not be delegated to EIP7702StatelessDeleGator', + ).toBe(false); +}); + +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 { isEip7702StatelessDelegatedAccount } = await import( + '@metamask/delegation-toolkit' + ); + + const isContractDelegated = await isEip7702StatelessDelegatedAccount({ + client: publicClient, + accountAddress: counterContract.address, + }); + + expect( + isContractDelegated, + 'Contract address should not be identified as EIP7702StatelessDeleGator delegation', + ).toBe(false); +});