Potential false positive "Missing initializer calls for one or more parent contracts"

:1234: Code to reproduce

I think I've handled the initializer calls for my upgradeable ERC20 token contract correctly.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;

import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol";
import {Upgrades} from "@openzeppelin/foundry-upgrades/Upgrades.sol";

import {Test} from "forge-std/Test.sol";

contract Token is ERC20Upgradeable, UUPSUpgradeable {
    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers();
    }

    function initialize(address initialOwner) external initializer {
        __Token_init(initialOwner);
    }

    function __Token_init(address initialOwner) internal onlyInitializing {
        __Context_init_unchained();
        __ERC20_init_unchained("Token", "TOK");
        __UUPSUpgradeable_init_unchained();

        __Token_init_unchained(initialOwner);
    }

    function __Token_init_unchained(address initialOwner) internal onlyInitializing {
        _mint(initialOwner, 100);
    }

    function _authorizeUpgrade(address newImplementation) internal pure override {
        {
            newImplementation;
        }
        // NOTE: In this MWE, anyone can upgrade. DO NOT USE IN PRODUCTION.
    }
}

However, deploying it with Upgrades.deployUUPSProxy in the test below

contract TokenTest is Test {
    address internal _defaultSender;
    Token internal _tokenProxy;

    function setUp() public {
        (, _defaultSender,) = vm.readCallers();

        // Deploy proxy and mint tokens for the `_defaultSender`.
        vm.prank(_defaultSender);
        _tokenProxy = Token(
            Upgrades.deployUUPSProxy({
                contractName: "Token.sol:Token",
                initializerData: abi.encodeCall(Token.initialize, _defaultSender)
            })
        );
    }

    function test_balance() public view {
        assertEq(_tokenProxy.balanceOf(_defaultSender), 100);
    }
}

fails with the following error

[FAIL: revert: Upgrade safety validation failed:
✘  src/Token.sol:Token

    src/Token.sol:29: Missing initializer calls for one or more parent contracts: `ERC20Upgradeable`
        Call the parent initializers in your initializer function
        https://zpl.in/upgrades/error-001

FAILED] setUp() (gas: 0)

I've provided a minimal example to reproduce the error in this repo https://github.com/heueristik/token.
To reproduce the problem, run

git clone --recursive https://github.com/heueristik/token
cd token
forge clean && forge build && forge test

Here is the workflow in which the test from above fails: https://github.com/heueristik/token/actions/runs/14624117496/job/41031361022#step:7:10

:laptop: Environment

  • OS:
    • macOS 15.3.1 (sequoia)
    • Apple M1
  • Forge
    forge Version: 1.0.0-stable
    Commit SHA: e144b82070619b6e10485c38734b4d4d45aebe04
    Build Timestamp: 2025-02-13T20:02:34.979686000Z (1739476954)
    Build Profile: maxperf
    
  • OZ
    • openzeppelin-contracts-upgradeable: v5.3.0
    • openzeppelin-foundry-upgrades: v0.4.0

Am I doing something wrong? Thank you for your help

It seems like the Upgrades library thinks that __Token_init_unchained is an initializer that must call the parent initializer as well.

This got answered here https://github.com/OpenZeppelin/openzeppelin-upgrades/issues/1150

Thanks for reporting. It seems like the fix should be to avoid checking for parent initializer calls if the initializer name ends with _unchained, since that is its typical usage pattern.
As a workaround for now, you can add /// @custom:oz-upgrades-unsafe-allow missing-initializer-call above the __Token_init_unchained function.