Uninitialized immutable internal fn pointer burns all gas
- Dominant language
- C++
- Stars
- 25.7k
- Forks
- 6.2k
- Avg merge
- 1d 11h
- Merged PRs (30d)
- 21
Description
## Description
Calling an uninitialized internal function pointer produces three different failure shapes depending purely on the pointer's storage class:
| Pointer storage class | Failure shape | returndata | gas |
|----------------------------------|---------------|-----------|-----|
| STORAGE internal fn ptr | `Panic(0x51)` | 0x24 bytes | ~normal |
| LOCAL internal fn ptr | `Panic(0x51)` | 0x24 bytes | ~normal |
| EXTERNAL fn ptr (uninit/deleted) | bare revert (extcodesize) | 0 | cheap |
| **IMMUTABLE internal fn ptr** | **bare revert (INVALID JUMP)** | **0** | **ALL gas burned** |
For a storage or local internal function pointer, codegen emits a zero-pointer guard before the indirect jump, and calling the uninitialized pointer reverts with a typed, catchable `Panic(0x51)` ("zero-initialized internal function"), 0x24 bytes of returndata, at roughly normal gas cost.
For an **immutable** internal function pointer that is never assigned in the constructor (which compiles — there is no compile-time check that a constructor assigns every immutable), the placeholder is substituted into the runtime bytecode as `0`. The immutable-fn-ptr call path does **not** emit the `Panic(0x51)` zero-guard; it performs the indirect `JUMP` directly. `JUMP` to offset `0` is an INVALID JUMP, so the EVM consumes **all remaining gas** and reverts with **empty returndata** (sub-call gas observed ~1.06e9 when unbounded).
Consequences of this divergence:
- The failure is **uncatchable as a typed Panic**: `try/catch Panic(uint)` will not fire (empty returndata); only `catch (bytes)` / `catch {}` sees it.
- It is an **all-gas griefing / DoS primitive**: any caller hitting an unset immutable internal fn ptr loses its entire gas budget — the opposite of the cheap external bare-revert.
- It is a silent **refactor footgun**: changing a `function() internal` from a storage variable to `immutable` (a common "deploy-time constant" optimization) silently changes the uninitialized-call failure from a cheap, catchable `Panic(0x51)` to an uncatchable all-gas invalid jump, with no warning at compile time.
Expected: calling an uninitialized internal function pointer should produce the documented `Panic(0x51)` ("zero-initialized internal function") regardless of whether the pointer is stored in storage, a local, or an immutable — i.e. the same catchable, bounded-gas failure shape — and/or the compiler should reject (or warn about) an immutable internal function pointer that is not assigned on every constructor path.
Note: the verified behavior above is the **legacy** codegen path (Foundry default, `via_ir = false`). Under `--via-ir` the same source instead yields a cheap, catchable `Panic(0x51)` for the immutable case (the IR `internalDispatch` guard applies uniformly). The storage/local `Panic(0x51)` case is backend-invariant.
## Environment
- Compiler version: 47b9dedda
- Compilation pipeline (legacy, IR, EOF): legacy (Foundry default, `via_ir = false`)
- Target EVM version (as per compiler settings):
- Framework/IDE (e.g. Foundry, Hardhat, Remix): Foundry (forge)
- EVM execution environment / backend / blockchain client:
- Operating system:
## Steps to Reproduce
Compile and run with the legacy pipeline (Foundry default, `via_ir = false`).
```solidity
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.0;
contract C {
// Immutable internal function pointer that is NEVER assigned in the
// constructor. This COMPILES (no compile-time assign-coverage check
// for immutables) and no warning is emitted.
function() internal returns (uint) immutable f;
constructor() {
// f is intentionally never assigned.
}
// Calling f() is a JUMP to the substituted-0 offset => INVALID JUMP
// => EVM burns ALL remaining gas and bare-reverts with EMPTY returndata.
// No Panic(0x51) guard is emitted for the immutable representation.
function callImmutable() external returns (uint) {
return f();
}
}
// Contrast: the SAME logical mistake with a storage-class internal fn ptr
// reverts with a typed, catchable Panic(0x51) (0x24 bytes), ~normal gas.
contract D {
function() internal returns (uint) g; // storage, never assigned
function callStorage() external returns (uint) {
return g(); // -> Panic(0x51), 0x24-byte returndata
}
}
```
Calling `C.callImmutable()` reverts with returndata length `0` and consumes all remaining gas (an INVALID JUMP to offset `0`), whereas calling `D.callStorage()` reverts with `0x24` bytes of returndata and panic code `0x51` at roughly normal gas cost — three distinct failure shapes for the same logical mistake (calling an uninitialized internal function pointer), differing purely by the pointer's representation.
Contributor guide
Assessment
This issue has not been assessed yet.