Find Out How We Made Top 5 in a CodeHawks Audit in Only Two Days

Find Out How We Made Top 5 in a CodeHawks Audit in Only Two Days

Introduction

In this article, you will find a detailed explanation of how we detected a bug that earned us a substantial reward in the Beanstalk contest.

We will be discussing how and what we did to detect this error:

The intention of this article is to encourage people to delve into and participate in as many contests as possible. It's truly an opportunity to put into practice all the knowledge you have been learning over time.

At the end of the article, we will provide a series of resources and advice to help you as much as possible.

Beanstalk GitHub Repository

Context

We'd like to share a bit about the situation we were in before starting the contest.

On the very same day, we began looking into the contest, we had just finished an intense testing campaign (Check the result in our GitHub Repo). The reality was that we only had 2-3 days to participate in the contest before starting our next testing campaign.

This short time frame was an additional challenge, but it was also an opportunity to put our skills and acquired knowledge to the test.

The Time Problem

This was our biggest challenge. As we mentioned earlier, we only had 2-3 days to work on the contest. Even though we knew it would be quite difficult, we decided to go for it.

We had an initial call to plan things out. We started with our new Notion template, which we use by default for every new project, and organized ourselves in a way that allowed us to distribute the work as efficiently and structured as possible.

Why We Chose the CodeHawks Contest Over Others

During this short period, we had to decide which contest to participate in among all the ones available at the time.

The decision was to start with the Beanstalk contest because it seemed quite interesting and was a topic we were somewhat familiar with.

So, after making our decision, we got to work right away.

Tools and Types of Tests Used

We decided to use a series of tools for this test to cover different perspectives.

Firstly, the project mainly used Hardhat, which didn't make things easy when it came to writing tests in a more straightforward, powerful, and visual manner. Therefore, we adapted it to Foundry to be able to work with it minimally and effectively.

These are the tools we used in the contest:

  • Static Analysis Tools: We initially used tools like Slitherin, Aderyn, Wake, and Olympix to start studying the weak points of the project based on their data. This helped us get a first impression.

    (For more details on how to find bugs with Aderyn check out our article "How to Write a Detector in Aderyn Step by Step")

  • Manual Analysis and Testing Campaign: Next, we began the manual analysis and testing campaign. During this phase, we analyzed each part of the contracts, understanding the logic of each function. We verified if all invariants were met by conducting fuzz tests on each function. For this part, we used Foundry as our main tool.

Why These Tools Are Important to Us

For us, one of the fundamental pillars of our audits is the use of fuzzing tests. Once we started using them in our audits, it marked a significant improvement. These types of tests allow us to explore countless possible scenarios that would take much longer with a manual method and would be much more complex.

This doesn't mean that the manual review is unnecessary. On the contrary, we first review the protocol manually and then use these tests to verify that everything works correctly.

Our Surprise When We Saw the Initial Results

This part was quite funny. A few days before the final contest results were announced, we received an update on our profile indicating that we had won a reward of 13,000 USDC. Naturally, we were thrilled and quickly shared the news among ourselves. We immediately reached out to the Cyfrin team to confirm, and they were very attentive and kind.

However, they explained that, unfortunately, those were not the final results yet and that the last count still needed to be done before the final outcome was announced.

So, we decided to wait a few more days for the final results.

At the end of this article, we will reveal the final reward amount we received.

Imagen

CodeHawks Team

Before diving into the code, we would like to thank the CodeHawks team for their incredible work. They were very attentive and treated us exceptionally well with a personal and pleasant approach.

We also want to invite everyone to check out the new update on their website. It is now much more visual, pleasant, and beautiful, greatly enhancing the user experience.

Challenges and What's Next

Next, we will break down the contract and the function where we discovered the issue. We invite you to jump to the section that interests you most from the following points:

  • Understanding the Contract

  • What is the problem with this function?

  • What is the solution?

  • Demonstrating the Problem with a POC

  • Test Results

  • Final Reward

Understanding the Contract:

In this section, we will be breaking down the entire logic of the contract line by line to simplify and explain it as clearly as possible.

/*
 * SPDX-License-Identifier: MIT
 */

pragma solidity =0.7.6;
pragma experimental ABIEncoderV2;

import {SafeMath} from "@openzeppelin/contracts/math/SafeMath.sol";
import {LibGauge} from "contracts/libraries/LibGauge.sol";

