โ† 0xrivet

proxy upgrade patterns โ€” a field guide

2024-03-15 ยท proxy patterns ยท portland, or

most people check if a contract is upgradeable. good start. almost nobody traces the full authority chain from the proxy to the human being who can actually push a new implementation. that's where the real information is.

a proxy being upgradeable is a boolean. who can upgrade it, how fast, and with what oversight โ€” that's the threat model.


four patterns, four fingerprints

every upgradeable contract you'll encounter in production fits one of these. each leaves a different on-chain fingerprint. here's how to identify them cold.

1. transparent proxy (EIP-1967)

the workhorse. OpenZeppelin's default. admin and implementation addresses live in standardized storage slots so block explorers can find them.

# read the implementation address
cast storage <PROXY> \
  0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc

# read the admin address
cast storage <PROXY> \
  0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103

if both slots return nonzero, you're looking at a transparent proxy. the admin slot typically points to a ProxyAdmin contract, not the actual authority. keep pulling the thread.

2. UUPS (EIP-1822)

upgrade logic lives in the implementation, not the proxy. the proxy is thin โ€” just delegates everything. same EIP-1967 implementation slot, but the admin slot is usually empty.

# implementation slot populated?
cast storage <PROXY> \
  0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc

# admin slot empty? likely UUPS
cast storage <PROXY> \
  0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103

# confirm: does the implementation expose upgradeToAndCall?
cast call <IMPL> "proxiableUUID()(bytes32)" 2>/dev/null

if proxiableUUID() returns the EIP-1967 implementation slot hash, it's UUPS. authority is whoever the implementation considers its owner โ€” usually an owner() or access control role on the impl itself.

3. beacon proxy

multiple proxies share a single beacon. upgrade the beacon, all proxies update simultaneously. used when you need to deploy hundreds of identical contracts (vaults, pools, user accounts).

# read the beacon address
cast storage <PROXY> \
  0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50

# get the current implementation from the beacon
cast call <BEACON> "implementation()(address)"

# who owns the beacon?
cast call <BEACON> "owner()(address)"

the leverage here is enormous. compromise the beacon owner once, upgrade every proxy in the fleet. one key, many contracts.

4. diamond proxy (EIP-2535)

most complex pattern. a single proxy delegates to multiple implementation contracts ("facets") based on function selectors. a router, not a proxy in the traditional sense.

# list all facets and their selectors
cast call <DIAMOND> \
  "facets()((address,bytes4[])[])"

# check a specific function โ†’ which facet handles it
cast call <DIAMOND> \
  "facetAddress(bytes4)(address)" 0x8da5cb5b

# who can add/replace/remove facets?
cast call <DIAMOND> "owner()(address)"

diamonds are powerful and dangerous. adding a facet is equivalent to deploying arbitrary new code behind the same address. there's no "implementation diff" โ€” every diamondCut can introduce entirely new attack surface.


the authority chain

identifying the proxy pattern is step one. the real question: who's at the end of the chain?

proxy
 โ””โ”€ ProxyAdmin (or beacon, or diamond owner)
     โ””โ”€ owner()
         โ””โ”€ timelock?
             โ””โ”€ proposer role
                 โ””โ”€ multisig
                     โ””โ”€ threshold + signers
                         โ””โ”€ are the signers EOAs or contracts?

every link is a cast call away. here's the full trace for the most common case โ€” transparent proxy with OZ governance stack:

P="<PROXY>"

# step 1: proxy โ†’ admin
ADMIN=$(cast storage $P \
  0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103 \
  | cut -c27- | sed 's/^/0x/')

# step 2: admin โ†’ owner
OWNER=$(cast call $ADMIN "owner()(address)")

# step 3: is the owner a contract?
CODE=$(cast code $OWNER | head -c 10)
# "0x" = EOA. stop here. this is the god key.
# anything else = contract. keep going.

# step 4: if timelock โ€” what's the delay?
cast call $OWNER "getMinDelay()(uint256)" 2>/dev/null

# step 5: if multisig โ€” what's the threshold?
cast call $OWNER "getThreshold()(uint256)" 2>/dev/null
cast call $OWNER "getOwners()(address[])" 2>/dev/null

stop when you hit an EOA or a governance token. that's the trust root.


traps

five things i see constantly in the wild. any one of them collapses the entire upgrade security model.

ProxyAdmin owned by an EOA. one private key controls every contract behind that admin. no timelock, no multisig, no delay. i've seen this on protocols with nine-figure TVL. check it. always.

timelock with zero delay. getMinDelay() returns 0. the timelock exists as a contract but provides no protection. transactions execute in the same block they're proposed. i've written about this one before โ€” it's raining in portland and i'm still finding these, which says something.

UUPS implementation not initialized. the nastiest footgun in the pattern. if the implementation contract itself was never called with initialize(), anyone can call it, become the owner, and call upgradeToAndCall with a self-destructing contract. the proxy is now bricked โ€” permanently. not a hypothetical. this has happened. OpenZeppelin added _disableInitializers() in the constructor for a reason.

# check if UUPS impl is initialized
# slot 0 of Initializable stores the version
cast storage <IMPL> 0x0
# 0x00 = uninitialized = critical

diamond with unchecked facet additions. diamondCut can add selectors that shadow existing ones. no standard enforces collision checks. a malicious facet can silently replace transfer(), approve(), or any function by registering the same selector. the DiamondCutFacet owner is the most powerful role in the system โ€” treat accordingly.

multisig with a module. gnosis safe modules can execute transactions without meeting the threshold. a 4-of-7 multisig with a module attached is really a 1-of-1 if the module is compromised.

# check for modules on a gnosis safe
cast call <SAFE> "getModules()(address[])"
# [] = clean. anything else = investigate

minimum viable check

three commands. takes 30 seconds. tells you the security posture of any upgradeable contract.

TARGET="<CONTRACT>"

# 1. is it upgradeable? who's the admin?
cast storage $TARGET \
  0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103

# 2. is the admin an EOA or a contract?
cast code <ADMIN_FROM_STEP_1> | head -c 10
# "0x" alone = EOA = single point of failure

# 3. if contract: multisig threshold or timelock delay?
cast call <ADMIN_FROM_STEP_1> "getThreshold()(uint256)" 2>/dev/null || \
cast call <ADMIN_FROM_STEP_1> "getMinDelay()(uint256)" 2>/dev/null

if the admin slot is empty, check the beacon slot (0xa3f0...3d50) and the implementation for UUPS. if both are empty, try facets() for diamond. one of them will hit.

what you learn: is it upgradeable (admin slot populated), how many humans need to agree (threshold), and how much warning you get (delay). three numbers. that's the floor of your trust model.


the proxy pattern tells you the mechanism. the authority chain tells you the risk. trace both.


written on a mass transit delay somewhere on the blue line. โ€” 0xrivet