[BIP-804] Protocol Fee Controller Migration

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:

  1. Grant the migration contract admin permission
  2. Grant someone (e.g., an account controlled by Maxis) permission to call finalizeMigration on the migration contract
  3. 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
2 Likes

This is quite an extensive upgrade to our new fee processing infrastructure. It is, however needed, to fully utilize it and process protocol fees efficiently. Excited that the Maxis will be part of this new process!

One more dev note about the migration in general and the admin permission required by the script.

The ProtocolFeeControllerMigration can only do the following things with the admin permission:

  • Set global protocol swap fees in the new ProtocolFeeController to the current value in the existing ProtocolFeeController
  • Set global protocol yield fees in the new ProtocolFeeController to the current value in the existing ProtocolFeeController
  • Update the protocol fee controller in the vault with the contract address set at construction time (i.e. the new ProtocolFeeController).

The migration script requires all those three permissions, so for simplicity it just gets the admin role and grants permissions to itself when needed, revoking them right after using them. After the migration is complete, the script contract will hold no privilege over the system.

Moreover, the script can’t do anything at all after the migration is finished. As stated above, the workflow is the following:

  • Migrate global fee percentages from old fee controller to new fee controller
  • Migrate existing pool data to new fee controller (can only port existing data, no new data can be added with this mechanism to the new fee controller). This can be called any amount of times with existing pools as long as the migration is not finalized
  • Finalize migration, effectively changing the fee controller in the vault

After the migration is finalized, all calls to the migrator contract will revert, and the migrator contract will have no permissions granted (admin permission is revoked when the migration is finalized).

Code reference: DethCode (same code across every network, referencing mainnet for simplicity only).

Overall the process is very simple from a technical perspective, and it can easily be verified that each permission is revoked after being used, and the admin role is also revoked at the end of the execution. Therefore the migrator script should be safe to use.

2 Likes

https://snapshot.box/#/s:balancer.eth/proposal/0xa174606ee924b6ccfcf6be9862f02ac8105541bd86df98ee0f44525c429f4b69