/**
 * @title GaugePointFacet
 * @author Brean
 * @notice Calculates the gaugePoints for whitelisted Silo LP tokens.
 */
contract GaugePointFacet {
    using SafeMath for uint256;

    uint256 private constant ONE_POINT = 1e18;
    uint256 private constant MAX_GAUGE_POINTS = 1000e18;

    uint256 private constant UPPER_THRESHOLD = 10001;
    uint256 private constant LOWER_THRESHOLD = 9999;
    uint256 private constant THRESHOLD_PRECISION = 10000;

    /**
     * @notice DefaultGaugePointFunction
     * is the default function to calculate the gauge points
     * of an LP asset.
     *
     * @dev If % of deposited BDV is .01% within range of optimal,
     * keep gauge points the same.
     *
     * Cap gaugePoints to MAX_GAUGE_POINTS to avoid runaway gaugePoints.
     */
    function defaultGaugePointFunction(
        uint256 currentGaugePoints,
        uint256 optimalPercentDepositedBdv,
        uint256 percentOfDepositedBdv
    ) external pure returns (uint256 newGaugePoints) {
        if (
            percentOfDepositedBdv >
            optimalPercentDepositedBdv.mul(UPPER_THRESHOLD).div(THRESHOLD_PRECISION)
        ) {
            // gauge points cannot go below 0.
            if (currentGaugePoints <= ONE_POINT) return 0;
            newGaugePoints = currentGaugePoints.sub(ONE_POINT);
        } else if (
            percentOfDepositedBdv <
            optimalPercentDepositedBdv.mul(LOWER_THRESHOLD).div(THRESHOLD_PRECISION)
        ) {
            newGaugePoints = currentGaugePoints.add(ONE_POINT);

            // Cap gaugePoints to MAX_GAUGE_POINTS if it exceeds.
            if (newGaugePoints > MAX_GAUGE_POINTS) return MAX_GAUGE_POINTS;
        }
    }
}

Explanation of the defaultGaugePointFunction

The defaultGaugePointFunction is used to adjust the gauge points of Liquidity Provider (LP) tokens based on the percentage of Base Deposited Value (BDV) deposited.

Input Parameters

  1. currentGaugePoints: The current gauge points of the LP token.

  2. optimalPercentDepositedBdv: The optimal percentage of BDV that should be deposited.

  3. percentOfDepositedBdv: The current percentage of BDV that is deposited.

Constants Used

  • ONE_POINT: Represents one gauge point (1e18).

  • MAX_GAUGE_POINTS: The maximum number of gauge points allowed (1000e18).

  • UPPER_THRESHOLD: Upper threshold (10001, representing 100.01%).

  • LOWER_THRESHOLD: Lower threshold (9999, representing 99.99%).

  • THRESHOLD_PRECISION: Threshold precision (10000).

Function Flow

The function has two main conditional blocks (if statements) that determine how to adjust the gauge points based on the percentage of BDV deposited.

Function Breakdown

Let's break down each block of the function to understand exactly what it does and when each part is executed.

1. Condition for Reducing Points

if (
    percentOfDepositedBdv >
    optimalPercentDepositedBdv.mul(UPPER_THRESHOLD).div(THRESHOLD_PRECISION)
)

Explanation:

  • Compares percentOfDepositedBdv with optimalPercentDepositedBdv adjusted by the UPPER_THRESHOLD (100.01% of the optimal).

  • Upper Threshold Calculation:

    • Multiplies optimalPercentDepositedBdv by UPPER_THRESHOLD (10001).

    • Divides the result by THRESHOLD_PRECISION (10000).

Example:

If optimalPercentDepositedBdv is 50: [ 50 \times 10001 / 10000 = 50.005 ]

The condition is: If percentOfDepositedBdv > 50.005, then it is met.

Action:

if (currentGaugePoints <= ONE_POINT) return 0;
newGaugePoints = currentGaugePoints.sub(ONE_POINT);
  • If currentGaugePoints is less than or equal to ONE_POINT, it is set to 0.

  • Otherwise, currentGaugePoints is reduced by ONE_POINT.

2. Condition for Increasing Points

else if (
    percentOfDepositedBdv <
    optimalPercentDepositedBdv.mul(LOWER_THRESHOLD).div(THRESHOLD_PRECISION)
)

