Payloads
Overview
The ProtocolFeeController
deployed with the Vault was found to have a couple shortcomings. First, it lacked events exposing the initial protocol fees (e.g., there was no record that a pool was protocol fee exempt), making it effectively impossible to track protocol fees off-chain.
This prompted a migration (the fee controller is upgradeable in the Vault): but when we started writing the migration, it quickly became apparent that there was a lot of pool-specific state relating to fees that was only set on pool registration (or by pool creators). There was no way to copy this state to a new controller, and therefore no way to migrate it.
To solve this problem, we created V2 of the ProtocolFeeController
with not only the events that originally motivated the upgrade, but also refactored the contract storage, added new getters, and most importantly, added a new migratePools
function to copy the previously inaccessible state during migrations.
Migration Mechanics
The general pattern for migrations is to first create and deploy the new version of the contract weāre upgrading, then deploy a migration helper contract to perform the actual migration. This migration helper is deployed with the addresses of the old and new contracts, and must be granted āadminā privileges by governance (i.e., so that the contract can grant itself any specific permissions it requires).
There is usually a permissionless performMigration
function that anyone can call, which migrates the contract and then renounces admin permissions. The migration contract is written so that it can only be called once. In V2 we had some heavyweight / multi-stage migrations, which required multiple calls to performNextStage
. When the last stage executed, the contract would renounce all permissions and become inert.
Protocol Fee Controller Migration
This migration didnāt quite fit any of the existing patterns. There is still a migration contract that must be granted admin privilege, but the main difference is that this migration includes dynamic pool state. Past migrations (e.g., upgrading the Gauge Adder) were deterministic, and did not involve any state that could change in the two weeks or so required to propose and pass a governance proposal. Also, the amount of data involved was small; usually just updating a couple addresses.
In this case, new pools will definitely be created during the governance latency period, and fee percentages might also change. Additionally, the number of state changes involved is fairly large, with dozens of pools to migrate (~60 so far on Mainnet). Itās possible future migrations could run out of gas or even hit the block limit.
To accommodate this, the migration is not deployed with any pool data. The permissionless migratePools
function can be called multiple times with the set of pools gathered at migration time, days or weeks after the contract deployments. This goes a long way toward ensuring that no pools will be missed. (And if any are missed, itās not catastrophic; governance or the pool creator would just have to set the fees again on the new controller.)
This non-deterministic flow has one important consequence, though. There must be a way to terminate the migration and renounce the admin permission, and this cannot be permissionless. Otherwise, it could be prematurely terminated by anyone, and we would need to redeploy and go through the whole governance process again. The finalizeMigration
function after all pools are migrated.
Post migration considerations
After the migration, there could be lingering issues with both controllers.
Any calls to collectAggregateFees
on the old controller will revert in the Vault after migration, as this can only be called by the current controller. However, it is possible fees collected before the migration were never withdrawn, leaving non-zero token balances in the old controller.
This is fine, as withdrawal does not go through the Vault. Pool creators and governance can always withdraw from a fee controller, whether or not it is the Vaultās current controller. Best practice might be to leave withdrawal permission on the old controller, at least until all protocol fee balances are zero. (Pool creators can withdraw forever.)
It is also possible that a pool was left off the list sent to migratePools
, or was changed after migration and before finalization. Pools can only be migrated once, so there is no way to āupdateā a previously migrated pool through the migration contract. In this case, it is possible for the new controllerās fee percentages to be out-of-sync with the Vault. If a pool with a creator was forgotten or added during migration, the new controller will show zero pool creator fee percentages. Note that the pool creator address itself cannot be ālost,ā as that is stored in the Vault.
In either case, these missing or out-of-sync pools can simply be updated in the new controller by governance (for protocol fees) or the pool creator (for pool creator fees). These calls will succeed, since post-migration the Vaultās current controller will be set to the new one.
Summary and Required Permissions
The new ProtocolFeeController
is already deployed. Once the ProtocolFeeControllerMigration
contract is deployed and we are ready, here are the steps:
- Grant the migration contract admin permission
- Grant someone (e.g., an account controlled by Maxis) permission to call
finalizeMigration
on the migration contract - On each chain, get the current list of pools, and call the permissionless
migratePools
function with the set of addresses. The fork test does it factory by factory; e.g., weighted pools, then stable pools, then stable surge pools, but it could probably be done all at once. The migration contract will revert if you try to migrate the same pool twice, and there is no data passed with it except the address, so it is safe to be permissionless.
This migration cannot migrate pool creator fees (though subsequent migrations can). Also check the list to be sure none of them have pool creators. If they do, the pool creator fees will be zero until the pool creator sets them again on the new controller. We donāt expect there to be any pool creators, but if there are, we could make a forum post asking pool creators to reset their fees on the new controller. (They would probably come to us anyway, asking why their revenue went to zero.)
Current data indicate there is only one pool with a pool creator, and it is most likely just a test pool that we can ignore.
4. When all pools have been migrated for that chain, call finalizeMigration
. I suppose, for cleanliness, you could also revoke the permission to call finalize, but it doesnāt really matter; the contract is inert, and everything will revert after finalization.
Technical Specifications
Balancer Maxi multi-sig address: 0x9ff471F9f98F42E5151C7855fD1b5aa906b1AF7e
DAO multi-sig address: 0x10A19e7eE7d7F8a52822f6817de8ea18204F2e4f
Admin role: 0x0000000000000000000000000000000000000000000000000000000000000000
ProtocolFeeControllerMigration
contract addresses (to receive Admin permission; i.e., the DAO multi-sig grants the Admin role to the migration contracts). Deployed here:
- Arbitrum:
0xdDea349828096DcdC0Cc5B7Db5F924f146AadD90
- Base:
0x1b6F057520B4e826271D47b8bdab98E35Af17E59
- Gnosis:
0x6B1Da720Be2D11d95177ccFc40A917c2688f396c
- Mainnet:
0x75635f85600Fc357906417f6b78AAf8755d2888D
- Sepolia:
0x79232d3431463dC2B8Fd8fb28B352B9Ba92Eb280
ActionIDs for finalizeMigration
(Grant these roles to the Maxi multi-sig, on the corresponding migration contracts):
- Arbitrum:
0xf2e99293214bb798dd007d998933c26df87063dcace3335292200398836b4327
- Base:
0xb2ea78ad7492bf6616d379d1c842b07b58cc73d263d08a1f4b1b9d30bc26efac
- Gnosis:
0x522351475deb850093dc3f48122e83134d8eca074fc3385c06fd4f16904e046d
- Mainnet:
0xebf97e324090ac50c328482b5ab66d37fb0e4bb5afe45fb890915675775ffc62
- Sepolia:
0x8c292d8e1af8e556f1aa3edc7a1214d5e14b9fb1afa68649633af554a5a6b881