Ante v0.5 Internal Code Audit

Ante
Ante Labs

--

This internal audit is meant to give a technical overview of the Ante v0.5 codebase for developers — how it works, why we decided to do things the way we did, and the implications of those decisions.

If you are unfamiliar with Ante and what it does, we recommend checking out our short intro here before reading this audit.

Summary of Findings

No critical flaws were found in our internal audit of the current Ante v0.5 contracts. Several minor issues were identified and addressed as described below and detailed further in the following sections:

Minor issues

  • Small rounding errors in accrued delay add up over time. These were proven both empirically and analytically to be extremely unlikely to result in material differences in user payouts, and will never result in pool insolvency.
  • Despite several mechanisms to discourage front-running, it is still theoretically possible for a sufficiently motivated front-runner to front-run a checkTest transaction by challenging the minimum 0.01 ETH into all Ante Pools early enough to be included in the eligible verifiers list. While this would not prevent a legitimate challenger from receiving a payout upon test failure, the team is actively working on more robust mechanisms to address these concerns.

Ante v0.5’s contracts have no admin keys or owners, are not upgradeable, and do not use proxy contracts. This means that there is no risk of introducing new bugs to the current Ante protocol and no way for team members to steal user funds via collusion.

Scope

Ante v0.5 is a fairly simple system composed of three key contracts:

AnteTest.sol: This file contains the abstract contract AnteTest, which both inherits the IAnteTest interface and is itself inherited by all Ante Tests written by our community.

AntePool.sol: This file contains the AntePool contract. Each Ante Pool is linked to a single Ante Test and enables individuals to stake and challenge that test’s invariant.

AntePoolFactory.sol: This file contains the AntePoolFactory contract which enables the deployment of Ante Pools on-chain.

These contracts can be viewed in https://github.com/antefinance/ante-v0-core/tree/v0.5/contracts and this audit specifically references commit hash 5d7488e3dd7946a384f24b13f6a475b3ad709451.

Now let’s examine these three contracts in detail.

AnteTest.sol

The interface IAnteTest defines 5 functions which are partially implemented by the abstract contract AnteTest.

The view functions in IAnteTest are implemented via the public variables testAuthor, testName, protocolName, and testedContracts. The checkTestPasses function is left unimplemented and must be implemented by any inheriting Ante Test.

Note that checkTestPasses is defined as an external function but NOT as a view. Though inheriting Ante Tests can implement this function as a view function if they choose, it is not necessary and ultimately determined by whether testing the invariant requires mutating state or not.

AnteTest also includes the convenience method getTestedContracts which returns the entire testedContracts array, since the default generated getter only returns specific elements given an index.

AnteTest is designed to be easy to inherit from, only requiring the inheriting contract to specify the name of the test for its own constructor. It also conveniently sets testName and testAuthor, but does not set protocolName and testedContracts, both of which must be initialized by the inheriting contract (usually in that contract’s constructor).

AntePoolFactory.sol

AntePoolFactory functions similarly to UniswapV2Factory in that it allows easy and standardized deployment of Ante Pools on chain. The key method here is createPool, which creates an Ante Pool given an Ante Test address. This method also makes sure that a pool hasn’t been created by this factory for the provided Ante Test yet and, importantly, initializes the created Ante Pool. This ensures that the Ante Pool is properly configured and performs several important validations on the Ante Test (described in the AntePool.sol section).

Another important function of AntePoolFactory is to store all of its own created Ante Pools. This mitigates a potential attack vector in which a hacker deploys a malicious Ante Pool and then steals staker and challenger funds. The pool address in this case can be checked against allPools in the official on-chain AntePoolFactory. If it’s not present, then the pool hasn’t been created by the factory and caution is warranted.

AntePool.sol

The AntePool contract is by far the most complex smart contract in our codebase, governing the interaction of stakers and challengers and ensuring that all stakeholders are properly rewarded depending on the current state of the underlying ante test.

Initialization

Initialization of the Ante Pool occurs in two steps. First, in the constructor, the factory variable is set to msg.sender. This is important because only the factory may call initialize to finish initializing the Ante Pool. Some other variables are also set in the constructor to their initial value, but it is not strictly important that they be initialized here vs in initialize or during definition.

The initialize function takes in an Ante Test address and configures the Ante Pool accordingly. Importantly, this function can only be called once to avoid malicious re-initialization of the contract, as enforced using the notInitialized modifier. This function also performs several important safety checks on the Ante Test, namely that it implements the checkTestPasses function and that this function currently returns true. Astute code readers may notice that pendingFailure (the variable which records whether or not the underlying Ante Test has failed) is initially defined as true and only set to false in the initialize method. The reason for this is to avoid introducing a redundant modifier preventing all external functions in the Ante Pool contract from being called pre-initialization (a situation which also would never occur “naturally”). Instead, we can set pendingFailure to true initially and use the existing testNotFailed modifier to accomplish the same goal and seal off an unlikely edge case without wasting gas.