Explanation:

  • Compares percentOfDepositedBdv with optimalPercentDepositedBdv adjusted by the LOWER_THRESHOLD (99.99% of the optimal).

  • Lower Threshold Calculation:

    • Multiplies optimalPercentDepositedBdv by LOWER_THRESHOLD (9999).

    • Divides the result by THRESHOLD_PRECISION (10000).

Example:

If optimalPercentDepositedBdv is 50: [ 50 \times 9999 / 10000 = 49.995 ]

The condition is: If percentOfDepositedBdv < 49.995, then it is met.

Action:

newGaugePoints = currentGaugePoints.add(ONE_POINT);
if (newGaugePoints > MAX_GAUGE_POINTS) return MAX_GAUGE_POINTS;
  • ONE_POINT is added to currentGaugePoints.

  • If newGaugePoints exceeds MAX_GAUGE_POINTS, it is set to MAX_GAUGE_POINTS.

What is the problem with this function?

The defaultGaugePointFunction has an issue in handling the adjustment of gauge points when the percentage of Base Deposited Value (BDV) deposited is exactly equal to the optimal percentage (optimalPercentDepositedBdv). In this case, the function lacks an explicit condition to manage this scenario, which can result in unintended behavior.

Detail of the Problem

  • Conditions Handle Inequality: The function has conditions to handle when percentOfDepositedBdv is greater than optimalPercentDepositedBdv adjusted by the UPPER_THRESHOLD, and when it is less than optimalPercentDepositedBdv adjusted by the LOWER_THRESHOLD.

  • Equality Case Not Handled: If percentOfDepositedBdv is exactly equal to optimalPercentDepositedBdv, neither condition is met, potentially leading to an unintended adjustment of gauge points to 0 instead of maintaining their current value.

What is the solution?

To solve this problem, an explicit condition needs to be added to handle the case where percentOfDepositedBdv is equal to optimalPercentDepositedBdv. This can be achieved by adding an else clause that returns the currentGaugePoints unchanged if none of the previous conditions are met.

Implementation of the Solution

Add the following condition at the end of the function:

else {
    return currentGaugePoints;
}

Demonstrating the Problem with a POC

To demonstrate the problem effectively, we wrote two tests: a simple test case and a fuzz test. These tests show that the defaultGaugePointFunction has a problem when the percentage of BDV deposited is exactly equal to the optimal percentage.

Simple Test Case

This test specifically targets the scenario where percentOfDepositedBdv is equal to optimalPercentDepositedBdv.

function testnew_GaugePointAdjustment() public {
    uint256 currentGaugePoints = 1189; 
    uint256 optimalPercentDepositedBdv = 64; 
    uint256 percentOfDepositedBdv = 64; 

    uint256 newGaugePoints = gaugePointFacet.defaultGaugePointFunction(
        currentGaugePoints,
        optimalPercentDepositedBdv,
        percentOfDepositedBdv
    );

    assertTrue(newGaugePoints <= MAX_GAUGE_POINTS, "New gauge points exceed the maximum allowed");
    assertEq(newGaugePoints, currentGaugePoints, "Gauge points adjustment does not match expected outcome");
}
  • Setup:

    • currentGaugePoints is set to 1189.

    • optimalPercentDepositedBdv is set to 64.

    • percentOfDepositedBdv is set to 64.

  • Result Verification:

    • Expected: newGaugePoints should equal currentGaugePoints because percentOfDepositedBdv equals optimalPercentDepositedBdv.

    • Actual: Without the else condition, newGaugePoints could be 0, which is incorrect.

Fuzz Test

This test uses a range of values to ensure robustness and checks for the correct adjustment of gauge points.

