LayerZeroFault
wallet security-fixes Updated:

ERC-7579 Smart Account 'HookPostCheckFailed' Execution Revert

VV

Written by

Updated:

Technical Guide

To resolve ERC-7579 modular account hook post-check execution reverts, ensure hook context variables are strictly preserved across preCheck and postCheck invocations and that post-check gas allocations exceed the hook contract’s state verification requirements. Reverts occur when hook contracts attempt to decode expired transient storage contexts or when hook gas limits cause out-of-gas errors during post-execution balance checks, reverting the entire UserOperation transaction.

With the widespread migration toward ERC-7579 Modular Smart Accounts (such as Biconomy Nexus, ZeroDev Kernel v3, and Rhinestone ModuleKit), decentralized wallets have shifted from monolithic smart contract architectures to composable, plug-and-play module registries.

However, developers and automated arbitrage bots orchestrating batch UserOperations frequently encounter silent or opaque execution reverts:

RPC Error: -32521: Execution reverted during UserOperation execution:
  Custom error: HookPostCheckFailed(0x3B99f842B76a6d68b9B4261869eFaA785a973E84, 0x12a0487c)
  EntryPoint: 0x0000000071727De22E5E9d8BAf0edAc6f37da032
  Account: 0x9fC6D73a7F9D5E7f8A61FeA332eBe449339e0839

When this occurs, the UserOperation successfully passes EntryPoint validation (validateUserOp), but the execution phase aborts entirely, wasting user sponsorship gas and blocking pending transaction pipelines.

[!TIP] Diagnostic Sandbox: Unpack nested hook calldata and simulate bundler prefund validation in our /tools/erc4337-calldata-decoder/. Also review our security analysis on ERC-7579 Session Key Cross-Chain Replay Defenses.

1. Architectural Anatomy of ERC-7579 Hooks

ERC-7579 defines standardized module interfaces for account abstraction. Execution hooks are implemented as security barriers surrounding the execution flow:

// Interface: IERC7579Hook
interface IHook {
    function preCheck(
        address msgSender,
        uint256 value,
        bytes calldata msgData
    ) external returns (bytes memory hookData);

    function postCheck(
        bytes calldata hookData
    ) external;
}

When an account executes a call via execute() or executeBatch(), the internal execution lifecycle proceeds through four strict steps:

  1. preCheck Invocation: The smart account passes caller metadata, execution calldata, and payment value to the hook module. The hook returns context bytes (hookData), typically containing pre-execution state (e.g., current token balances, timestamp, or caller nonces).
  2. Execution Phase: The account calls the target contract (e.g., swapping tokens on Uniswap or approving an ERC-20 spender).
  3. postCheck Invocation: The account forwards the saved hookData back into postCheck(). The hook evaluates state transitions (e.g., checking if the total spent amount exceeds policy allowances).
  4. Error Bubble-Up: If postCheck() reverts or returns non-truthy verification, the account executes revert HookPostCheckFailed(hookAddress, revertReason).

2. Root Causes of HookPostCheckFailed

Cause A: State Delta Violation (Spending Limits & Balance Floors)

The most common trigger is policy violation in daily spending limit hooks (such as Rhinestone’s SpendingLimitModule). If the target calldata transfers 1,000 USDC but the hook’s remaining epoch allowance is 950 USDC, preCheck() records the baseline, but postCheck() detects the delta and reverts.

Cause B: Corrupted hookData Serialization

If an account integrates multiple sub-hooks or a Hook Multiplexer (e.g., HookMultiPlexer.sol), context bytes returned by preCheck() are packed sequentially. If a custom hook mismanages dynamic array offsets or ABI-decoding in postCheck(), an EVM panic 0x41 (array out of bounds) or unhandled revert occurs.

Cause C: Insufficient Gas Budget for Post-Check Storage Writes

Bundler gas estimators calculate callGasLimit based solely on the target contract’s estimated gas. However, modern post-check hooks perform complex operations:

  • Updating merkle accumulator roots.
  • Writing spent balances across multiple storage slots.
  • Verifying cryptographic signatures or zero-knowledge proof validity.

If callGasLimit does not include an explicit buffer for post-check execution, the EVM runs out of gas inside postCheck(), bubbling up an out-of-gas error as HookPostCheckFailed.

3. Step-by-Step Diagnostic Protocol

Step 1: Decode the Revert Payload with cast

Use Foundry’s cast to unpack the custom error selector and embedded hook address:

cast 4byte-decode 0xa79075720000000000000000000000003b99f842b76a6d68b9b4261869efaa785a973e84

Output:

HookPostCheckFailed(address,bytes)
Hook Address: 0x3B99f842B76a6d68b9B4261869eFaA785a973E84
Inner Error: 0x12a0487c -> SpendingLimitExceeded(uint256 remaining, uint256 requested)

Step 2: Simulate with State Overrides

Execute an eth_call or eth_simulateV1 with trace logs enabled to verify whether gas starvation or policy logic triggered the error:

