A `constant` holding `type(D).creationCode` records no bytecode dependency unless it is named by a plain identifier: valid, acyclic contracts fail to compile with an ICE depending on declaration order
Description
A constant state variable may be initialised with type(D).creationCode — this is supported and is
covered by checked-in tests (test/libsolidity/syntaxTests/metaTypes/codeAccessIsConstant.sol,
test/libsolidity/syntaxTests/constants/initialization/type_info.sol).
The call-graph builder, however, only ever descends into a constant's initializer when the constant
is named by a plain identifier. When the same constant is named by a member access
(L.K), or when it is a public constant reached through its generated getter, the initializer
is never visited, so the type(D).creationCode inside it never records a bytecode dependency on
D.
Two things break as a result:
- A valid, acyclic contract fails to compile with an internal compiler error, purely because of the order in which the two contracts are declared. Swapping the declarations makes it compile, with no other change.
- The circular-bytecode-reference check (
TypeError 7813) is silently skipped for genuine cycles routed through such a constant. Those also end in an ICE instead of the diagnostic.
Both pipelines are affected (legacy and via IR).
Environment
- Compiler versions: 0.8.4 through 0.8.36 (0.8.0 unaffected)
- Pipelines: legacy and via IR, both affected
- EVM version: default
- OS: macOS
Reproducer 1 — valid acyclic contract, ICE (4 lines)
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.0;
contract C { bytes public constant K = type(D).creationCode; }
contract D { uint public x = 1; }Expected: bytecode for C and D.
Actual:
$ solc --bin order.sol
Internal compiler error:
/solidity/libsolidity/codegen/CompilerContext.cpp(255): Throw in function std::shared_ptr<evmasm::Assembly> solidity::frontend::CompilerContext::compiledContract(const ContractDefinition &) const
Dynamic exception type: boost::wrapexcept<solidity::langutil::InternalCompilerError>
std::exception::what: Compiled contract not found.
[solidity::util::tag_comment*] = Compiled contract not found.$ solc --via-ir --bin order.sol
Internal compiler error: ... Invalid IR generated:
Error (3517): Unknown data object "D_12".
--> :106:36:
|
106 | let _1 := datasize("D_12")
| ^^^^^^Control — move D above C and it compiles. This is the only change:
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.0;
contract D { uint public x = 1; }
contract C { bytes public constant K = type(D).creationCode; }Second control — adding one reachable direct reference to type(D).creationCode anywhere in
C restores the missing edge and the original order compiles. (Putting the same expression in an
unreachable internal function does not, which is consistent with the reachability model and
confirms the missing dependency edge is the cause.)
Reproducer 2 — the same hole reached by member access, and a real cycle that is not reported
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.0;
library LD { bytes constant K = type(D).creationCode; }
library LC { bytes constant K = type(C).creationCode; }
contract C { function f() public pure returns (bytes memory) { return LD.K; } }
contract D { function f() public pure returns (bytes memory) { return LC.K; } }This is a genuine circular bytecode reference. Expected: TypeError 7813. Actual: the same
Compiled contract not found. ICE — the cycle check never sees the edge.
Control: the same two libraries and the same cycle, with the constant read by an identifier inside the library instead of by member access from outside, is reported correctly.
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.0;
library LD { bytes constant K = type(D).creationCode;
function g() internal pure returns (bytes memory) { return K; } }
library LC { bytes constant K = type(C).creationCode;
function g() internal pure returns (bytes memory) { return K; } }
contract C { function f() public pure returns (bytes memory) { return LD.g(); } }
contract D { function f() public pure returns (bytes memory) { return LC.g(); } }Error: Circular reference to contract bytecode either via "new" or "type(...).creationCode" / "type(...).runtimeCode".The direct form (return type(D).creationCode; in the function body) and the file-level-constant
form are also both reported correctly. Identifier vs member access is the only axis that matters.
Cause
libsolidity/analysis/FunctionCallGraph.cpp.
buildCreationGraph deliberately skips constants when seeding from state variables (:41-43):
for (auto const* stateVar: base->stateVariables())
if (!stateVar->isConstant())
stateVar->accept(builder);so a constant's initializer is reached from exactly one place — visit(Identifier) (:143-152),
which recurses on demand:
if (auto const* variable = dynamic_cast<VariableDeclaration const*>(_identifier.annotation().referencedDeclaration))
if (variable->isConstant())
{
solAssert(variable->isStateVariable() || variable->isFileLevelVariable(), "");
variable->accept(*this); // <-- the recursion
}Two ways of naming the same constant never reach that branch:
visit(MemberAccess)(:167-204) has no constant-variable branch at all. It computesfunctionType/functionDefand returns early at:183-184when the member is not a function, soL.Kdescends only into the expressionL(aContractDefinition) and never intoK's value.buildDeployedGraphexplicitly ignores public-variable getters (:85-86):else // If it's not a function, it must be a getter of a public variable; we ignore those solAssert(variable, "");
Either way the m_graph.bytecodeDependency.emplace(...) at :178 never runs.
CompilerStack::createAndAssignCallGraphs (:146-149) then copies an empty dependency set into
annotation().contractDependencies, and both findAndReportCyclicContractDependencies (:180-182)
and the compilation order are computed without the edge.
Versions*
Tested on 0.8.0, 0.8.4, 0.8.10, 0.8.17, 0.8.20, 0.8.22, 0.8.26, 0.8.28, 0.8.30, 0.8.34, 0.8.36 (Reproducer 1):
- legacy: clean on 0.8.0, ICE on 0.8.4 through 0.8.36 (every version tested).
- via IR: clean on 0.8.0, 0.8.4 and 0.8.10; ICE on 0.8.17 through 0.8.36.
So the legacy failure arrives with the 0.8.1-0.8.4 call-graph rework and the via-IR pipeline joins
it later, once it started resolving these data objects the same way.
FunctionCallGraph.cpp is unchanged on develop (last commit de1a017cc, 2023-08-14).
Test coverage
test/libsolidity/syntaxTests/bytecodeReferences/ is dedicated to this invariant and covers cycles
through library functions (library_function_circular_reference.sol, library_called.sol), but
has no case for a cycle through a library constant.
Separately, syntaxTests/metaTypes/codeAccessIsConstant.sol and
syntaxTests/constants/initialization/type_info.sol both bless the exact construct — and
codeAccessIsConstant.sol even declares B after the contract holding the constant. They pass
because syntax tests stop before code generation; the same sources ICE under --bin. So no
checked-in expectation needs to change here, but the fix does need coverage at a tier that runs
codegen.
Relation to #16345
#16345 reports the same ICE string for
contract A { bytes constant public code = type(A).creationCode; }. That is a different defect,
and its fix would not address this one:
- Circularity / intended remedy. #16345 is a genuine self-cycle and its title asks to "Disallow" it — that program should be rejected. Reproducer 1 here is acyclic and valid and should compile. The two fixes point in opposite directions.
- Declaration order. Untouched by #16345 — a self-reference cannot be reordered. Here it is the whole axis, and the control that isolates it.
- Access path. #16345 exercises only the ignored-getter branch (
:85-86). The member-access branch (:167-204) is untouched by it. That the two differ is demonstrable: the same self-reference madeinternaland read by an identifier is correctly reported asTypeError 7813rather than ICE-ing —contract A { bytes constant code = type(A).creationCode; function f() public pure returns (bytes memory) { return code; } } - Root cause. #16345 frames it as a missing validation rule about self-reference; the cause here is the un-visited constant initializer in the call-graph builder, which is also what produces the acyclic failure.
Source: argotorg/solidity