Making a contract upgradeable (reusing UUPSManaged)¶
This guide shows how to make a value-bearing contract upgradeable by reusing the shared primitives built in
spec 025 — without reimplementing the proxy, authorization, storage-safety, or deploy machinery
(the PR #724 ask). WagerRegistry is the reference adopter (spec 025); MembershipManager (spec 027) is the
second, with voucher redemption (spec 026) shipped as its first in-place upgrade — both are now live.
Background: ADR-004. Operations: runbooks/contract-upgrades.md.
What you reuse (don't rebuild)¶
| Piece | Where | What it gives you |
|---|---|---|
UUPSManaged base |
contracts/upgradeable/UUPSManaged.sol |
UUPS + AccessControl, UPGRADER_ROLE, _disableInitializers, non-brickable _authorizeUpgrade, base __gap |
| Storage-layout gate | scripts/deploy/check-storage-layout.js + CI test.yml |
Blocks reordered/removed/retyped (state-corrupting) upgrades |
| Deploy/upgrade tooling | scripts/deploy/lib/upgradeable.js |
deployProxy / upgradeProxy / getImplementation, records proxy+impl in deployments/ |
Steps to make YourContract upgradeable¶
1. Inherit the base and swap OZ bases to their *Upgradeable variants¶
import {UUPSManaged} from "../upgradeable/UUPSManaged.sol";
// e.g. ReentrancyGuardUpgradeable / PausableUpgradeable from @openzeppelin/contracts-upgradeable IF needed
contract YourContract is IYour, UUPSManaged /*, ReentrancyGuardUpgradeable, ... */ {
UUPSManaged already brings AccessControlUpgradeable — do not also list it. Only add the upgradeable bases
your contract actually uses (e.g. MembershipManager needs neither reentrancy nor pause).
2. Replace the constructor with a one-time initialize¶
function initialize(address admin, /* ...your args... */) external initializer {
__UUPSManaged_init(admin); // FIRST — UUPS + AccessControl; grants DEFAULT_ADMIN_ROLE + UPGRADER_ROLE
// __ReentrancyGuard_init(); __Pausable_init(); // only those you inherit
// ... your former constructor body, verbatim ...
}
Critical: move any inline state initializers into initialize (e.g. uint256 x = 1; →
declare uint256 x; and set x = 1; inside initialize). Inline initializers run in constructor context
and are ignored behind a proxy. This is the single most common conversion bug — WagerRegistry's
_nextWagerId = 1 is the canonical example.
Do not write your own constructor; the implementation's constructor is inherited from UUPSManaged and calls
_disableInitializers() (so a bare implementation can never be initialized).
3. Keep storage append-only and add a __gap¶
Add a trailing reserve after your last state variable:
When a later upgrade adds state, append it after existing variables (it consumes __gap); never insert,
reorder, remove, or retype existing storage. If a new variable needs seeding, add a reinitializer(N)
function (state that defaults to 0 needs none).
4. Register it with the tooling¶
- Add
{ name: "YourContract", deploymentsKey: "yourContract" }toUPGRADEABLE_CONTRACTSinscripts/deploy/check-storage-layout.jsso CI validates it. - Deploy via
deployProxy({ name: "YourContract", initArgs: [...] })in your deploy script; it recordsyourContract(proxy) andyourContractImplindeployments/. - Verify the implementation (empty constructor args) in
verify.js.
5. Tests¶
- Route existing tests through a proxy (see
test/helpers/proxy.jsfor theWagerRegistrypattern, or use thehardhat-upgradesplugin'supgrades.deployProxy). - Add upgrade-lifecycle tests (see
test/upgradeable/): deploy, in-place upgrade preserves state, onlyUPGRADER_ROLEupgrades, re-init reverts, storage-incompatible upgrade is rejected.
The membership → voucher path (worked example)¶
- MembershipManager (spec 027 — ✅ implemented): inherits
UUPSManaged;constructor(admin, paymentToken_, treasury_)converted →initialize;_tiers / _memberships / authorizedCallers / paymentToken / treasury / accruedFees / sanctionsGuard / memberTermsHashkept append-only with a trailing__gap; deployed viadeployProxyand registered in the storage-layout check ({ name: "MembershipManager", deploymentsKey: "membershipManager" }). Behavior-neutral cutover (memberships are 30-day time-bound, so the legacy coexistence window drains in ~a month);WagerRegistryis repointed viasetMembershipManager(proxy). - Voucher redemption (spec 026 — ✅ implemented): two parts. The tradable asset is a separate,
immutable
MembershipVoucher(contracts/access/MembershipVoucher.sol) — an ERC-721 bearer claim that is intentionally not upgradeable (a bearer asset's rules must not change after purchase, and it minimizes the attack surface on a USDC-taking contract). Only the mutable redemption logic (screening, Terms, grant) is added to the membership proxy as its first in-place upgrade — append the voucher pointer +redeemVoucher/setVoucher, thenupgradeProxy({ name: "MembershipManager", proxyAddress }). No membership redeploy, no state migration, no broad role grant — exactly mirroring how feature 024 lands on the WagerRegistry proxy. (So: don't make the voucher upgradeable; do ship its redemption logic as an upgrade.)
Don'ts¶
- Don't copy-paste the UUPS wiring into your contract — inherit
UUPSManaged. - Don't add a constructor with logic — use
initialize. - Don't insert/reorder storage — append only, behind
__gap. - Don't bypass
check:storage-layout— it is the guardrail against fund-corrupting upgrades.