In practice, this two-step initialization is taken care of by the pool factory’s createPool function. This is also the reason why the process is split into two steps — since constructor arguments modify smart contract bytecode, having the initialization of the Ante Pool occur solely in the pool’s constructor would require a comparatively expensive on-chain byte manipulation step to place the Ante Test address in the Ante Pool’s bytecode in createPool.

Decay

During normal operation (i.e. while the underlying Ante Test still passes), challengers pay stakers a 20%/year fee called decay. This calculation is managed by the updateDecay function.

Since historical state data is not normally available on-chain, all the state required to compute the decay must be present on-chain at the time of the calculation. To accomplish this, we implement a common pattern in which the decay accrued is updated whenever a contract interaction occurs which may change the decay calculation.

Namely, updateDecay is called during stake, unstake, unstakeAll, and finally upon test failure in checkTest.

Interestingly, due to issues with approximating floating point calculations with integer arithmetic, small numerical errors in accrued decay accumulate over the course of normal pool operation. We investigated these numerical errors heavily and proved, both empirically and analytically, that they remain vanishingly small relative to pool balance changes and are extremely unlikely to result in material differences in staker and challenger payouts. The more quantitatively inclined readers may be interested in our two-part writeup on this subject.

Staking/Challenging

At the code level, staking and challenging work similarly. Both use the stake method, with the msg.value of the transaction indicating how much value is being deposited and a single boolean flag isChallenger determining whether the user is staking or challenging.

The data structures used to store staker and challenger info, stakingInfo and challengerInfo respectively, are both instances of the PoolSideInfo struct. This struct also tracks how much each user balance has changed due to decay.

There are slight differences in the deposit and withdraw workflows between stakers and challengers to mitigate bad behavior. Challengers are required to deposit at least 0.01 ETH, in order to mitigate an attack whereby a user can challenge every pool and front-run a checkTest call which causes the underlying ante test to fail (this is discussed in greater detail below). Stakers must wait at least 24 hours to withdraw, to avoid a situation in which stakers are given advance warning of an exploit and can withdraw before challengers can trigger a failing test and claim their reward. The data tracking when stakers are allowed to withdraw is stored in withdrawInfo.

Check Test and Claim

Challengers can check if the underlying Ante Test linked to an Ante Pool is failing or not by calling the pool’s checkTest function. In order to properly incentivize active checking of Ante Tests, we include a VERIFIER_BOUNTY of 5% of staker capital, paid to the challenger who successfully triggers a failing test.

To avoid arbitrary addresses being able to front-run a checkTest transaction, we restrict the set of eligible verifiers to existing challengers who have been challenging for at least 12 blocks. Additionally, to prevent arbitrary addresses from challenging a failing Ante Test after observing the corresponding checkTest transaction in the mempool, we restrict the set of challengers eligible for any payout to those who have challenged for at least 12 blocks. Both these checks are enabled via the eligibilityInfo struct.

However, we acknowledge that these solutions don’t perfectly solve the front-running issue, and are actively working on more robust mechanisms.

Note that since the total eligible challenger balance (using the above criteria) is recalculated upon test failure, small numerical errors accumulated prior to test failure do not prevent challengers from claiming their full share of the staked balance.

As challengers claim their corresponding share of rewards following test failure, their balance is set to zero so they cannot erroneously claim multiple times.

Security Considerations

Writing bug-free code is hard, but there are tools available to Solidity developers which make it a bit easier. It’s extremely important to make use of methods like audits, bug bounties, insurance, unit testing, static analysis, input fuzzing, formal verification, and other emerging tools (like Ante Tests) to guarantee the safety of user funds to the maximal extent possible.

Though it by no means guarantees absolute code safety, we are proud of the unit testing suite we developed for Ante v0.5. But don’t just take our word for it — you can run the tests yourself from our public repo. These tests fall broadly into three categories:

  • Tests of initial conditions
  • Tests of final state after a transition (i.e, a user stakes, or calls checkTest, challengers can claim correct amount after test failure, etc.)
  • Tests of specific exploits or disallowed behavior (i.e., Ante Pool can’t be initialized twice, stakers can’t withdraw after test failure, etc.)

You can see these patterns in action in our testing suite, as shown below in our tests on the reward claim functionality:

We also include the output of running slither, a popular static analyzer, on our repo for convenience here.

We hope you enjoyed this in-depth discussion of the Ante v0.5 codebase and the “peek behind the curtain” into some of the considerations underpinning the protocol design. If you have any questions or just want to discuss further, feel free to message us on Twitter or connect with us in our Discord!

--

--

Ante
Ante Labs

Building a Smart Tests Community for @AnteFinance