Runbook: Upgrading the contracts (UUPS proxies)¶
How to deploy and upgrade the upgradeable contracts. Two contracts are UUPS proxies today —
WagerRegistry (spec 025) and MembershipManager (spec 027) — plus any future contract that inherits
UUPSManaged. This runbook is written with WagerRegistry in the examples; for MembershipManager substitute
the contract name and its deployment keys (membershipManager proxy / membershipManagerImpl). Background:
ADR-004. Reuse guide for making a new contract upgradeable:
developer-guide/upgradeable-contracts.md.
Worked precedents. Feature 024 (open challenges) shipped as an in-place upgrade of the
WagerRegistryproxy; voucher redemption (spec 026) shipped as the first in-place upgrade of theMembershipManagerproxy (the tradableMembershipVoucherERC-721 is a separate, immutable contract — not upgraded). Both followed the In-place upgrade steps below verbatim.Why this matters: an upgrade replaces the code that custodies user funds. The proxy address never changes and all state is preserved — but a bad storage layout or a lost upgrade key is catastrophic. Follow this runbook exactly. Upgrades are authorized by
UPGRADER_ROLE, held by the air-gapped floppy-keystore admin.
Key facts¶
- Each upgradeable contract is an ERC1967 UUPS proxy in front of a swappable implementation.
- The proxy address is the stable one users / frontend / subgraph use; it is recorded in
deployments/<network>-chain<id>-v2.jsonunder e.g.wagerRegistry. The current implementation is underwagerRegistryImpland changes on every upgrade. - Storage is append-only: never insert, reorder, remove, or retype existing state variables; new state
consumes the trailing
__gap. The CI gate (npm run check:storage-layout) blocks an incompatible upgrade. - Upgrade authorization is
UPGRADER_ROLE(separate fromDEFAULT_ADMIN_ROLE) and is non-brickable — no upgrade can remove the upgrade path.
Pre-flight (every upgrade)¶
- The new implementation MUST keep storage append-only. Validate locally:
npm run compile
npm run check:storage-layout # OZ validateUpgrade vs the recorded deployed impl — FAILS on incompat
- The full suite MUST pass (behavior preserved):
- Mount the floppy keystore so the admin (
UPGRADER_ROLE) can sign:
First deploy (cutover — proxy with current logic)¶
This stands up the proxy running the current logic on a network for the first time. Testnet first.
# Amoy (testnet)
npm run deploy:amoy # deploys ERC1967Proxy + implementation, records both in deployments/
npm run verify:amoy # verifies the implementation; explorer links the proxy to it
npm run sync:frontend-contracts:amoy # frontend points at the PROXY address (stable)
Source verification reports a PARTIAL match — this is expected, not an incident¶
Since spec 080 the compiler is configured with metadata.bytecodeHash: "none", which keeps the
source-file fingerprint out of the compiled bytes. That is deliberate: it is what stops a file move
or a directory rename from silently relocating every CREATE2 address.
The consequence lands here. A verifier that compares the embedded provenance hash — Sourcify's "perfect match", for instance — will report a partial match rather than an exact one, because there is no longer a fingerprint to compare. Verifiers that recompile from the declared compiler settings are unaffected, and both explorers this repo uses do that:
| Verifier | Result |
|---|---|
Etherscan / Blockscout (npm run verify:<network>, scripts/deploy/verify.js) |
verifies normally — they replay the declared settings |
| Sourcify-style provenance comparison | partial match — expected, not a failure |
Do not "fix" a partial match by re-enabling the metadata hash. Doing so re-couples every deterministic address to the source tree layout, which is the defect spec 080 exists to remove. If a partial match is ever a hard requirement for a particular chain or listing, that is a decision to take deliberately with the address consequence understood.
Validate on Amoy (see specs/025-upgradeable-registry/quickstart.md §5): run the full wager lifecycle
against the proxy and confirm parity with the legacy registry; confirm the deployments record has
wagerRegistry (proxy), wagerRegistryImpl, and the legacy address; confirm the frontend shows legacy
wagers as settle-only (coexistence) and new wagers on the proxy.
# Polygon (mainnet) — only after Amoy sign-off
npm run deploy:polygon && npm run verify:polygon && npm run sync:frontend-contracts:polygon
In-place upgrade (ship new logic — e.g. feature 024)¶
The proxy address does NOT change; all state and funds are preserved.
- Run the Pre-flight above (compile,
check:storage-layout,npm test, mount floppy). - Upgrade via the generic tooling. From a one-off hardhat script or console using
scripts/deploy/lib/upgradeable.js:
const { upgradeProxy } = require("./scripts/deploy/lib/upgradeable");
// proxyAddress = deployments[...].contracts.wagerRegistry
await upgradeProxy({ name: "WagerRegistry", proxyAddress });
// optional reinitializer for new state needing seeding:
// await upgradeProxy({ name: "WagerRegistry", proxyAddress, call: { fn: "initializeVN", args: [...] } });
upgradeProxy runs validateUpgrade (storage-layout safety) BEFORE sending anything on-chain, deploys
the new implementation, calls upgradeToAndCall (signed by the floppy UPGRADER_ROLE admin), and updates
wagerRegistryImpl in the deployments record.
3. Verify the new implementation and re-sync the frontend ABI (address unchanged):
- Post-upgrade checks: confirm the proxy address is unchanged; spot-check that existing wagers still read and resolve correctly; confirm new functions are live.
Rollback / abort¶
- Before
upgradeToAndCall: nothing changed on-chain — fix the implementation and re-run pre-flight. - After a bad upgrade: deploy a corrected implementation and upgrade again (the upgrade path is
non-brickable). There is no automatic downgrade; "rollback" = upgrade forward to a corrected impl. Keep the
previous implementation address (recorded in git history of the deployments file) so you can re-point to it
via
upgradeToAndCallif the corrected build is byte-identical to the prior good one.
Failure modes & gotchas¶
check:storage-layoutfails → the new layout reorders/removes/retypes existing state. Make the change append-only (add new vars at the end, drawing from__gap). Do NOT bypass the gate.- "Nonce too low" during deploy → the OZ plugin sends txs the script's
NonceManagerdoesn't observe;deploy.jsresets the NonceManager after the proxy deploy. If scripting manually, re-fetch the nonce. - Re-running
deploy.jsmints a NEW proxy (not idempotent, unlike the old CREATE2 deploy). To change logic on an existing deployment, run an upgrade, never the deploy script. - Lost
UPGRADER_ROLEkey → the contract keeps working but can never be upgraded again. Protect the floppy keystore; consider movingUPGRADER_ROLEto a timelock/multisig before mainnet scale. - Per-contract scope → an upgrade targets one proxy. Upgrading
WagerRegistrydoes not touchMembershipManager(and vice-versa); run the pre-flight,check:storage-layout, and post-upgrade checks separately for each, against its own…Implrecord. - Network state → Amoy (80002) and Mordor/ETC (63) run the feature-complete upgradeable set (UUPS registry + UUPS membership + voucher + open challenges). Polygon mainnet (137) is still the pre-UUPS set; its first deploy is a cutover to proxies (carrying members over), not an in-place upgrade.