Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add DSM and modularize CircuitBreaker #3

Merged

Conversation

Keinberger
Copy link

@Keinberger Keinberger commented Aug 17, 2023

Add DSM implementation and modularize CircuitBreaker

@vdaubry and me have been working the past few weeks to implement the new CircuitBreaker specifications and optimize modularity. We believe the EIP should provide a modular standard for anyone to extend upon and build individualized implementations.

The main characteristic of hacks is “liquidity being drained in a short time frame”. That’s why it makes sense to track that metric on-chain and introduce a CircuitBreaker standard to provide a way to handle unusual TVL activity. Nevertheless, there are multiple other metrics that are also highly indicative of hacks, most and foremost unusual price changes within AMM’s or other protocol specific values that should not be able to change by unrealistic amounts under normal circumstances.

That lead us to our conclusion: The CircuitBreaker standard should provide a way to track multiple metrics on-chain. It might be token outflows, but it should also enable protocols to track other metrics, that are crucial for protocol health. Protocol’s should not be bound to only tracking TVL.

Furthermore, that will allow others to build extensions on top of the CircuitBreaker for their specific needs.

Crucially, this philosophy is very common when it comes to EIP’s. Two of the most prominent, EIP20 and EIP721, both follow this philosophy: A minimalistic base implementation, that maximizes compatibility and modularization to allow others to build individualistic implementations and extensions.

The new approach in a nutshell🥜

The new CircuitBreaker base implementation allows the protocol to configure security parameters (a more generic naming for tokenLimiter) for their protocol. Each security parameter has a unique bytes32 identifier (previously a token address). This still allows protocol’s to configure it to track token addresses, but it also allows protocols to track other metrics. Each security parameter also has to be configured with a SettlementModule contract. This contract gets called to “settle” transactions whenever the CircuitBreaker gets triggered. The CircuitBreaker will automatically call the prevent function of the SettlementModule. It is up to the SettlementModule implementation to specify what will happen. The prevent function can be coded to always revert, schedule a future transfer (delay the settlement) or do something else. This allows protocols to implement highly individualistic settlement strategies for different metrics. Delaying the transfer or reverting are not the only options anymore. Protocol’s could also choose to let governance settle the transactions or implement other settlement strategies.

And the cool thing about this: Our proposed approach is perfectly capable of doing the exact same thing as the original CircuitBreaker did beforehand: It can track token in- & outflows and delay or revert potential hack transactions. Even the same, existing Foundry tests pass with our proposed approach.

To summarize: We are just modularizing the CircuitBreaker to allow for more customization. It still achieves the existing goals of delaying or reverting transactions.

What has been changed

Our code changes followed the philosophy of introducing minimal code changes to the existing code, while still implementing our new approach.

EIP7265 Interface

  • Renamed AssetRegistered event to SecurityParameterAdded
  • Renamed AssetInflow event to ParameterIncrease
  • Renamed AssetWithdraw event to ParameterDecrease
  • Renamed AssetRateLimitBreached event to RateLimited
  • Renamed registerAsset function to addSecurityParameter
  • Renamed updateAssetParams function to updateSecurityParameter
  • Renamed onTokenInflow function to increaseParameter
  • Renamed onTokenOutflow function to decreaseParameter
  • onNativeAssetOutflow function is being handled by decreaseParameter
  • Deleted overrideRateLimit
    • became redundant because of SettlementModule implementation
  • Deleted overrideExpiredRateLimit
    • became redundant because of SettlementModule implementation

CircuitBreaker.sol

This is the new base CircuitBreaker implementation. The new contract only has 236 lines instead of 349 and allows for more modularization.

Changes

  • Implemented more generic naming
    • tokenLimiters mapping uses bytes32 as the key instead of an address
    • Renamed “tokenLimiters” to “limiters”
  • Implemented OpenZeppelin Ownable for more security when it comes to admin handling
  • Deleted gracePeriod
    • No longer needed
  • Deleted global isRateLimited variable
    • Rate Limiting is now being handled on a per security parameter basis. If protocol’s choose to implemented global rate limiting, they can do so by checking the status of a given security parameter limiter.
  • New _decreaseParameter and _increaseParameter functions call the prevent function on the SettlementModule in case of the CircuitBreaker being triggered. The prevent function implementation then specifies how to handle the trigger situation, as opposed to locking the funds by default.
  • Deleted _safeTransferIncludingNative
    • Is no longer needed

New: ISettlementModule.sol

The interface defines generic method for a Settlement module. We provided 2 sample settlement modules:

  • DelayedSettlementModule
  • RejectSettlementModule

Protocol can choose which SettlementModule to attach to each security parameter they’re tracking.

But protocol's can also provide their own SettlementModule if they need to.

  • Renamed “schedule” into “prevent”: each module will have a different way of preventing a transaction from going through. Some module are not scheduling, such as “RejectSettlementModule”.

New: IDelayedSettlementModule.sol + DelayedSettlementModule.sol

This implementation is a thin wrapper around OpenZeppelin TimelockController. The DelayedSettlementModule prevent transaction from going through by locking funds in a TimeLock. Funds can be unlocked by the “execute” function.

New: ITokenCircuitBreaker.sol

This interface implements the existing event and function naming of the EIP for the TokenCircuitBreaker extension.

New: TokenCircuitBreaker.sol

This contract is a wrapper for the CircuitBreaker contract. This wrapper allows for implementing the existing core functionality: Tracking asset inflows and outflows. It showcases how our proposed new CircuitBreaker contract can be extended with only minimal changes to allow for the same functionality.

@diyahir
Copy link
Member

diyahir commented Aug 17, 2023

Looks like the tests are no longer passing @Keinberger @Philogy

Error (2973): Wrong argument count for modifier invocation: 1 arguments given but expected 0.
  --> src/core/CircuitBreaker.sol:67:7:
   |
67 |     ) Ownable(_initialOwner) {
   |       ^^^^^^^^^^^^^^^^^^^^^^

To test run, you may need to delete the lib or run forge clean to do this workflow depending on your dev env

forge install openzeppelin/openzeppelin-contracts --no-git
forge install foundry-rs/forge-std --no-git
forge test

@Keinberger
Copy link
Author

Keinberger commented Aug 18, 2023

Hey @diyahir !
This issue is due to the newly released Openzeppelin contracts version 4.9.3. They unfortunately changed the Ownable contract constructor arguments. You can run forge update to update the dependencies, it should fix the issue.

Also make sure to run foundryup to update the solidity compiler to support version 0.8.20 and above!

If the issue still persists:

  1. Newly clone the repository
git clone https://github.com/ikigai-labs-xyz/defi-circuit-breaker-eips.git
  1. Install the Openzeppelin dependency:
forge install Openzeppelin/openzeppelin-contracts
  1. Install the forge-std dependency:
forge install foundry-rs/forge-std --no-git
  1. Update the dependencies:
forge update

This should solve the issue :)

@diyahir
Copy link
Member

diyahir commented Aug 18, 2023

Works now! Thanks! It's super cool to have a layer of abstraction that fits the goals of the token limiter, I am really liking the way it's laid out logically.

A few comments:

  1. Naming
    ITokenCircuitBreaker -> IAssetCircuitBreaker
    Which is more consistent with its implementation since it can handle Native Assets + ERC20

  2. Can you elaborate on why overriding and grace period is no longer necessary?

  • A grace period did the following:
    Tracked the TVL while letting every token still pass through, the reason being that when markets are super violent, one may want to disable the CB's reverting/delaying functionality (USDC Depeg, USDT Depeg, stETH Depeg, bridge attacks), cases where the underlying token is only going to be partially redeemed so there's a race to not be last. But can be turned back on and continue where it left off. There needs to be some way to turn it off temporarily, many projects will request this (even if just for peace of mind), so we should make this a requirement in the standard

  • The same goes for the overriding functionality, if there's a rate limit and it's a false positive, you need to be able to override it, so as to minimize the impact of the other users, otherwise, they will be forced to wait for the entire cooldown period. If I trigger the rate limit with a 30% drawdown, and it was just normal usage, we need a way to make sure we have a way to restore functionality as quickly as possible, something I don't see in this implementation but maybe I'm wrong?

  1. Why is the settlement module defined on a per-parameter basis?
    To me, I don't see a realistic scenario where protocols are defining multiple settlement modules, adding additional overhead, finding suitable admins, etc, it's not too important in either direction, but just want to point this out.

  2. Reversion on Delay developer option
    One thing for composability is a reversion option on delay, for example, say I interface with your protocol which has a CB, If I am now delayed, then it could throw off my accounting, so I need to know if I was delayed or reverted, or what's going on with the funds. We had this option previously, but it looks to have been lost in the new implementation.

  3. Limiter now on a per-asset basis
    We had intentionally had isRateLimited variable globally defined for the reference implementation because it would be pretty silly to let someone take 30% of your USDC, trigger the USDC limit, and still be able to steal 30% of the USDT

A lof these things we had already came to a consensus on, so unless there's a compelling reason to omit/change them, I don't think we should deviate too much, particularly on grace periods, overriding limits, and global limits

@Keinberger
Copy link
Author

Hey again @diyahir !

Thanks very much for the thorough feedback, really appreciate it. We are very happy that you like our modularized approach.

In regards to the points:

  1. Yes, completely agree that the IAssetCircuitBreaker naming scheme would be more appropriate. Will change that!

  2. a) Grace period:

  • We implemented the minimum of code required to achieve the Circuit Breaker, we think the standard should be as minimalistic as possible to provide a good foundation to build on top. We would gladly include the Grace Period, could you please help us clarify the specs regarding the Grace Period:

    • The Limiter currently holds an history of liquidity over time
    • The Circuit Breaker triggers when withdrawals exceeds a threshold over a pre-defined time window
    • This means that withdrawals would automatically be possible once the predefined time window has passed
  • ⇒ In this context, what is the purpose of the GracePeriod ? Is it a way to override the pre-defined time window ? Would love to understand better!

  1. b) Override functionality:
  • We totally understand the benefit of having an override functionality. We are happy to bring that functionality back. We will include it inside of the rate limiter library.
  1. Great point! Funny enough, we actually came across the same question when we were engineering the new approach. Nevertheless, we did this purposefully, here’s why: Since the CircuitBreaker is now able to track multiple different metrics, these different metrics also have different severities for protocols, and thereby require different settlement strategies.
  • An example: Imagine you are tracking TVL outflow and the price rates returned from Oracle’s. If your TVL parameter is being breached, you may choose to freeze the assets or delay the settlement, since there have been funds involved. If the data returned from your Oracle’s change by unrealistic amounts, it does actually not make sense to implement a freezing or delaying settlement strategy, since there are no funds involved. Instead, you may want to choose to pause the protocol, as unrealistic price data may throw off your internal mechanics.
  1. Also good point, reversion option is totally possible with the current implementation. When defining a security parameter you want to track(ex: TVL), you can provide a RevertSettlementModule that will revert the transaction when the Circuit Breaker triggers.

    Regarding interfacing with a protocol that has a Circuit Breaker, the flow would be the following:

  • You call the protocol protected by the Circuit Breaker
  • The protocol calls increase / decrease parameter
  • If the Circuit Breaker triggers, it returns ‘true’
  • The protocol can notify that you have been delayed / reverted / or any other settlement strategy
  1. Regarding the global isRateLimited variable, we think that might be problematic having a global rate. Let’s consider the following example:
  • Lets consider a perpetual exchange (GMX) which uses chainlink keepers to execute limit orders, or perform liquidations. This protocol can use the Circuit Breaker to monitor exchange rates to protect from price manipulation hack, or just to pause some feature in case of a unusual violent market movement.
  • If a price rate changes more than % in the predefined time windows the firewall will trigger. It might not make sense to keep a global variable in that scenario and pause withdrawals (no liquidity outflow is involved in this scenario)
  • We were thinking that the problem you describe can be solved at the protocol level: in case of a trigger the protocol can pause all pools if it wants to (and potentially communicate on its frontend that withdrawals are temporarily paused)

All in all, we very much welcome your feedback, we are happy that you like our overall approach. We also do not want to deviate from decisions, where consensus has already been found previously. These should definitely stay the same!

@diyahir
Copy link
Member

diyahir commented Aug 22, 2023

  1. Grace periods, if not already in the standard then we should add it. It basically says for a period of time, let's ignore all restrictions of the circuit breaker and let the actions pass. But in a way that does not break the accounting for when the period ends. For example, if we set a parameter gracePeriodUntil = now + 1 day, then everything until that time should be allowed through while still tracking the liquidity so as to pick back up and enforce the limits.

Code-wise this is just an additional check here as well as in the DSM location (actually it would be better to refactor this):

 if (!firewallTriggered || _inGracePeriod())
      _safeTransferIncludingNative(_token, _recipient, _amount);
else
     DSM()
  1. This is not a good solution because it requires a selection by the original protocol dev, not the composable consumer. Instead, it's better to expose if the firewall was triggered and propagate that up the stack, similar to ERC20 transfers and let the developer on the other end decide to revert. (SafeERC20 vs ERC20).
   function onNativeAssetOutflow(
        address _recipient
    ) external payable override onlyProtected onlyOperational returns bool {
        bool firewallTriggered = _decreaseParameter(
            getTokenIdentifier(NATIVE_ADDRESS_PROXY),
            msg.value,
            _recipient,
            msg.value,
            new bytes(0)
        );

       ...

        return firewallTriggered

    }
  1. It is important not to overengineer this since, for 99% of the cases, the CB is in the background as a backstop but never used. So if there is an issue, stop everything, asses that it wasn´t a hack -> carry on as before as quickly as possible. The idea of letting part of the system continue while there's a pending vulnerability is not a good idea, even if it is technically feasible this is not the desired behavior most protocols would expect.

I would expect most protocols to strictly use the token inflow and outflow metrics since at the very end of the day, the only thing you care about is the assets are not stolen quickly through a hack. Even in your GMX example, the price oracle manipulation's goal is to steal assets, so the standard Token CB would suffice. The oracle change can and will be manipulated as much as possible to steal the assets, so creating heuristics around that would not work super well (for example, stop everything if oracle moves quickly by 50% in 5 minutes or whatever, the hacker will just do up to 49% and use that margin to steal everything). So the ultimate backstop is inflow and outflows because the attack surface is infinite. If there is a way to create any margin, then that is a window to steal everything basically.

Grace periods, overrides, and limits should be global parameters in the reference implementation because this is the expected behavior the majority of protocols will want. For the reference implementation, we are trying to keep it as robust, simple, intuitive, and minimalist as possible while meeting the expectations and needs of the majority of protocols. Super interesting to have good separations on a technical level, but this is really a power-user level of controls that most protocols won't need/want.

@Keinberger
Copy link
Author

Hey again @diyahir !

Thanks for your feedback, we added back the grace period, cooldown period and global isRateLimited variable.

Regarding your last point, we agree that the primary use case is tracking token inflow and outflow metrics, and the implementation should simplify this process as much as possible.
We think we can come up with an implementation that would both:
• Provide good defaults, enabling protocols to track token inflow and outflow out of the box
• Keep the standard flexible, so that more sophisticated teams can implement more advanced use cases, such as tracking multiple metrics and having different settlement behavior

Using CircuitBreaker in Defi is still very much an experiment. We have assumption about the way protocols are going to use this piece of infrastructure, but at this stage we think our standard should be as future-proof as possible concerning potential use cases.
That being said, we fully agree with you that implementing the token inflow / outflow use case shouldn't necessitate a deep understanding of CircuitBreaker's inner workings.

This PR is already big enough, so let's not address everything simultaneously :)
We agree on the vast majority of the current code, we can reserve these topics for future PRs.

@diyahir
Copy link
Member

diyahir commented Aug 28, 2023

I agree with not fixing everything here, there is one issue I see with the way the grace period was implemented:

function overrideRateLimit() external onlyOwner {
    if (!isRateLimited) revert CircuitBreaker__NotRateLimited();
    isRateLimited = false;
    // Allow the grace period to extend for the full withdrawal period to not trigger rate limit again
    // if the rate limit is removed just before the withdrawal period ends
    gracePeriodEndTimestamp = lastRateLimitTimestamp + WITHDRAWAL_PERIOD;
}

This is susceptible to a 'false flag -> hack' attack. The hacker triggers the rate limit, starts the grace period, and steals everything while in the grace period.

So overriding the limit should not trigger a grace period, instead overriding should basically sync the tvl to the current time such that now the BPS drawdown has been reset. The difference is that in this case overriding a rate limit does not make the protocol susceptible to an instant attack and just 'resets' the drawdown window. I know a lot of these sound silly, but if you give a hacker an inch, he will take a mile.

I do agree we can merge this once this is fixed (we can just delete that line), and have a base for people to improve upon with the improved refactoring and separation.

@diyahir diyahir merged commit 8aff4de into DeFi-Circuit-Breaker:core-overhaul Sep 28, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
3 participants