Reentrancy is the oldest and most well-known attack vector in smart contract land. The first ever reentrancy attack involved reentering a single function in a single contract. Over the years, these attacks have evolved, becoming more sophisticated and exploiting multiple functions across contracts in larger systems. Some code may be entirely safe in isolation. However, within the context of a larger system, reentrancy remains possible even with reentrancy locks on individual contracts. Recent Compound fork exploits serve as prime examples of multi-contract reentrancy. While developing the second version of Volt Protocol, the challenge of creating a complex multi-contract system that is safe from all categories of reentrancy attacks was addressed.
The devised solution is a global reentrancy lock. This pattern consists of two components: a contract known to the entire system that locks the system while state is changing and a reusable modifier placed on all state-changing functions, making lock and unlock calls before and after function execution. At first glance, this might seem like a simple state machine with a contract that has two states, either locked or unlocked. However, it was necessary to ensure certain state-changing methods could only be accessed when the system was already locked, while others could only be accessed when the system was unlocked. This led to the development of a global reentrancy lock with multiple lock layers.
Original Global Reentrancy Lock Design
The codebase provides a reference demonstrating how this type of reentrancy lock can be implemented. In practice, this lock ensures that once the outer layer is passed, it cannot be reentered. Additionally, while in the inner layer, no other methods in the inner layer can be executed until the initial inner method call is completed. This effectively locks resources and prevents any possible reentrancy, while allowing flexibility in the system to access functions concurrently, and safely.
The original global reentrancy lock developed had 2 lock layers, however, it can be made more generic by enabling developers to set the maximum lock level allowable. This allows for easy configuration if a system requires more than two layers in its reentrancy lock. Whatever level of locks required in a system, developers can set their preference.
N-tier Global Reentrancy Lock
A concrete example of how this global reentrancy lock can be utilized is a protocol that has multiple internal AMMs for trading with users and has deposits in Aave, Compound, and Morpho. Users should be able to trade with any AMM, but while trading, they should not be able to move protocol assets by calling a different module. Simultaneously, users should be prevented from trading with other AMMs while another trade is in progress. One AMM should have the ability to deposit funds in an underlying protocol during a user trade, but during the deposit phase into the underlying protocol, no other operation in the system should be allowed. This design methodology creates a unidirectional control flow, with external actors only able to access the inner layer of the protocol through the outer layer. Once entered, the system cannot be re-entered except through predefined and approved paths. Protected resources can only be accessed once the pre-requisite resources (if any) are accessed and all resources at the same level are freed.
Consider a scenario in which User A trades with the internal AMM, moving the system lock level from 0 to 1. With lock level 1 in place, no other AMM methods can be called until the lock level is reset to 0. The internal AMM then deposits funds into AAVE, raising the system lock level from 1 to 2. At this point, neither venue deposits nor internal AMM methods can be executed. Once the function call to the deposit contract completes, the global reentrancy lock returns to level 1, allowing the internal AMM function call to finish and the global reentrancy lock to return to level 0. The system is now ready for another user interaction.
The provided code has been tested extensively with Echidna symbolic fuzzing, Foundry invariant and unit tests, and hevm symbolic execution. Readers are encouraged to examine the codebase for examples of how this global reentrancy lock can be implemented in practice. Hopefully this lock helps make DeFi safer. This code has not been audited, so please get it audited before using it in production.
Special thanks to Erwan from Volt for helping in the design of the global reentrancy lock and Owen Thurm from Guardian audits for reviewing this contract before release.