Fix: ERC-4337 Session Key AA22 Reverts on Clock Drift and L2 Skew
In modular smart account architectures utilizing ERC-4337 and ERC-7579 (such as ZeroDev Kernel, Biconomy Nexus, or Safe Modular), Session Keys enable autonomous agents, Web3 gaming clients, and algorithmic trading scripts to execute pre-approved transactions without prompting the user for repetitive hardware or passkey approvals.
However, during automated test runs or live production trading, developers frequently encounter an abrupt bundler rejection:
RPC Error: -32500: EntryPoint simulation failed: AA22 expired or not yet valid
at EntryPoint.simulateValidation(UserOperation)
at SessionKeyValidator.validateUserOp(0x48f1...39b2)
[Bundler Rejection]: UserOp simulation reverted: Validation data timestamp out of bounds.
The transaction passes schema checks and cryptographic signature recovery succeeds, yet bundlers (such as Pimlico, Alchemy, or Biconomy) refuse to package and broadcast the UserOperation.
The root cause of this failure mode is temporal boundary misalignment: the UserOperation’s session policy defines strict validAfter and validUntil Unix timestamps that collide with client-sequencer clock drift, L2 block time acceleration, or out-of-order mempool submission.
If your smart account is failing validation due to WebAuthn user verification flags or high-$s$ ECDSA malleability, consult our companion guide on Fixing ERC-4337 Passkey P256 AA23 Signature Verification Reverts.
Architectural Breakdown: EntryPoint Validation Data Encoding
In the ERC-4337 specification (both EntryPoint v0.6 and v0.7), validateUserOp returns a packed 32-byte validationData integer:
// ERC-4337 EntryPoint specification
// uint256 validationData = authorizer (20 bytes) | validUntil (6 bytes) | validAfter (6 bytes)
function _intersectTimeRange(
uint256 validationData,
uint256 paymasterValidationData
) internal view returns (uint256) {
uint48 validUntil = uint48(validationData >> 160);
uint48 validAfter = uint48(validationData >> 208);
// CRITICAL VALIDATION CHECK
if (block.timestamp < validAfter || block.timestamp >= validUntil) {
revert("AA22 expired or not yet valid");
}
}
The Three Timing Trap Vectors
- Client System Clock Skew: If a client device’s clock is running 8 seconds ahead of real-world UTC time and sets
validAfter = Math.floor(Date.now() / 1000), the UserOperation reaches the node while the canonical L2block.timestampis still in the past. - Sub-Second L2 Block Dynamics: On networks like Arbitrum One (which can mint multiple micro-blocks per second) or Base (2-second blocks), block timestamps can drift slightly relative to wall-clock NTP time due to sequencer batching lag.
- Mempool Staging Latency: If an algorithmic agent sets
validUntilto a narrow window (e.g. 30 seconds for MEV protection), network congestion or bundler re-queuing can cause the simulation to execute whenblock.timestamp == validUntil, emittingAA22.
Step-by-Step Resolution Protocol
To resolve AA22 temporal rejections across automated trading agents and modular account implementations, apply this three-tier defensive strategy.
1. Fetch Canonical Block Timestamp from RPC
Never construct session policies using client-side Date.now(). Query the latest mined block’s timestamp directly from your RPC provider:
// src/utils/getCanonicalTimestamp.ts
import { type PublicClient } from 'viem';
export async function getCanonicalBlockTimestamp(publicClient: PublicClient): Promise<number> {
try {
const latestBlock = await publicClient.getBlock({ blockTag: 'latest' });
return Number(latestBlock.timestamp);
} catch (error) {
console.warn('[SessionKeyGuard] Failed to fetch latest block, falling back to UTC wall time:', error);
return Math.floor(Date.now() / 1000);
}
}
2. Apply Defensive Retrospective Buffers in Session Policies
When initializing or signing an ERC-7579 or Kernel v3 session key, configure a 300-second retrospective buffer for validAfter and an adequate expiration margin for validUntil:
// src/sessionKeys/createSessionPolicy.ts
import { type PublicClient } from 'viem';
import { getCanonicalBlockTimestamp } from '../utils/getCanonicalTimestamp';
interface SessionTimeWindowOptions {
durationSeconds?: number;
}
export async function calculateSafeSessionTimestamps(
publicClient: PublicClient,
options: SessionTimeWindowOptions = { durationSeconds: 86400 } // Default 24 hours
) {
const currentBlockTime = await getCanonicalBlockTimestamp(publicClient);
// CRITICAL FIX 1: Subtract 300 seconds (5 mins) to absorb clock drift & sequencer skew
const validAfter = Math.max(0, currentBlockTime - 300);
// CRITICAL FIX 2: Add 120s buffer to validUntil to prevent boundary race conditions
const validUntil = currentBlockTime + options.durationSeconds! + 120;
console.log(`[SessionKeyGuard] Policy Temporal Bounds:`);
console.log(`- validAfter: ${validAfter} (BlockTime - 300s)`);
console.log(`- validUntil: ${validUntil} (BlockTime + ${options.durationSeconds}s + 120s)`);
return { validAfter, validUntil };
}
3. Bundler Pre-Flight Simulation Interceptor
In autonomous agents running transaction loops, simulate the UserOperation locally using publicClient.simulateUserOperation and detect temporal proximity prior to public broadcast:
// src/agents/safeUserOpBroadcaster.ts
import { type UserOperation } from 'permissionless';
export function assertTimeWindowSafety(validUntil: number, minRemainingSeconds = 60) {
const now = Math.floor(Date.now() / 1000);
const remaining = validUntil - now;
if (remaining < minRemainingSeconds) {
throw new Error(
`SESSION_EXPIRED_RISK: Only ${remaining}s remaining before validUntil (${validUntil}). ` +
`Aborting broadcast to prevent AA22 simulation revert.`
);
}
}
Temporal Parameter Safety Matrix
| Configuration Scenario | validAfter Setting | validUntil Setting | Simulation Outcome |
|---|---|---|---|
| Strict Wall-Clock | Date.now() / 1000 | Date.now() / 1000 + 30s | High Revert Risk (AA22) on L2 skew |
| Buffered On-Chain | block.timestamp - 300 | block.timestamp + 3600 | 100% Reliable Execution |
Zero-Value (0) | 0 (Always valid from genesis) | Defined timestamp | Permitted for permanent session signers |
Zero-Value Until (0) | Defined timestamp | 0 (Never expires) | Allowed by EntryPoint, but high risk if compromised |
Frequently Asked Questions
Q: Why does the EntryPoint specification use uint48 for timestamps?
uint48 provides sufficient range to represent Unix timestamps in seconds up to the year 8,921,556 AD, while allowing two timestamps and an Ethereum authorizer address to fit compactly inside a single 32-byte EVM storage word (uint256).
Q: Can an attacker exploit a retrospective validAfter (e.g. block.timestamp - 300)?
No. Setting validAfter in the past merely indicates that the session key became mathematically authorized 5 minutes ago. It does not allow an attacker to alter the on-chain execution order of transactions, because transactions are still ordered and sequenced by the current block builder.
Q: Does AA22 consume gas fees from the smart account or paymaster?
No. AA22 occurs during the simulateValidation or estimateUserOperationGas RPC calls executed off-chain by the bundler. Bundlers drop transactions failing validation without broadcasting them on-chain, protecting accounts from gas drain.