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
- User upgrades account to Smart Account via MetaMask's "Switch" button (EIP-7702)
- DApp attempts to sign PackedUserOperation with verifying contract = user's account address
- MetaMask throws validation error and blocks the signature
- Same operation succeeds in other wallets (OKX, Phantom)
Evidence
Reproduction Steps
- Upgrade MetaMask account to Smart Account using Account Details "Switch" button
- 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
- Smart Account Creation: Line 141 uses
Implementation.Stateless7702 which creates an account that requires verifyingContract to be the account address itself
- 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' }
]
};
- 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
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'sEIP7702StatelessDeleGatorimplementation. The validation incorrectly blocks legitimate use cases where an internal account must be used as theverifyingContractfor PackedUserOperation signatures.Problem Description
Current Behavior
verifyingContract"External signature requests cannot use internal accounts as the verifying contract."Expected Behavior
verifyingContractis the signing account itselfRoot Cause
The
validateVerifyingContractfunction inpackages/signature-controller/src/utils/validation.ts(lines 214-239) applies a blanket restriction:Technical Details
EIP-7702 StatelessDeleGator Architecture
Simple7702Accountwhich uses EntryPoint as verifying contractEIP7702StatelessDeleGator(0x63c0c19a282a1b52b07dd5a65b58948a07dae32b) requires the domain to be the account which signs the authorizationAffected Workflow
Evidence
Details: External signature requests cannot use internal accounts as the verifying contract. Version: viem@2.32.0Reproduction Steps
Complete Reproduction Code
Key Points Where Error Occurs
Implementation.Stateless7702which creates an account that requiresverifyingContractto be the account address itselfbundlerClient.sendUserOperation()is called, it internally triggerseth_signTypedData_v4with:validateVerifyingContractfunction throws:"External signature requests cannot use internal accounts as the verifying contract."Expected vs Actual Behavior
Proposed Solutions
Option 1: EIP-7702 Context-Aware Validation
Add special handling for EIP-7702 Account Abstraction contexts:
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
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-confirmationsbugeip-7702account-abstractionsignature-controller