Add StaticDelegateCall contract
This primitive allows for the creation of view-only getters to contracts that call dynamic external libraries with delegatecall. It is inspired by Gnosis' StorageSimulation ( https://github.com/gnosis/util-contracts/blob/main/contracts/storage/StorageSimulation.sol ), but this implementation disallows external callers and is meant to be used internally for building view functions.
PR Checklist
- [x] Tests
- [x] Documentation
- [x] Changeset entry (run
npx changeset add)
🦋 Changeset detected
Latest commit: 6bfc778cb11a2c42e1b9c0b465921ff9439656c9
The changes in this PR will be included in the next version bump.
This PR includes changesets to release 1 package
| Name | Type |
|---|---|
| openzeppelin-solidity | Minor |
Not sure what this means? Click here to learn what changesets are.
Click here if you're a maintainer who wants to add another changeset to this PR
I think this makes sense as a primitive, but we always ask for concrete use cases. Would you mind sharing a few?
In our case, we were instantiating a contract with a possibly user specified "dynamic library" at runtime (i.e. not inlined at compile time, nor externally linked at deploy time), that would act on contract storage but do implementation-specific things, while still conforming to an interface like IGadget.
Can you share a little more context about this?
I think this makes sense as a primitive, but we always ask for concrete use cases. Would you mind sharing a few?
In our case, we were instantiating a contract with a possibly user specified "dynamic library" at runtime (i.e. not inlined at compile time, nor externally linked at deploy time), that would act on contract storage but do implementation-specific things, while still conforming to an interface like IGadget.
Can you share a little more context about this?
The two use cases that come to mind are 1) dynamic libraries and 2) external storage access, which differ primarily in whether the StaticDelegateCall is used internally, or exposed and called externally.
The dynamic library use case is similar to having an externally linked library with a well-defined API, except that the library can be specified at runtime, and even changed at runtime.
In our case, we had pools with interest rates that are computed from the current liquidity state in storage. The liquidity state is complex and relatively large. As there are many different ways to build good (and bad) interest rate models, we wanted the flexibility to develop and experiment with new interest rate models, and allow for user-defined ones, so that pools could compete on the merits of their interest rate models, among other things. We also wanted to do this without redeploying the pool implementation for every new interest rate model. In some cases, the interest rate models may have some of their own state too.
Basically, the contracts looked something like this:
interface IInterestRateModel {
...
function rate() external view returns (uint256);
}
contract Pool {
Liquidity storage _liquidity; /* complex */
IInterestRateModel _interestRateModel;
...
function rate() external view returns (uint256) {
/* gets rate from interest rate model, based on current liquidity */
}
function doStuffWithLiquidity() external {
/* uses rate to price liquidity */
}
}
There are a couple of different approaches to the interest rate model computing a rate based on the Pool's liquidity state:
- Deploy the interest rate model as an independent instance and pass liquidity state to it in calldata with
call/staticcall- This requires copying the liquidity state from storage to memory to make it available as calldata, which is fine if the liquidity state is simple and small, but in our case was too expensive. It also requires a separate interest rate model instance for each pool.
- Inline the interest rate model as a library at compile time, or link it as an external library at deploy time
- This is not ideal, as we would like to have cheap clones of a single pool logic contract and do not want to burden a user with redeploying the entire pool contract when instantiating a permissionless pool.
- Deploy the interest rate model as a "dynamic library" and
delegatecallit from the pool
Option 3 allows for the interest rate models to be deployed once as logic contracts that can then be used by different pools. However, since there is no Solidity language support for "dynamic libraries" (see ethereum/solidity#8490, ethereum/solidity#2469, ethereum/solidity#8353), this kind of interaction must be done manually with delegatecall, and it's currently not possible to create simple view getters (like rate() in Pool above) without emulating them with something like StaticDelegateCall.
Another application of this use case might be a generic Governor that collects votes for a proposal, but uses a dynamic library to decide the weighting of the votes, similar to how Snapshot has voting strategies. The Governor contract can be a standardized deployment, while projects can specify their own voting strategy at deploy time or runtime — without compile-time inheritance or overrides — with the potential of upgrading the voting strategy with time or even using different voting strategies for different proposals.
The other use case is external storage access. In this use case, a contract would expose the StaticDelegateCall to external contracts and allow for arbitrary access to its storage. This was done in the Gnosis v2 contracts with their StorageAccessible mixin (also the inspiration for this implementation) and attachable readers for interpreting contract state. For contract developers, this might be done to future-proof the contract API (at least for view functions), in case the API was missing a getter to some internal state, or to outsource the logic for more complicated view functions. More interestingly, it could be used to expose internal state onchain to other contracts for downstream logic. One example of this would be an Ante Test that asserts some internal invariant in a contract that may not have been exposed at all by the contract's public API.
I still think the ideal version of this would be at the language level and/or with EVM support, rather than the delegateCallAndRevert mechanism, but I thought this might be useful to some projects in the meanwhile.
Just wanted to point out that this is not actually a "static delegate call", but rather a delegate call with all storage changes reverted AFTER the result is computed.
There is a important nuance because in the case of a staticcall, the operation will revert if any of the forbidden opcode is used in the sub-context. With this construction, such operation are possible, and can possibly modify the return of some view function called in the subcontext. The construction guarantees that the effect of forbiden opcode being called is negated by the revert, but its not exactly a static delegate call.
Could that (subtle) difference be exploited? I guess yes, but I don't know how precisely.
I still think the ideal version of this would be at the language level and/or with EVM support, rather than the
delegateCallAndRevertmechanism, but I thought this might be useful to some projects in the meanwhile.
This should probably be proposed as a new EIP. I know it takes ages to get one of these in a network upgrade, so the sooner its proposed the better.
While I agree that some project might use such a mechanism, I also believe that it is way more subtle and complex than something like the Multicall contract. IMO, anyone that needs that is knowledgeable enough to write it and use it. On the other hand what message do we give by providing it in the library? That this is a recommended/good practice? Its not just providing the code (which in fairness is not that complex). Its also putting out documentation, and supporting users that may want to use that. I'm personally not feeling comfortable with that last part.