Skip to content

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 WagerRegistry proxy; voucher redemption (spec 026) shipped as the first in-place upgrade of the MembershipManager proxy (the tradable MembershipVoucher ERC-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.json under e.g. wagerRegistry. The current implementation is under wagerRegistryImpl and 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 from DEFAULT_ADMIN_ROLE) and is non-brickable — no upgrade can remove the upgrade path.

Pre-flight (every upgrade)

  1. 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
  1. The full suite MUST pass (behavior preserved):
npm test
  1. Mount the floppy keystore so the admin (UPGRADER_ROLE) can sign:
npm run floppy:mount

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.

  1. Run the Pre-flight above (compile, check:storage-layout, npm test, mount floppy).
  2. 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):

npm run verify:<network>
npm run sync:frontend-contracts:<network>
  1. 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 upgradeToAndCall if the corrected build is byte-identical to the prior good one.

Failure modes & gotchas

  • check:storage-layout fails → 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 NonceManager doesn't observe; deploy.js resets the NonceManager after the proxy deploy. If scripting manually, re-fetch the nonce.
  • Re-running deploy.js mints 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_ROLE key → the contract keeps working but can never be upgraded again. Protect the floppy keystore; consider moving UPGRADER_ROLE to a timelock/multisig before mainnet scale.
  • Per-contract scope → an upgrade targets one proxy. Upgrading WagerRegistry does not touch MembershipManager (and vice-versa); run the pre-flight, check:storage-layout, and post-upgrade checks separately for each, against its own …Impl record.
  • 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.