function testGaugePointAdjustmentUnifiedFuzzing(
    uint256 currentGaugePoints,
    uint256 optimalPercentDepositedBdv,
    uint256 percentOfDepositedBdv
) public {
    currentGaugePoints = bound(currentGaugePoints, 1, MAX_GAUGE_POINTS - 1);
    optimalPercentDepositedBdv = bound(optimalPercentDepositedBdv, 1, 100);
    percentOfDepositedBdv = bound(percentOfDepositedBdv, 1, 100);

    uint256 expectedGaugePoints = currentGaugePoints;

    if (percentOfDepositedBdv * THRESHOLD_PRECISION > optimalPercentDepositedBdv * UPPER_THRESHOLD) {
        expectedGaugePoints = currentGaugePoints > ONE_POINT ? currentGaugePoints - ONE_POINT : 0;
    } else if (percentOfDepositedBdv * THRESHOLD_PRECISION < optimalPercentDepositedBdv * LOWER_THRESHOLD) {
        expectedGaugePoints = currentGaugePoints + ONE_POINT <= MAX_GAUGE_POINTS ? currentGaugePoints + ONE_POINT : MAX_GAUGE_POINTS;
    }

    uint256 newGaugePoints = gaugePointFacet.defaultGaugePointFunction(
        currentGaugePoints,
        optimalPercentDepositedBdv,
        percentOfDepositedBdv
    );

    assertTrue(newGaugePoints <= MAX_GAUGE_POINTS, "New gauge points exceed the maximum allowed");
    assertEq(newGaugePoints, expectedGaugePoints, "Gauge points adjustment does not match expected outcome");
}
  • Expected Gauge Points Calculation:

    • Initially, expectedGaugePoints is set to currentGaugePoints.

    • Threshold Check:

      • If percentOfDepositedBdv is above the upper threshold, expectedGaugePoints is decreased by ONE_POINT or set to 0.

      • If percentOfDepositedBdv is below the lower threshold, expectedGaugePoints is increased by ONE_POINT, capped at MAX_GAUGE_POINTS.

  • Assertions:

    • Ensures newGaugePoints does not exceed MAX_GAUGE_POINTS.

    • Verifies newGaugePoints matches expectedGaugePoints.

Test Results

To run the tests correctly, follow these steps:

export FORKING_RPC=https://eth-mainnet.g.alchemy.com/v2/{API}
forge test --mc GaugePointFacetTest --mt testGaugesssPointAdjustmentUnifiedFuzzing -vvv
[FAIL. Reason: assertion failed; counterexample: calldata=0xdace5ffa0000000000000000000000000465ff2e9c9fd7f2b5a78cfaa0671d046c517d5d00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001 args=[25110567842437950750745261937466550563734912349 [2.511e46], 0, 1]] testGaugesssPointAdjustmentUnifiedFuzzing(uint256,uint256,uint256) (runs: 0, μ: 0, ~: 0)
Logs:
  Bound Result 505308988514485682721
  Bound Result 1
  Bound Result 1
  Error: Gauge points adjustment does not match expected outcome
  Error: a == b not satisfied [uint]
        Left: 0
       Right: 505308988514485682721

Traces:
  [30286] DefaultTestContract::testGaugesssPointAdjustmentUnifiedFuzzing(25110567842437950750745261937466550563734912349 [2.511e46], 0, 1)
    ├─ [0] console::log("Bound Result", 505308988514485682721 [5.053e20]) [staticcall]
    │   └─ ← [Stop]
    ├─ [0] console::log("Bound Result", 1) [staticcall]
    │   └─ ← [Stop]
    ├─ [0] console::log("Bound Result", 1) [staticcall]
    │   └─ ← [Stop]
    ├─ [826] GaugePointFacet::defaultGaugePointFunction(505308988514485682721 [5.053e20], 1, 1) [staticcall]
    │   └─ ← [Return] 0
    ├─ emit log_named_string(key: "Error", val: "Gauge points adjustment does not match expected outcome")
    ├─ emit log(val: "Error: a == b not satisfied [uint]")
    ├─ emit log_named_uint(key: "      Left", val: 0)
    ├─ emit log_named_uint(key: "     Right", val: 505308988514485682721 [5.053e20])
    ├─ [0] VM::store(VM: [0x7109709ECfa91a80626fF3989D68f67F5b1DD12D], 0x6661696c65640000000000000000000000000000000000000000000000000000, 0x0000000000000000000000000000000000000000000000000000000000000001)
    │   └─ ← [Return]
    └─ ← [Stop]

Suite result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 9.52ms (3.61ms CPU time)

Final Reward

I know many of you probably skipped most of the article to see the final reward amount 😝.

In the end, we received a total of 8223.41 USDC, an amount that motivated us even more to continue doing this kind of work.

It was truly an enriching and very interesting experience.

Resources

As the final part of the test, we encourage you to carefully study and research certain resources that, in our opinion, can be very helpful for learning about auditing, testing, development, and the application of tools, among other things.

We hope you enjoyed the article as much as we enjoyed writing it.