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: 47b9ded
- 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).
// 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.
Description
Calling an uninitialized internal function pointer produces three different failure shapes depending purely on the pointer's storage class:
Panic(0x51)Panic(0x51)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 thePanic(0x51)zero-guard; it performs the indirectJUMPdirectly.JUMPto offset0is 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:
try/catch Panic(uint)will not fire (empty returndata); onlycatch (bytes)/catch {}sees it.function() internalfrom a storage variable toimmutable(a common "deploy-time constant" optimization) silently changes the uninitialized-call failure from a cheap, catchablePanic(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-irthe same source instead yields a cheap, catchablePanic(0x51)for the immutable case (the IRinternalDispatchguard applies uniformly). The storage/localPanic(0x51)case is backend-invariant.Environment
via_ir = false)Steps to Reproduce
Compile and run with the legacy pipeline (Foundry default,
via_ir = false).Calling
C.callImmutable()reverts with returndata length0and consumes all remaining gas (an INVALID JUMP to offset0), whereas callingD.callStorage()reverts with0x24bytes of returndata and panic code0x51at 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.