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
| Contract | Upgradeable | Pattern | Authorized by |
|---|---|---|---|
| OllaCore | Yes | UUPS proxy | Owner (OllaGovernance) |
| OllaVault | Yes | UUPS proxy | Owner (OllaGovernance) |
| WithdrawalQueue | Yes | UUPS proxy | DEFAULT_ADMIN_ROLE (OllaGovernance) |
| RewardsAccumulator | Yes | UUPS proxy | DEFAULT_ADMIN_ROLE (OllaGovernance) |
| StakingManager | Yes | UUPS proxy | DEFAULT_ADMIN_ROLE (OllaGovernance) |
| StakingProviderRegistry | Yes | UUPS proxy | DEFAULT_ADMIN_ROLE (OllaGovernance) |
| OllaGovernance | Yes | UUPS proxy | Self (via timelock) |
| SafetyModule | No | Plain contract | Replaced via OllaCore.setSafetyModule() |
| StAztec | No | Plain ERC-20 | Immutable |
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:
- Append new variables immediately above the
__gapdeclaration. - Reduce the gap size by the number of slots consumed.
- 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.,
uint256toaddress). - 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
- Each upgradeable contract has a committed storage layout fixture in
contracts/upgrade/fixtures/. - On every PR, CI runs
forge inspect <Contract> storage-layoutand compares against the fixture. - 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.soland 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:
- Timelock delay: Gives the community time to review and react before an upgrade takes effect. Users can exit the protocol during this window.
- Testnet verification: Always deploy and test on Sepolia before mainnet.
- Forward-fix: If an upgrade introduces a bug, deploy a corrected implementation and upgrade again.
- Emergency pause: If the upgraded contract is actively harmful, the guardian can pause to stop further damage while a fix is prepared.