A new paradigm for smart contract security, beyond `require` and `assert`

A new paradigm for smart contract security, requirebeyond_assert

Summary

Don't write require statements just for specific functions; write require statements for your protocol. Function compliance checks (requirements) - effects (Effects) - interactions (INteractions) + protocol invariants (Invariants) or FREI-PI pattern can help your contract be more secure, because it forces developers to focus on function-level security in addition to Also focus on protocol-level immutability.

motivation

In March 2023, Euler Finance was hacked and suffered a loss of US$200 million. Euler Finance is a lending marketplace where users can deposit collateral and borrow against it. It has some unique features and they are actually a lending marketplace comparable to Compound Finance and Aave.
You can read the postmortem about this hack here . Its main takeaway is the lack of a health check in a specific function, allowing users to break the underlying immutability of the lending market.

Fundamental Invariants

At the core of most DeFi protocols is an immutability, a property of program state that is expected to always be true. There may be multiple invariants, but generally they are built around a core idea. Here are some examples:

  • As in the lending market: users cannot take any action that would put any account in an unsafe or less safe collateral position ("less safe" means that it is already below the minimum safety threshold and therefore cannot be withdrawn further).
  • In AMM DEX: x * y == k, x + y == k, etc.
  • In Liquidity Mining Staking: Users should only be able to withdraw the amount of staked tokens they deposited.

Where Euler Finance went wrong wasn't necessarily that they added features, didn't write tests, or didn't follow traditional best practices. They audited the upgrade and had tests, but it still slipped through the cracks. The core problem is that they forget the core immutability of the lending market (and so do the auditors!).
Note: I'm not trying to pick on Euler, they are a talented team, but this is a recent case.

core of the problem

You might be thinking "Well, that's right. That's why they got hacked; they forgot a require statement". Yes and no.
But why do they forget the require statement?

Check-valid-interaction not good enough

A common pattern recommended for solidity developers is the Checks-Effects-Interactions pattern. It is useful for eliminating errors related to reentrancy and often increases the amount of input validation developers have to perform. However , it is prone to missing the forest for the trees.
What it teaches developers is: "First I write my require statement, then I do the validation, then maybe I do any interaction, and then I'm safe." The problem is, more often than not, it becomes a mix of checks and effects – not bad, right? The interaction is still final, so reentrancy is not an issue. But it forces users to focus on more specific features and individual state transitions rather than the global, broader context. That said:
the mere check-validate-interaction pattern causes developers to forget about the core immutability of their protocols .
It's still an excellent pattern for developers, but protocol immutability should always be ensured (seriously, you should still use CEI!).

Correct approach: FREI-PI mode

Take , for example, this snippet from dYdX’s SoloMargin contract ( source code ), which is a lending market and leveraged trading contract. This is a good example of what I call the Function Requirements-Effects-Interactions + Protocol Invariants pattern, or FREI-PI pattern.
Therefore, I believe this is the only lending market in the early stage that does not have any market-related holes. Compound and Aave don't have problems directly, but their forked code has had issues. And bZx has been hacked many times .
Examine the code below and note the following abstractions:

  1. Check input parameters (_verifyInputs).
  2. Actions (data conversion, state operations)
  3. Check final state (_verifyFinalState).
function operate(
     Storage.State storage state,
     Account.Info[] memory accounts,
     Actions.ActionArgs[] memory actions
 )
     public
 {
     Events.logOperation();

     _verifyInputs(accounts, actions);

     (
         bool[] memory primaryAccounts,
         Cache.MarketCache memory cache
     ) = _runPreprocessing(
         state,
         accounts,
         actions
     );

     _runActions(
         state,
         accounts,
         actions,
         cache
     );

     _verifyFinalState(
         state,
         accounts,
         primaryAccounts,
         cache
     );
 }

Still performs the usual Checks-Effects-Interactions. It is worth noting that check-validate-interaction with additional checks is not equivalent to FREI-PI – they are similar but serve fundamentally different goals. Therefore, developers should consider them different: FREI-PI, as a higher abstraction, aims at protocol safety, while CEI aims at functional safety.
The structure of this contract is really interesting – users can perform the actions they want in a sequence of actions (deposit, borrow, trade, transfer, liquidate, etc.). Want to deposit 3 different coins, withdraw a 4th, and liquidate an account? This is a single call.
This is the power of FREI-PI: users can do whatever they want within the protocol, as long as the immutability of the core lending market holds at the end of the call: a user cannot take any action that puts any account in an unsafe or worse Unsafe collateral positions. For this contract, this is performed in verifyFinalState, which checks the collateral status of each affected account to ensure that the agreement is in a better state than when the transaction began.
There are some additional invariants included in this function that complement the core invariants and help with side functionality like closing markets, but it's the
core checks that really keep the protocol safe.

Entity-centric FREI-PI

Another problem with FREI-PI is the entity-centric concept. Consider a lending market and assumed core immutability:

一个用户不能采取任何行动,将任何账户置于不安全或更不安全的抵押品仓位

Technically this is not the only immutability, but it is for user entities (it is still a core protocol immutability, usually user immutability is a core protocol immutability). The lending market will also typically have 2 additional entities:

  1. Oracle
  2. Management/Governance

