Skip to content

Commit 5e0cc33

Browse files
authored
Support annotation to validate arbitrary functions as initializers (#1148)
1 parent a6a710d commit 5e0cc33

File tree

6 files changed

+194
-28
lines changed

6 files changed

+194
-28
lines changed

docs/modules/ROOT/pages/writing-upgradeable.adoc

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,12 +191,19 @@ contract MyContract {
191191

192192
Do not leave an implementation contract uninitialized. An uninitialized implementation contract can be taken over by an attacker, which may impact the proxy. To prevent the implementation contract from being used, you should invoke the `_disableInitializers` function in the constructor to automatically lock it when it is deployed:
193193

194-
```
194+
[source,solidity]
195+
----
195196
/// @custom:oz-upgrades-unsafe-allow constructor
196197
constructor() {
197198
_disableInitializers();
198199
}
199-
```
200+
----
201+
202+
=== Validating Initializers
203+
204+
The OpenZeppelin Upgrades plugins will automatically detect specific issues with initializers in implementation contracts. These include checking if your implementation contract is missing an initializer when there are parent initializers that need to be called, if a parent initializer is not called, or if a parent initializer is called more than once. The plugins will also warn if parent initializers are not called in linearized order.
205+
206+
Reinitializers are not included in validations by default, because the Upgrades plugins cannot determine whether they are intended to be used for new deployments. If you want to validate a reinitializer function as an initializer that can be used for new deployments, annotate it with `@custom:oz-upgrades-validate-as-initializer`. Note that functions which cannot possibly be initializers are always ignored, such as private functions which cannot be called externally or by child contracts.
200207

201208
[[creating-new-instances-from-your-contract-code]]
202209
== Creating New Instances From Your Contract Code

packages/core/CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
# Changelog
22

3-
## Unreleased
3+
## 1.43.0 (2025-04-11)
44

55
- Add Sonic network to manifest file names. ([#1146](https://github.com/OpenZeppelin/openzeppelin-upgrades/pull/1146))
6+
- Support annotation to validate functions as initializers. ([#1148](https://github.com/OpenZeppelin/openzeppelin-upgrades/pull/1148))
67

78
## 1.42.2 (2025-03-19)
89

packages/core/contracts/test/ValidationsInitializer.sol

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ contract Parent_NoInitializer {
2424
}
2525
}
2626

27+
contract Parent_ValidateAsInitializer {
28+
uint8 x;
29+
/// @custom:oz-upgrades-validate-as-initializer
30+
function parentAssumeInit() public {
31+
x = 1;
32+
}
33+
}
34+
2735
contract Parent_InitializerModifier is Initializable {
2836
uint8 x;
2937
function parentInit() initializer internal {
@@ -117,6 +125,36 @@ contract MissingInitializer_UnsafeAllow_Contract is Parent_InitializerModifier {
117125
}
118126
}
119127

128+
contract ValidateAsInitializer_Ok is Parent_InitializerModifier {
129+
uint y;
130+
/// @custom:oz-upgrades-validate-as-initializer
131+
function regularFn() public {
132+
parentInit();
133+
y = 2;
134+
}
135+
}
136+
137+
contract Reinitializer_NotDetected is Parent_InitializerModifier {
138+
uint y;
139+
function initializeV2() public reinitializer(2) {
140+
parentInit();
141+
y = 2;
142+
}
143+
}
144+
145+
contract Reinitializer_ValidateAsInitializer_Ok is Parent_InitializerModifier {
146+
uint y;
147+
/**
148+
* Text before
149+
* @custom:oz-upgrades-validate-as-initializer
150+
* Text after
151+
*/
152+
function initializeV2() public reinitializer(2) {
153+
parentInit();
154+
y = 2;
155+
}
156+
}
157+
120158
contract A is Initializable {
121159
uint a;
122160
function __A_init() onlyInitializing internal {
@@ -263,6 +301,58 @@ contract InitializationOrder_UnsafeAllowDuplicate_But_WrongOrder is A, B, C, Par
263301
}
264302
}
265303

304+
contract InitializationOrder_ValidateAsInitializer_Ok is A, B, C, Parent_ValidateAsInitializer {
305+
function initialize() public {
306+
__A_init();
307+
__B_init();
308+
__C_init();
309+
parentAssumeInit();
310+
}
311+
}
312+
313+
contract InitializationOrder_ValidateAsInitializer_WrongOrder is A, B, C, Parent_ValidateAsInitializer {
314+
function initialize() public {
315+
__A_init();
316+
__B_init();
317+
parentAssumeInit();
318+
__C_init();
319+
}
320+
}
321+
322+
contract InitializationOrder_ValidateAsInitializer_MissingCall is A, B, C, Parent_ValidateAsInitializer {
323+
function initialize() public {
324+
__A_init();
325+
__B_init();
326+
__C_init();
327+
}
328+
}
329+
330+
contract InitializationOrder_ValidateAsInitializer_DuplicateCall is A, B, C, Parent_ValidateAsInitializer {
331+
function initialize() public {
332+
__A_init();
333+
__B_init();
334+
__C_init();
335+
parentAssumeInit();
336+
parentAssumeInit();
337+
}
338+
}
339+
340+
contract Parent_ValidateAsInitializer_External_Ok {
341+
uint8 x;
342+
/// @custom:oz-upgrades-validate-as-initializer
343+
function parentAssumeInit() external {
344+
// this is a valid initializer, but it does not need to be called by the child (and it is not possible to do so), because it is external
345+
x = 1;
346+
}
347+
}
348+
349+
contract Child_Of_ValidateAsInitializer_External_Ok is Parent_ValidateAsInitializer_External_Ok {
350+
uint y;
351+
function initialize() public {
352+
y = 2;
353+
}
354+
}
355+
266356
contract WithRequire_Ok is Parent__OnlyInitializingModifier {
267357
uint y;
268358
function initialize(bool foo) initializer public {

packages/core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@openzeppelin/upgrades-core",
3-
"version": "1.42.2",
3+
"version": "1.43.0",
44
"description": "",
55
"repository": "https://github.com/OpenZeppelin/openzeppelin-upgrades/tree/master/packages/core",
66
"license": "MIT",

packages/core/src/validate-initializers.test.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ interface ExpectedErrors {
4242
count: number;
4343
}
4444

45-
function testRejects(name: string, kind: ValidationOptions['kind'], expectedErrors?: ExpectedErrors) {
45+
function testRejects(name: string, kind: ValidationOptions['kind'], expectedErrors: ExpectedErrors) {
4646
testOverride(name, kind, {}, expectedErrors);
4747
}
4848

@@ -98,10 +98,18 @@ testRejects('MissingInitializer_Bad', 'transparent', {
9898
testAccepts('MissingInitializer_UnsafeAllow_Contract', 'transparent');
9999
testOverride('MissingInitializer_Bad', 'transparent', { unsafeAllow: ['missing-initializer'] });
100100

101+
testAccepts('ValidateAsInitializer_Ok', 'transparent');
102+
103+
testRejects('Reinitializer_NotDetected', 'transparent', {
104+
contains: ['Missing initializer'],
105+
count: 1,
106+
});
107+
testAccepts('Reinitializer_ValidateAsInitializer_Ok', 'transparent');
108+
101109
testAccepts('InitializationOrder_Ok', 'transparent');
102110
testAccepts('InitializationOrder_Ok_2', 'transparent');
103111

104-
testAccepts('InitializationOrder_WrongOrder_Bad', 'transparent'); // warn 'Expected initializers to be called for parent contracts in the following order: A, B, C'
112+
testAccepts('InitializationOrder_WrongOrder_Bad', 'transparent'); // warn 'Expected: A, B, C'
105113
testAccepts('InitializationOrder_WrongOrder_UnsafeAllow_Contract', 'transparent');
106114
testAccepts('InitializationOrder_WrongOrder_UnsafeAllow_Function', 'transparent');
107115
testOverride('InitializationOrder_WrongOrder_Bad', 'transparent', { unsafeAllow: ['incorrect-initializer-order'] }); // skips the warning
@@ -122,7 +130,21 @@ testAccepts('InitializationOrder_Duplicate_UnsafeAllow_Contract', 'transparent')
122130
testAccepts('InitializationOrder_Duplicate_UnsafeAllow_Function', 'transparent');
123131
testAccepts('InitializationOrder_Duplicate_UnsafeAllow_Call', 'transparent');
124132
testOverride('InitializationOrder_Duplicate_Bad', 'transparent', { unsafeAllow: ['duplicate-initializer-call'] });
125-
testAccepts('InitializationOrder_UnsafeAllowDuplicate_But_WrongOrder', 'transparent'); // warn 'Expected initializers to be called for parent contracts in the following order: A, B, C'
133+
testAccepts('InitializationOrder_UnsafeAllowDuplicate_But_WrongOrder', 'transparent'); // warn 'Expected: A, B, C'
134+
135+
testAccepts('InitializationOrder_ValidateAsInitializer_Ok', 'transparent');
136+
testAccepts('InitializationOrder_ValidateAsInitializer_WrongOrder', 'transparent'); // warn 'Expected: A, B, C, Parent_ValidateAsInitializer'
137+
testRejects('InitializationOrder_ValidateAsInitializer_MissingCall', 'transparent', {
138+
contains: ['Missing initializer calls for one or more parent contracts: `Parent_ValidateAsInitializer`'],
139+
count: 1,
140+
});
141+
testRejects('InitializationOrder_ValidateAsInitializer_DuplicateCall', 'transparent', {
142+
contains: ['Duplicate calls found to initializer `parentAssumeInit` for contract `Parent_ValidateAsInitializer`'],
143+
count: 1,
144+
});
145+
146+
testAccepts('Parent_ValidateAsInitializer_External_Ok', 'transparent');
147+
testAccepts('Child_Of_ValidateAsInitializer_External_Ok', 'transparent');
126148

127149
testAccepts('WithRequire_Ok', 'transparent');
128150
testRejects('WithRequire_Bad', 'transparent', {
@@ -178,11 +200,11 @@ testRejects('TransitiveChild_Bad_Parent', 'transparent', {
178200
'Missing initializer calls for one or more parent contracts: `TransitiveGrandparent2`', // occurs twice: missing initializer for child, missing initializer for parent
179201
],
180202
count: 2,
181-
}); // warn 'Expected initializers to be called for parent contracts in the following order: TransitiveGrandparent2, TransitiveParent_Bad'
203+
}); // warn 'Expected: TransitiveGrandparent2, TransitiveParent_Bad'
182204
testRejects('TransitiveChild_Bad_Order', 'transparent', {
183205
contains: ['Missing initializer calls for one or more parent contracts: `TransitiveGrandparent2`'],
184206
count: 1,
185-
}); // warn 'Expected initializers to be called for parent contracts in the following order: TransitiveGrandparent2, TransitiveParent_Bad'
207+
}); // warn 'Expected: TransitiveGrandparent2, TransitiveParent_Bad'
186208
testRejects('TransitiveChild_Good_Order_Bad_Parent', 'transparent', {
187209
contains: ['Missing initializer calls for one or more parent contracts: `TransitiveGrandparent2`'],
188210
count: 1,

packages/core/src/validate/run/initializer.ts

Lines changed: 65 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import type { ContractDefinition, FunctionDefinition } from 'solidity-ast';
2+
import type { Node } from 'solidity-ast/node';
23
import { ASTDereferencer, findAll } from 'solidity-ast/utils';
34
import { SrcDecoder } from '../../src-decoder';
45
import { ValidationExceptionInitializer, skipCheck, tryDerefFunction } from '../run';
6+
import { getAnnotationArgs, getDocumentation, hasAnnotationTag } from '../../utils/annotations';
7+
import { logNote } from '../../utils/log';
58

69
/**
710
* Reports if this contract is non-abstract and any of the following are true:
@@ -20,12 +23,12 @@ export function* getInitializerExceptions(
2023
}
2124

2225
const linearizedParentContracts = getLinearizedParentContracts(contractDef, deref);
23-
const parentNameToInitializersMap = getParentNameToInitializersMap(linearizedParentContracts);
26+
const parentNameToInitializersMap = getParentNameToInitializersMap(linearizedParentContracts, decodeSrc);
2427

2528
const remainingParents = getParentsNotInitializedByOtherParents(parentNameToInitializersMap);
2629

2730
if (remainingParents.length > 0) {
28-
const contractInitializers = getPossibleInitializers(contractDef, false);
31+
const contractInitializers = getPossibleInitializers(contractDef, false, decodeSrc);
2932

3033
// Report if there is no initializer but parents need initialization
3134
if (
@@ -71,10 +74,10 @@ function getLinearizedParentContracts(contractDef: ContractDefinition, deref: AS
7174
* Gets a map of parent contract names to their possible initializers.
7275
* If a parent contract has no initializers, it is not included in the map.
7376
*/
74-
function getParentNameToInitializersMap(linearizedParentContracts: ContractDefinition[]) {
77+
function getParentNameToInitializersMap(linearizedParentContracts: ContractDefinition[], decodeSrc: SrcDecoder) {
7578
const map = new Map();
7679
for (const parent of linearizedParentContracts) {
77-
const initializers = getPossibleInitializers(parent, true);
80+
const initializers = getPossibleInitializers(parent, true, decodeSrc);
7881
if (initializers.length > 0) {
7982
map.set(parent.name, initializers);
8083
}
@@ -318,22 +321,65 @@ function getRecursiveFunctionIds(
318321
}
319322

320323
/**
321-
* Get all functions that could be initializers. Does not include private functions.
322-
* For parent contracts, only internal and public functions which contain statements are included.
324+
* Get all functions that are annotated as initializers or are inferred to be initializers.
325+
* Logs a note if any reinitializer is found.
323326
*/
324-
function getPossibleInitializers(contractDef: ContractDefinition, isParentContract: boolean) {
327+
function getPossibleInitializers(
328+
contractDef: ContractDefinition,
329+
isParentContract: boolean,
330+
decodeSrc: SrcDecoder,
331+
): FunctionDefinition[] {
325332
const fns = [...findAll('FunctionDefinition', contractDef)];
326-
return fns.filter(
327-
fnDef =>
328-
(fnDef.modifiers.some(modifier => ['initializer', 'onlyInitializing'].includes(modifier.modifierName.name)) ||
329-
['initialize', 'initializer'].includes(fnDef.name)) &&
330-
// Skip virtual functions without a body, since that indicates an abstract function and is not itself an initializer
331-
!(fnDef.virtual && !fnDef.body) &&
332-
// Ignore private functions, since they cannot be called outside the contract
333-
fnDef.visibility !== 'private' &&
334-
// For parent contracts, only internal and public functions which contain statements need to be called
335-
(isParentContract
336-
? fnDef.body?.statements?.length && (fnDef.visibility === 'internal' || fnDef.visibility === 'public')
337-
: true),
333+
return fns.filter((fnDef: FunctionDefinition) => {
334+
const validateAsInitializer = hasValidateAsInitializerAnnotation(fnDef, decodeSrc);
335+
if (!validateAsInitializer && fnDef.modifiers.some(modifier => 'reinitializer' === modifier.modifierName.name)) {
336+
logNote(`Reinitializers are not included in validations by default`, [
337+
`${decodeSrc(fnDef)}: If you want to validate this function as an initializer, annotate it with '@custom:oz-upgrades-validate-as-initializer'`,
338+
]);
339+
}
340+
341+
return inferPossibleInitializer(fnDef, validateAsInitializer, isParentContract);
342+
});
343+
}
344+
345+
function hasValidateAsInitializerAnnotation(node: Node, decodeSrc: SrcDecoder): boolean {
346+
const doc = getDocumentation(node);
347+
const tag = 'oz-upgrades-validate-as-initializer';
348+
const validateAsInitializer = hasAnnotationTag(doc, tag);
349+
if (validateAsInitializer) {
350+
const annotationArgs = getAnnotationArgs(doc, tag);
351+
if (annotationArgs.length !== 0) {
352+
throw new Error(`${decodeSrc(node)}: @custom:${tag} annotation must not have any arguments`);
353+
}
354+
}
355+
return validateAsInitializer;
356+
}
357+
358+
function hasInitializerNameOrModifier(fnDef: FunctionDefinition) {
359+
return (
360+
fnDef.modifiers.some(modifier => ['initializer', 'onlyInitializing'].includes(modifier.modifierName.name)) ||
361+
['initialize', 'initializer'].includes(fnDef.name)
362+
);
363+
}
364+
365+
/**
366+
* Infers whether a function could be an initializer. Does not include private functions.
367+
* For parent contracts, only internal and public functions which contain statements are included.
368+
*/
369+
function inferPossibleInitializer(
370+
fnDef: FunctionDefinition,
371+
validateAsInitializer: boolean,
372+
isParentContract: boolean,
373+
): boolean {
374+
return (
375+
(validateAsInitializer || hasInitializerNameOrModifier(fnDef)) &&
376+
// Skip virtual functions without a body, since that indicates an abstract function and is not itself an initializer
377+
!(fnDef.virtual && !fnDef.body) &&
378+
// Ignore private functions, since they cannot be called outside the contract
379+
fnDef.visibility !== 'private' &&
380+
// For parent contracts, only internal and public functions which contain statements need to be called
381+
(isParentContract
382+
? Boolean(fnDef.body?.statements?.length) && (fnDef.visibility === 'internal' || fnDef.visibility === 'public')
383+
: true)
338384
);
339385
}

0 commit comments

Comments
 (0)