import { createPublicClient, http } from 'viem';
import { mainnet } from 'viem/chains';

const client = createPublicClient({
  chain: mainnet,
  transport: http(),
});

// Simulate UserOperation with extended gas buffer
const simulation = await client.simulateContract({
  address: accountAddress,
  abi: accountAbi,
  functionName: 'execute',
  args: [target, value, callData],
  gas: 450_000n, // Explicit gas override ensuring postCheck has >100k gas
});

4. Production Remediation Strategies

Fix 1: Implement Dynamic Gas Padding for Modular Hooks

When building UserOperations in Viem or Permissionless.js, pad callGasLimit by the maximum worst-case execution cost of your installed hooks:

import { estimateUserOperationGas } from 'permissionless/actions';

const gasEstimates = await estimateUserOperationGas(bundlerClient, {
  userOperation: rawUserOp,
  entryPoint: ENTRYPOINT_ADDRESS_V07,
});

// Apply safety multiplier specifically to callGasLimit for ERC-7579 hooks
const finalUserOp = {
  ...rawUserOp,
  callGasLimit: (gasEstimates.callGasLimit * 125n) / 100n + 55_000n, // +55k gas floor for postCheck storage writes
  verificationGasLimit: (gasEstimates.verificationGasLimit * 115n) / 100n,
};

Fix 2: Pre-Validate State Limits Prior to UserOp Broadcast

Before dispatching transactions, query the installed hook module’s view methods on-chain to confirm policy headroom:

import { getContract } from 'viem';

const spendingHook = getContract({
  address: SPENDING_LIMIT_HOOK_ADDRESS,
  abi: spendingLimitHookAbi,
  client,
});

const [remainingAllowance, resetTimestamp] = await spendingHook.read.getLimit([
  accountAddress,
  tokenAddress,
]);

if (remainingAllowance < requestedAmount) {
  throw new Error(`Spending limit exceeded. Available: ${remainingAllowance}, Requested: ${requestedAmount}`);
}

Fix 3: Handle Multi-Hook Chain Context Unpacking

If writing custom ERC-7579 hook modules, ensure postCheck safely decodes hookData without assuming strict calldata sizing:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

import { IHook } from "./interfaces/IERC7579Hook.sol";

contract SafeSpendingLimitHook is IHook {
    error SpendingLimitExceeded(uint256 remaining, uint256 requested);
    error InvalidHookData();

    function preCheck(
        address,
        uint256,
        bytes calldata msgData
    ) external override returns (bytes memory hookData) {
        // Extract transfer details and return validated snapshot
        return abi.encode(msg.sender, block.timestamp);
    }

    function postCheck(bytes calldata hookData) external override {
        if (hookData.length < 64) revert InvalidHookData();
        (address initiator, uint256 startTimestamp) = abi.decode(hookData, (address, uint256));
        
        // Post-validation logic here...
    }
}

5. Summary Checklist for Node Operators & dApp Engineers

  1. Verify Hook Registry State: Ensure installed hooks have not expired or entered emergency pause state.
  2. Buffer callGasLimit: Always add at least 40,000 to 60,000 gas to account for storage writes in postCheck.
  3. Avoid Nonce and Allowance Clashes: In token-gated operations, verify that allowances have not collided with other execution engines like Permit2 (see our companion guide on Permit2 Nonce Bitmap Expiration Collision Revert Fix).
  4. Inspect Decoded Reverts: Use cast 4byte-decode on the second argument of HookPostCheckFailed to retrieve the underlying policy error.
Interactive Diagnostic Sandbox

Local UserOp & Protocol Security Sandbox

Decode UserOperation calldata, simulate prefund accounting, and test bundler validation rules in your local browser.

Companion Diagnostic Guide Uniswap Permit2 Nonce Bitmap Collision →

Resolve Uniswap Permit2 transaction reverts caused by nonce bitmap collisions and timestamp expirations. Manage unordered nonces across trading bots.

Read Guide
Contextual Architecture

Related Diagnostic Guides

View All in Silo →

Related Inquiries

What causes an ERC-7579 hook postCheck revert?

PostCheck reverts occur when the hook contract fails invariant validation (such as spending limit overruns), when transient storage context from preCheck is corrupted, or when remaining transaction gas is insufficient to execute postCheck logic.

How does ERC-7579 pass execution state between preCheck and postCheck?

ERC-7579 modular accounts pass a bytes payload returned by preCheck directly into postCheck as the hook context parameter, allowing stateless hooks to verify pre- and post-execution account balances.

Can an ERC-7579 hook cause a denial-of-service on an account?

Yes. If a buggy or malicious hook reverts in postCheck, all account transactions are blocked. Recovery requires calling uninstallModule with root validator authorization through a dedicated fallback entrypoint.

How do you debug reverting ERC-7579 UserOperations?

Extract the packed execution calldata and simulate the UserOperation in an air-gapped decoder such as our ERC-4337 Calldata Sandbox to pinpoint the exact revert selector and failing hook address.