Skip to main content

Upgrade Safety

Olla uses the UUPS (Universal Upgradeable Proxy Standard) pattern for core contracts. This page documents the upgrade philosophy, storage layout rules, and operational procedures.

What is upgradeable

ContractUpgradeablePatternAuthorized by
OllaCoreYesUUPS proxyOwner (OllaGovernance)
OllaVaultYesUUPS proxyOwner (OllaGovernance)
WithdrawalQueueYesUUPS proxyDEFAULT_ADMIN_ROLE (OllaGovernance)
RewardsAccumulatorYesUUPS proxyDEFAULT_ADMIN_ROLE (OllaGovernance)
StakingManagerYesUUPS proxyDEFAULT_ADMIN_ROLE (OllaGovernance)
StakingProviderRegistryYesUUPS proxyDEFAULT_ADMIN_ROLE (OllaGovernance)
OllaGovernanceYesUUPS proxySelf (via timelock)
SafetyModuleNoPlain contractReplaced via OllaCore.setSafetyModule()
StAztecNoPlain ERC-20Immutable

Why SafetyModule is not upgradeable

The SafetyModule is the protocol's circuit breaker. Making it silently upgradeable via proxy would undermine trust. Users need to verify exactly what code can pause the protocol. If the SafetyModule needs changes, governance deploys a new instance and calls OllaCore.setSafetyModule() (requires unpaused state, no active rebalance).

Storage layout rules

All upgradeable contracts follow these rules to prevent storage collisions:

Storage gaps

Every upgradeable contract reserves 50 storage slots as a gap:

uint256[50] private __gap;

When adding new state variables to an upgrade:

  1. Append new variables immediately above the __gap declaration.
  2. Reduce the gap size by the number of slots consumed.
  3. Never reorder, remove, or change the type of existing state variables.

For example, adding one uint256 variable:

// Before
uint256[50] private __gap;

// After
uint256 public newVariable;
uint256[49] private __gap; // 50 - 1 = 49

What breaks storage

These changes will corrupt contract state and must never be done:

  • Changing the order of existing state variables.
  • Changing the type of an existing variable (e.g., uint256 to address).
  • Removing a state variable (removing from the middle shifts all subsequent slots).
  • Inserting a variable between existing variables.
  • Changing an inherited contract's storage layout.

What is safe

  • Appending new variables at the end (before the gap).
  • Reducing the gap to accommodate new variables.
  • Adding new constants or immutables (these don't use storage slots).
  • Adding new internal/private functions.
  • Changing function logic without modifying storage.

CI enforcement

Storage layout compatibility is validated on every pull request by the storage-layout-check.yml GitHub Actions workflow.

How it works

  1. Each upgradeable contract has a committed storage layout fixture in contracts/upgrade/fixtures/.
  2. On every PR, CI runs forge inspect <Contract> storage-layout and compares against the fixture.
  3. If the layout has changed, the PR fails with a diff showing which slots moved.

Updating fixtures

If a storage layout change is intentional (e.g., you added a new state variable and shrunk the gap):

yarn check:storage         # See what changed
yarn check:storage:update # Regenerate all fixtures

Commit the updated fixtures alongside the contract change. The PR reviewer should verify that:

  • New variables are appended (not inserted).
  • The gap decreased by the correct amount.
  • No existing variable changed position or type.

Upgrade procedure

All upgrades go through the governance timelock.

Pre-upgrade checklist

  • New implementation compiles and passes all tests.
  • Storage layout check passes (yarn check:storage).
  • New implementation has been deployed and verified on a testnet.
  • initialize() is not callable on the new implementation (constructor calls _disableInitializers()).
  • If the upgrade adds new state that needs initialization, use upgradeToAndCall() with a migration function.

Upgrade flow

For OllaCore and OllaVault (owned by OllaGovernance):

1. Deploy new implementation contract
2. Governance admin schedules: upgradeCore(newImpl) or upgradeSatellite(proxy, newImpl)
3. Wait for timelock delay
4. Governance admin executes the scheduled call
5. OllaGovernance calls proxy.upgradeToAndCall(newImpl, "")

For satellite contracts (WithdrawalQueue, RewardsAccumulator, StakingManager, StakingProviderRegistry):

1. Deploy new implementation contract  
2. Governance admin schedules: upgradeSatellite(proxyAddress, newImpl)
3. Wait for timelock delay
4. Governance admin executes

For OllaGovernance (self-upgrade):

1. Deploy new implementation contract
2. Governance admin schedules: upgradeToAndCall(newImpl, "")
3. Wait for timelock delay
4. Governance admin executes
5. OllaGovernance calls _authorizeUpgrade(newImpl) on itself

Post-upgrade verification

  • Verify the proxy's implementation address changed: cast call <proxy> "implementation()" --rpc-url <url>
  • Verify state is intact: run PrintState.s.sol and compare key values.
  • Verify new functionality works on-chain (if applicable).
  • Monitor for unexpected behavior in the next rebalance and accounting cycle.

Rollback considerations

UUPS upgrades are not reversible in the traditional sense. You cannot "undo" an upgrade because:

  • The proxy now points to the new implementation.
  • If the new implementation changed storage, reverting to the old implementation would read corrupted state.

The mitigations are:

  1. Timelock delay: Gives the community time to review and react before an upgrade takes effect. Users can exit the protocol during this window.
  2. Testnet verification: Always deploy and test on Sepolia before mainnet.
  3. Forward-fix: If an upgrade introduces a bug, deploy a corrected implementation and upgrade again.
  4. Emergency pause: If the upgraded contract is actively harmful, the guardian can pause to stop further damage while a fix is prepared.