OpenZeppelin (upgrades + utilities)
@openzeppelin/upgrades-core, upgrade-safe-transpiler, merkle-tree and
docs-utils are in all three of our Foundry contract suites.
Docs: https://docs.openzeppelin.com/contracts
Upgradeable contracts
We use UUPS, not Transparent — the upgrade logic lives in the implementation, so the proxy is cheaper to deploy, and we deploy one proxy per property.
contract AuctionManager is Initializable, UUPSUpgradeable, OwnableUpgradeable {
function initialize(address usdc_, address owner_) public initializer {
__Ownable_init(owner_);
__UUPSUpgradeable_init();
usdc = IERC20(usdc_);
}
function _authorizeUpgrade(address) internal override onlyOwner {}
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() { _disableInitializers(); }
}
Four things that are not optional:
_disableInitializers()in the constructor. Without it, anyone can initialize the implementation contract directly and, with UUPS, callupgradeToon it — a known way to brick the proxy.initialize, not a constructor. Constructor code never runs in the proxy's context, so any state set there is invisible._authorizeUpgrademust be access-controlled. An empty override with no modifier makes the contract upgradeable by anyone.- Use the
Upgradeablevariants of every OZ base (OwnableUpgradeable, notOwnable). Mixing them corrupts storage.
Storage layout — the rule you cannot break
Upgrades keep the proxy's storage. You may append variables. You may never insert, reorder, remove, or change the type of an existing one.
contract V1 { uint256 a; address b; }
contract V2 { uint256 a; address b; uint256 c; } // ✅ appended
contract V2 { uint256 a; uint256 c; address b; } // ❌ b and c now read each other's bytes
Leave a gap in base contracts meant to be extended:
uint256[50] private __gap;
Validate before every upgrade — this is what upgrades-core is for:
npx @openzeppelin/upgrades-core validate out/build-info
Run it in CI. A storage-layout mistake is not recoverable after the fact; the funds are already misread.
Merkle allowlists
@openzeppelin/merkle-tree for whitelists (course access, qualified bidders) — one
32-byte root onchain instead of N addresses.
import { StandardMerkleTree } from "@openzeppelin/merkle-tree";
const tree = StandardMerkleTree.of(entries.map(a => [a]), ["address"]);
// publish tree.root onchain, serve proofs from the API
if (!MerkleProof.verify(proof, root, keccak256(bytes.concat(keccak256(abi.encode(msg.sender)))))) revert NotAllowed();
The double-hash is StandardMerkleTree's leaf encoding. Single-hashing it is the
usual reason a proof verifies in the JS test and fails onchain.
Other OZ we rely on
SafeERC20for every USDC movement — see../ekx-circle-usdc/SKILL.md.ReentrancyGuardon anything that transfers out then updates state.AccessControloverOwnableonce there is more than one privileged role.Pausableas a circuit breaker on contracts holding user funds.
Gotchas
- Missing
_disableInitializers()— implementation hijack. - Storage layout change — unrecoverable corruption.
- Non-
Upgradeablebase contracts mixed in. - Unprotected
_authorizeUpgrade. - Single-hashed merkle leaves.
initializervsreinitializer(2)— a V2 adding new state needsreinitializer, and it must be callable exactly once.- OZ v5 changed
Ownable's constructor to require an owner argument. v4 code does not compile against v5.