Skip to content

EIP-7702 StatelessDeleGator blocked by signature validation - internal account as verifying contract #6239

Description

@apm1001

EIP-7702 StatelessDeleGator blocked by signature validation - internal account as verifying contract

Summary

The current signature validation in signature-controller (added in PR #5470) prevents EIP-7702 Account Abstraction from working with MetaMask's EIP7702StatelessDeleGator implementation. The validation incorrectly blocks legitimate use cases where an internal account must be used as the verifyingContract for PackedUserOperation signatures.

Problem Description

Current Behavior

  • External signature requests cannot use internal MetaMask accounts as the verifyingContract
  • Error thrown: "External signature requests cannot use internal accounts as the verifying contract."
  • This blocks EIP-7702 Account Abstraction workflows that require the account itself as the verifying contract

Expected Behavior

  • EIP-7702 StatelessDeleGator should be able to sign UserOperations where the domain's verifyingContract is the signing account itself
  • Other wallets (OKX, Phantom) successfully handle identical signature requests
  • MetaMask should support EIP-7702 AA flows after account upgrade via "Switch" button in Account Details

Root Cause

The validateVerifyingContract function in packages/signature-controller/src/utils/validation.ts (lines 214-239) applies a blanket restriction:

function validateVerifyingContract({
  data,
  internalAccounts,
  origin,
}: {
  data: MessageParamsTypedData;
  internalAccounts: Hex[];
  origin: string | undefined;
}) {
  const verifyingContract = data?.domain?.verifyingContract;
  const isExternal = origin && origin !== ORIGIN_METAMASK;

  if (
    verifyingContract &&
    typeof verifyingContract === 'string' &&
    isExternal &&
    internalAccounts.some(
      (internalAccount) =>
        internalAccount.toLowerCase() === verifyingContract.toLowerCase(),
    )
  ) {
    throw new Error(
      `External signature requests cannot use internal accounts as the verifying contract.`,
    );
  }
}

Technical Details

EIP-7702 StatelessDeleGator Architecture

  • Unlike Simple7702Account which uses EntryPoint as verifying contract
  • EIP7702StatelessDeleGator (0x63c0c19a282a1b52b07dd5a65b58948a07dae32b) requires the domain to be the account which signs the authorization
  • This is a legitimate architectural requirement, not a security vulnerability

Affected Workflow

  1. User upgrades account to Smart Account via MetaMask's "Switch" button (EIP-7702)
  2. DApp attempts to sign PackedUserOperation with verifying contract = user's account address
  3. MetaMask throws validation error and blocks the signature
  4. Same operation succeeds in other wallets (OKX, Phantom)

Evidence

Reproduction Steps

  1. Upgrade MetaMask account to Smart Account using Account Details "Switch" button
  2. Run the following Account Abstraction flow code that triggers the validation error

Complete Reproduction Code

/* eslint-disable @typescript-eslint/no-explicit-any */
import type { Address, PublicClient, WalletClient } from "viem";
import {
  encodeFunctionData,
  erc20Abi,
  http,
  maxUint256,
} from "viem";
import {
  createBundlerClient,
  entryPoint07Abi,
  entryPoint07Address,
  createPaymasterClient,
} from "viem/account-abstraction";
import {
  Implementation,
  toMetaMaskSmartAccount,
} from "@metamask/delegation-toolkit";

export async function sendTransaction(
  publicClient: PublicClient,
  walletClient: WalletClient
) {
  const chainId = publicClient.chain!.id;

  const bundlerUrl = 'your-bundler-url';
  const paymasterUrl = 'your-paymaster-url';

  const bundlerClient = createBundlerClient({
    client: publicClient,
    transport: http(bundlerUrl),
  });
  const paymasterClient = createPaymasterClient({
    transport: http(paymasterUrl),
  });

  if (!walletClient.account) {
    throw new Error("Wallet client account not found");
  }

  const { address: sender } = walletClient.account;

  const receiver = "0xDcD6d7D4D3A4967A7EB3A80074588b0641F97d0f"; // Any user address
  const token = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; // Mainnet USDC address
  const feeToken = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; // Mainnet USDC address
  const amount = 1000000n; // 1 USDC

  // 1. ENTRY POINT
  const entryPoint = {
    abi: entryPoint07Abi,
    address: entryPoint07Address,
    version: "0.7",
  };

  // 2. GET SMART ACCOUNT - This is where EIP-7702 StatelessDeleGator is used
  const smartAccount = await toMetaMaskSmartAccount({
    client: publicClient,
    implementation: Implementation.Stateless7702, // KEY: Uses StatelessDeleGator
    address: sender, // The internal MetaMask account address
    signatory: { account: walletClient as any },
  });

  // 3. MOCK PAYMASTER REQUEST TO GET PAYMASTER ADDRESS
  const { paymaster: paymasterAddress } =
    await paymasterClient.getPaymasterStubData({
      callData: "0x",
      maxFeePerGas: 0n,
      maxPriorityFeePerGas: 0n,
      nonce: 0n,
      sender: smartAccount.address, // Same as original sender address
      entryPointAddress: entryPoint.address,
      chainId,
      context: { token: feeToken },
    });

  if (!paymasterAddress) {
    throw new Error("Paymaster address not found");
  }

  // 4. PREPARE CALLDATA (approve + transfer)
  const approveCall = {
    to: feeToken as Address,
    value: 0n,
    data: encodeFunctionData({
      abi: erc20Abi,
      functionName: "approve",
      args: [paymasterAddress, maxUint256],
    }),
  };
  const mainCall = {
    to: token as Address,
    value: 0n,
    data: encodeFunctionData({
      abi: erc20Abi,
      functionName: "transfer",
      args: [receiver, amount],
    }),
  };
  const calls = [approveCall, mainCall];
  const callData = await smartAccount.encodeCalls(calls);

  // 5. FETCH STUB DATA FROM PAYMASTER
  const smartAccountNonce = await smartAccount.getNonce();
  let maxFeePerGas = await publicClient.getGasPrice();
  maxFeePerGas = (maxFeePerGas * 130n) / 100n; // Add 30% for reliability
  let maxPriorityFeePerGas = await publicClient.estimateMaxPriorityFeePerGas();
  maxPriorityFeePerGas = (maxPriorityFeePerGas * 130n) / 100n; // Add 30% for reliability

  const { paymasterData } = await paymasterClient.getPaymasterStubData({
    callData,
    maxFeePerGas,
    maxPriorityFeePerGas,
    nonce: smartAccountNonce,
    sender: smartAccount.address,
    entryPointAddress: entryPoint.address,
    chainId,
    context: { token: feeToken },
  });

  // 6. ESTIMATE GAS
  const { callGasLimit, preVerificationGas, verificationGasLimit } =
    await bundlerClient.estimateUserOperationGas({
      callData,
      account: smartAccount,
      nonce: smartAccountNonce,
      entryPointAddress: entryPoint.address,
      paymaster: paymasterAddress,
      paymasterData,
    });

  // 7. FETCH DATA FROM PAYMASTER
  const userOp = {
    sender: smartAccount.address,
    nonce: smartAccountNonce,
    callData,
    callGasLimit,
    verificationGasLimit,
    preVerificationGas,
    maxFeePerGas,
    maxPriorityFeePerGas,
    paymaster: paymasterAddress,
    paymasterData,
    chainId,
    entryPointAddress: entryPoint.address,
    context: { token: feeToken },
  };

  const { paymasterVerificationGasLimit, paymasterPostOpGasLimit } =
    await paymasterClient.getPaymasterData(userOp);

  // 8. SEND USER OPERATION - This is where the validation error occurs
  // The bundlerClient internally calls eth_signTypedData_v4 with verifyingContract = sender address
  // which triggers MetaMask's validateVerifyingContract() function and throws the error
  const txHash = await bundlerClient.sendUserOperation({
    callData,
    account: smartAccount,
    nonce: smartAccountNonce,
    entryPointAddress: entryPoint.address,
    maxFeePerGas,
    maxPriorityFeePerGas,
    paymaster: paymasterAddress,
    paymasterData,
    paymasterVerificationGasLimit,
    paymasterPostOpGasLimit,
    callGasLimit,
    preVerificationGas,
    verificationGasLimit,
  });

  console.log("UserOperation hash:", txHash);
  return txHash;
}

Key Points Where Error Occurs

  1. Smart Account Creation: Line 141 uses Implementation.Stateless7702 which creates an account that requires verifyingContract to be the account address itself
  2. Signature Request: When bundlerClient.sendUserOperation() is called, it internally triggers eth_signTypedData_v4 with:
    const domain = {
      chainId: 1,
      name: 'EIP7702StatelessDeleGator', 
      version: '1',
      verifyingContract: sender // Same as the internal MetaMask account address
    };
    
    const types = {
      PackedUserOperation: [
        { name: 'sender', type: 'address' },
        { name: 'nonce', type: 'uint256' },
        { name: 'initCode', type: 'bytes' },
        { name: 'callData', type: 'bytes' },
        { name: 'accountGasLimits', type: 'bytes32' },
        { name: 'preVerificationGas', type: 'uint256' },
        { name: 'gasFees', type: 'bytes32' },
        { name: 'paymasterAndData', type: 'bytes' },
        { name: 'entryPoint', type: 'address' }
      ]
    };
  3. Validation Error: MetaMask's validateVerifyingContract function throws: "External signature requests cannot use internal accounts as the verifying contract."

Expected vs Actual Behavior

  • Expected: Signature should succeed, allowing EIP-7702 Account Abstraction workflow to complete
  • Actual: Validation error blocks the signature, preventing AA transaction execution
  • Other Wallets: OKX and Phantom successfully sign identical requests

Proposed Solutions

Option 1: EIP-7702 Context-Aware Validation

Add special handling for EIP-7702 Account Abstraction contexts:

// Note: Function would need to be async and accept networkClient for full implementation
function validateVerifyingContract({
  data,
  internalAccounts,
  origin,
  // networkClient, // Would be needed for code checking
}: {
  data: MessageParamsTypedData;
  internalAccounts: Hex[];
  origin: string | undefined;
  // networkClient?: { getCode: (address: string) => Promise<string> };
}) {
  const verifyingContract = data?.domain?.verifyingContract;
  const isExternal = origin && origin !== ORIGIN_METAMASK;
  
  // Allow internal account as verifying contract for EIP-7702 AA
  const isEIP7702Context = data?.primaryType === 'PackedUserOperation' && 
                          data?.domain?.name === 'EIP7702StatelessDeleGator';

  // Or additionally check if the account is delegated to EIP7702StatelessDeleGator
  let isEIP7702DelegatedAccount = false;
  
  // Example implementation (would need networkClient parameter and async function):
  /*
  const accountCode = await networkClient.getCode(verifyingContract);
  
  // EIP-7702 delegation format: 0xef0100 + 20-byte delegated address
  const EIP_7702_PREFIX = '0xef0100';
  const STATELESS_DELEGATOR = '0x63c0c19a282a1b52b07dd5a65b58948a07dae32b';
  
  if (accountCode?.toLowerCase().startsWith(EIP_7702_PREFIX.toLowerCase())) {
    // Extract delegated address from EIP-7702 format (skip 0xef0100, take next 20 bytes)
    const delegatedAddress = `0x${accountCode.slice(6, 46)}`;
    if (delegatedAddress.toLowerCase() === STATELESS_DELEGATOR.toLowerCase()) {
      isEIP7702DelegatedAccount = true;
    }
  }
  */

  if (
    verifyingContract &&
    typeof verifyingContract === 'string' &&
    isExternal &&
    !isEIP7702Context && // Allow based on signature context
    !isEIP7702DelegatedAccount && // Allow if account is delegated to StatelessDeleGator
    internalAccounts.some(
      (internalAccount) =>
        internalAccount.toLowerCase() === verifyingContract.toLowerCase(),
    )
  ) {
    throw new Error(
      `External signature requests cannot use internal accounts as the verifying contract.`,
    );
  }
}

Option 2: User Consent Flow

For ambiguous cases, prompt user for explicit consent when internal account is used as verifying contract in EIP-7702 contexts.

Security Considerations

  • The proposed changes maintain security for non-EIP-7702 contexts
  • EIP-7702 self-referential signatures are architecturally required, not exploitable
  • User has already explicitly upgraded account to Smart Account via MetaMask UI
  • Other wallets successfully handle these signatures without security issues

References

Impact

This validation blocks MetaMask users from utilizing EIP-7702 Account Abstraction features with paymasters and bundlers, limiting MetaMask's compatibility with the evolving AA ecosystem. Users are forced to use alternative wallets for AA workflows, degrading the MetaMask user experience.

Labels

  • team-confirmations
  • bug
  • eip-7702
  • account-abstraction
  • signature-controller

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions