ADR-004: Upgradeable contracts via UUPS proxies¶
Status: Accepted
Date: 2026-06-20
Authors: FairWins engineering
Deciders: realcodywburns
Context¶
The active escrow contract WagerRegistry was not upgradeable: state and logic lived in one deployed
contract at a fixed address. Every logic change — even a purely additive feature — required deploying a
new contract at a new address. Because the new contract starts with empty storage, every wager,
balance, and mapping on the old address was left behind: existing wagers were stranded and had to be
settled out-of-band on each release. This made shipping features (e.g. open-challenge wagers, feature 024)
disproportionately costly and risky.
In parallel, the membership work (transferable/giftable memberships via voucher NFTs) hit the same wall and realized upgradeability is an app-wide need, not a WagerRegistry-specific one (PR #724). So the machinery must be reusable across value-bearing contracts.
Constraints: this is the highest-risk surface in the repo (it controls who may replace fund-custody code);
the existing external interface (functions, events, errors, Wager shape) must stay stable; admin keys are
the air-gapped floppy keystore; the live non-upgradeable registry on Polygon cannot be retro-wrapped.
Decision¶
We will make value-bearing contracts upgradeable via OpenZeppelin UUPS proxies (ERC1967Proxy +
UUPSUpgradeable), with the upgrade machinery factored into a reusable base UUPSManaged that every
such contract inherits. WagerRegistry is the first adopter (spec 025); MembershipManager is the second
(sibling spec), after which the voucher feature ships as the membership proxy's first in-place upgrade.
The legacy non-upgradeable registry is handled by coexistence, not migration: it becomes settle-only while new wagers use the proxy.
Rationale¶
- UUPS over Transparent/Beacon/Diamond: smallest proxy, lowest gas, OZ's current default; authorization lives in the implementation so we enforce non-brickability and least privilege in code we control; each contract gets its own impl (Beacon's shared-impl model doesn't fit WagerRegistry ≠ MembershipManager).
- Reusable
UUPSManagedbase (PR #724): one audited place for the cross-cutting primitives —_disableInitializers(footgun defense), one-timeinitializer, a least-privilege non-brickable_authorizeUpgradegate, and storage__gap— so the second adopter doesn't reimplement (or drift from) the security-critical wiring. - Storage-layout safety as a CI gate: OpenZeppelin
hardhat-upgradesvalidateUpgradeblocks a reordered/removed/retyped-storage (state-corrupting) upgrade before it can be applied. - Coexistence cutover: the live contract can't be retro-wrapped and on-chain fund migration is high-risk; legacy wagers settling on the old address is the honest, low-risk one-time cost.
Consequences¶
Positive¶
- Future logic ships as an in-place upgrade: stable address, preserved state, no stranded wagers, no frontend/subgraph repoint (only the ABI grows).
- The pattern is reusable:
MembershipManager+ voucher redemption ride the same base + tooling. - Behavior-neutral migration — the full existing test suite passes unchanged through the proxy.
Negative¶
- A one-time coexistence window where the app surfaces both the legacy (settle-only) registry and the new proxy until legacy wagers drain.
- Two new dependencies (
@openzeppelin/contracts-upgradeable,@openzeppelin/hardhat-upgrades), pinned to the audited OZ stack already in use. - Upgradeability is a powerful capability over funds — mitigated by least-privilege
UPGRADER_ROLE+ the floppy keystore + the storage-layout CI gate + security review.
Risks¶
- Uninitialized implementation hijack → mitigated by
_disableInitializers()in the base constructor. - Re-initialization to seize roles → mitigated by the one-time
initializer(re-init reverts). - Bricked upgrade path → mitigated by a non-brickable
_authorizeUpgradethat no upgrade can remove. - State corruption via bad layout → mitigated by append-only storage +
__gap+ the CIvalidateUpgradegate. - Inline initializer bug → the
_nextWagerId = 1inline initializer was moved intoinitialize(inline initializers run in constructor context and are ignored behind a proxy).
Alternatives Considered¶
Alternative 1: Transparent proxy¶
Upgrade logic in a separate ProxyAdmin. Cons: more gas, an extra admin contract, heavier admin/selector
model. Why not chosen: UUPS is lighter and its role-gated authorization fits the floppy-keystore flow.
Alternative 2: Beacon proxy¶
One beacon upgrades many proxies sharing one implementation. Cons: our upgradeable contracts are different implementations. Why not chosen: adds indirection with no benefit here.
Alternative 3: Diamond (EIP-2535)¶
Modular facets. Cons: larger audit/tooling surface than needed. Why not chosen: UUPS hosts growing surfaces via additive upgrades (exactly how feature 024 lands).
Alternative 4: Drain-first or on-chain state migration at cutover¶
Cons: drain-first means pausing creation and waiting out all resolve deadlines (long); state migration of escrowed funds is complex and risky. Why not chosen: coexistence is lower-risk and honest.
Implementation Notes¶
contracts/upgradeable/UUPSManaged.sol— the reusable base. Adopters inherit it, call__UUPSManaged_init(admin)first in theirinitializer, and keep state append-only with a trailing__gap.- Storage is append-only: never insert/reorder/remove existing variables; new state consumes
__gap. Runnpm run check:storage-layout(gating in CItest.yml). - Deploy/upgrade via
scripts/deploy/lib/upgradeable.js(deployProxy/upgradeProxy); both proxy and implementation addresses are recorded indeployments/(wagerRegistry= proxy,wagerRegistryImpl= implementation). - Operational procedure:
docs/runbooks/contract-upgrades.md. Reuse guide for new contracts:docs/developer-guide/upgradeable-contracts.md.
References¶
- spec 025:
specs/025-upgradeable-registry/(spec.md, plan.md, research.md, data-model.md, contracts/) - PR #724 (this work); feature 024 (
specs/024-open-challenge-wagers/) ships as the first in-place upgrade - ADR-001: Trail of Bits toolchain
- OpenZeppelin UUPS / Upgrades Plugins documentation
Revision History¶
| Date | Changes | Author |
|---|---|---|
| 2026-06-20 | Initial version | FairWins engineering |