Inside Balancer Code — TimelockAuthorizer
Full credit for this detailed article goes to Beethoven X member 0xSkly. Many thanks for comprehensively showcasing the extent of Balancer’s codebase and technology.
Fine grained authorization mechanisms are key when protecting a complex protocol like Balancer. Another key aspect is execution transparency combined with a delay so users can react to changes to the Protocol before they go live.
Currently, Balancer achieves this through a classic
Timelock contract that handles execution delay combined with the
Authorizer contract which handles authorization. They’ve now combined this into one contract with the fitting name
TimelockAuthorizer. A main problem I have with
Timelock contracts and other proxy contracts is that it obfuscates intent, making it harder for an average user to decode what is executed because of the hashed proxy call. Have you ever tried to follow a timelock transaction executed by a gnosis multisig proxy contract? Good luck.
After this rather long intro, let’s do a deep dive into how this new contract works and if it makes execution intent easier to track.
How is authentication and authorization applied?
Just as a quick recap, authentication is figuring out who you are, and authorization is figuring out if you are allowed to perform this action. At the basis of this lies the
Authentication contract. It provides the
We see the modifier calls the
_authenticateCaller function which resolves an
actionId and delegates a call to the virtual function
_canPerform with the
sender and the
actionId which reverts when returned
false. Just from looking at this snippet, we can assume that the
actionId, which is based on the
msg.sig, resembles the function to execute whereas
_canPerform is expected to check if the caller is allowed to do this action.
So let’s have a closer look at this
actionId which seems to be a key concept. Why is it not just
msg.sig which is the function signature you may ask.
We see it creates a hash together with
_actionIdDisambiguator . The comments do a good job describing this property.
The nice thing about this is that it allows sharing permissions so that for example, all contracts deployed from the same factory share the same permissions. Otherwise, one would need to add permissions for each pool deployed from a factory which would be rather cumbersome.
So now that we’ve got this out of the way, let’s move on to the
_canPerform function, which apparently is responsible for authorization. For this, we look at the
BasePoolAuthorization contract, which serves as a base for all pools.
So if it’s an owner only action, only the owner can do this action (what surprise), otherwise we again delegate the call to our final destination, the
Authorizer , finally coming back to the actual thing I wanted to talk about. What a detour, but it was kinda necessary to understand the full context.
Inside the TimelockAuthorizer
From what we have seen, we expect this contract to handle function execution permissions based on the
targetContractAddress and also support some form of timelocked execution. We stopped at
_getAuthorizer().canPerform(..) so let’s continue there
We see that
msg.sender is referenced as
account and the
targetContractAddress is the
where parameter. We check if this
actionId has a delay configured (which means this action is timelocked) by checking
_delaysPerActionId[actionId] > 0 . If there is no delay configured, we just check if permissions are given.
_isPermissionGranted is a mapping between the hash over
actionId, account, where to a boolean.
If it has a delay, then only the
_executor is allowed to call this function (
account == address(_executor))which means the contract behind this
_executor address is somehow executing a timelocked action. So let’s see whats up with this contract which is assigned in the constructor
_executor = new TimelockExecutor();
The contract only has 1 function
execute which basically proxies a function call
The function can only be called by
address(authorizer) which is assigned in the constructor
authorizer = TimelockAuthorizer(msg.sender);
msg.sender is the
TimelockAuthorizer wich deploys the contract in its own constructor call. So only the
TimelockAuthorizer contract can call the
execute function. Therefore we can already assume the execution flow for a timelocked action to be something like
TimelockAuthorizer.execute(...) => TimelockExecutor.execute(...) => targetContract.performAction()
Now that we know what the contract behind the
_executor is, let’s go back and figure out how this all comes together. For this, we jump into the
schedule function which is the start of a function call protected with a delay (timelocked).
Seems pretty straight forward, we decode the action the caller wants to perform, verify he is permitted to do so and schedule it. Note that there is an additional
executors argument passed along which is an array of addresses. We’ll see in a bit what that is about. Following the execution chain, it pulls the configured delay for this action and we end up in
We define the
scheduledExecutionId which increments on the
_scheduledExecutions arrays length. We calculate the execution time and push the whole configuration into the array. If you pass no
executors then it sets this
protected flag, which I don’t know yet what it does, but im sure we’ll find out. Finally, we see what the
executors address array is for. All those addresses are granted the permission to execute this specific scheduled action without the need to give them permanent permissions to execute all actions with this
actionId. This adds a nice additional layer of control.
Now we made it to the final stretch, the execution of the scheduled action
It is triggered via its
scheduledExecutionId , making sure enough time has passed via
block.timestamp > scheduledExecution.executableAt and executes it via
_executor.execute(...). We also see now what happens when the
protected flag is set. It checks the permission on the
msg.sender , so if you pass an empty array for
executors when scheduling an action, only addresses which have explicit permission for this
actionId can execute it.
There we are; we have seen the whole execution flow of actions that can be executed immediately and delayed (timelocked) actions. What we have not seen yet is how we grant & revoke permissions and configure delays for specific actions. But that’s for another time.
Autorizer contract enables for an even more fine grained control over Balancer permissions and removes one level of indirection. Unfortunately, it still requires some sort of script to decode the scheduled actions where it would be nice for people if they could just inspect them from Etherscan. But to make this possible would come with its own drawbacks.