Every additional immutability makes the protocol harder to secure, so less is better.

Oracle

For oracles, take the $130 million Cream Finance breach as an example. Core immutability of oracle entities:

预言机提供准确且(相对)实时的信息

It turns out that validating oracles at runtime with FREI-PI is tricky, but it can be done and requires some forethought. Generally speaking, Chainlink is a good choice to rely on primarily for most of the immutability requirements. In the rare case of manipulation or surprise, it might be beneficial to have some safeguards that reduce flexibility in favor of accuracy (like checking whether the last known value was several hundred percent greater than the current value). Likewise, dYdX's SoloMargin system does a great job with their DAI oracle, here's the code (in case you can't tell, I think it's the best-written complex smart contract system in history).
For more on oracle evaluation, and to highlight the capabilities of the Euler team, they wrote a great article on calculating the price of manipulating Uniswap V3 TWAP oracles .

Management/Governance

Creating immutability for managed entities is the trickiest. This is mainly due to the fact that most of their role is to change other existing invariants. That said, if you can avoid using an administrative role, you should.
Fundamentally, the core invariants of a managed entity might be:

管理员应该在当且仅当在其他的不变性或需要特意移除或修改不变性时才采取行动。

Interpretation: Administrators can do things that should not result in breaking immutability, unless they drastically change things to protect user funds (eg: moving assets into a rescue contract is a removal of immutability). Administrators should also be considered a user, so the user immutability of the core lending market should also hold for them (meaning they cannot attack other users or the protocol). Currently, some administrator behaviors are impossible to verify with FREI-PI at runtime, but if there are strong enough invariants elsewhere, hopefully most issues can be mitigated. I say currently, because one could imagine using a zk proof system that might check the entire state of the contract (every user, every oracle, etc.). As an example of an administrator violating immutability, take the Compound governance action that borked the cETH market
in August 2022 . Fundamentally, this upgrade breaks Oracle's immutability: Oracle provides accurate and (relatively) real-time information. Due to the lack of functionality, Oracle can provide incorrect information. A runtime FREI-PI verification that checks whether the affected Oracle can provide real-time information can prevent this from happening during upgrades. This can be incorporated into _setPriceOracle to check whether all assets receive real-time information. The benefit of FREI-PI for the management role is that the management role is relatively price insensitive (or at least it should be), so higher gas usage shouldn't be a big issue.

Complexity is dangerous

So while the most important invariants are the core invariants of the protocol, there can also be some entity-centric invariants that must be held by the core invariants. However, the simplest (and smallest) set of invariants is probably the safest. A shining example of simple being good is Uniswap…

Why Uniswap has never been hacked (probably)

AMMs can have the simplest basic invariance of any DeFi primitive: tokenBalanceX * tokenBalanceY == k (e.g. constant product model). Every function in Uniswap V2 is built around this simple immutability:

  1. Mint: added to k
  2. Burn: Subtract from k
  3. Swap: transfer x and y, but don’t move k.
  4. Skim: Rescale tokenBalanceX * tokenBalanceY to make it equal to k and remove the excess.

The security secret of Uniswap V2: the core is a simple immutability, and all functions are in service of it. The only other entity that can be argued is governance, which can turn on a fee switch, which does not touch the core immutability, just the distribution of ownership of the token balance. This simplicity in their security claims is why Uniswap has never been hacked. Simplicity is not actually a disdain for the excellent developers of Uniswap's smart contracts. On the contrary, it takes excellent engineers to find simplicity.

Gas problem

My Twitter feed has been filled with screams of horror and anguish from optimizationists that these checks are unnecessary and inefficient. There are two points on this issue:

  1. You know what else is inefficient? Had to send information to Laurence North Korean hacker via etherscan , use ETH to transfer money, and threatened FBI intervention.
  2. You've probably already loaded all the required data from storage, so at the end of the call, just add a little require check on the hot data. Do you want your agreement to cost a negligible amount of money, or do you want it to be fatal?

If the cost is too high, rethink the core variables and try to simplify.

What does this mean to me?

As a developer, define and express core invariants as early as possible in the development process. As a concrete suggestion: make yourself the first function you write to be _verifyAfter, which verifies your immutability after every call to your contract. Put it in your contract and deploy it there. Supplement this invariance (and other entity-centric invariants) with more extensive invariance tests that are checked before deployment ( Foundry guide ).
Transient storage opens up some interesting optimizations and improvements that Nascent will experiment with – I recommend you consider how transient storage can be used as a tool to achieve better safety across calling contexts.
In this article, I didn't spend much time on the introduction of FREI-PI mode input validation, but it is very important. Defining the bounds of the input is a challenging task to avoid overflows and similar situations. Consider checking out and following the progress of our tool: pyrometer (currently in beta, please give us a star). It can provide insight and help find places where you may not be doing input validation.

in conclusion

Above any catchy abbreviation (FREI-PI) or pattern name, the really important point is this:
find simplicity in the core immutability of your protocol. And work like crazy to make sure it's never destroyed (or is captured before it is).

Guess you like

Origin blog.csdn.net/weixin_28733483/article/details/132789115