Step-by-Step Debugging for Deployed Solidity Smart Contracts

Step-by-Step Debugging for Deployed Solidity Smart Contracts

Comprehensive Techniques for Diagnosing and Fixing Code Errors

Debugging smart contracts that have already been deployed on the blockchain can be extremely challenging. In traditional programming, debugging involves adding logs, breakpoints, and other analytical tools that allow for real-time tracking of the application's state. Unfortunately, this approach is much more complicated with smart contracts deployed on the blockchain. We can freely modify the code and add logs when we develop smart contracts locally. However, sometimes our code must integrate with another protocol already deployed on the blockchain. This is when problems arise: we implement our integration, unexpected errors emerge, and the compiler generates a flood of cryptic hex values. If only we could modify the code of the integrated protocol, gain insights into its internal workings, and add logs… Fortunately, we can! In this article, we will guide you through this process and uncover the secrets of effectively debugging deployed smart contracts.

Defining the Problem: A Practical Example

Let’s imagine we are working on an integration with the Aave protocol, one of the most popular decentralized finance (DeFi) platforms. Below is a simple contract we developed, which is designed solely to supply tokens to an Aave pool.

// PoolSupplier.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;

import {IAaveV3Pool} from "./IAaveV3Pool.sol";
import {IERC20} from "./IERC20.sol";

contract PoolSupplier {
    IAaveV3Pool public immutable POOL;

    constructor(IAaveV3Pool _pool) {
        POOL = _pool;
    }

    function supply(IERC20 _asset, uint256 _amount) public {
        IERC20(_asset).approve({_spender: address(POOL), _amount: _amount});

        POOL.supply({_asset: address(_asset), _amount: _amount, _onBehalfOf: msg.sender, _referralCode: 0});
    }
}

Of course, we want to test our logic before deploying to production. Let's write a test using the Foundry framework.

// PoolSupplier.t.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;

import {Test, console} from "forge-std/Test.sol";
import {PoolSupplier} from "../src/PoolSupplier.sol";
import {IAaveV3Pool} from "../src/IAaveV3Pool.sol";
import {IERC20} from "../src/IERC20.sol";

IAaveV3Pool constant ETHEREUM_POOL = IAaveV3Pool(0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2);
IERC20 constant WETH = IERC20(0x0000000000085d4780B73119b644AE5ecd22b376);

contract PoolSupplierTest is Test {
    PoolSupplier public poolSupplier;

    function setUp() public {
        vm.createSelectFork({
            urlOrAlias: vm.envString("ETHEREUM_RPC_URL"),
            blockNumber: 21_000_950 // 19 Oct 2024
        });

        poolSupplier = new PoolSupplier(ETHEREUM_POOL);
    }

    function test_supply_success() public {
        uint256 suppliedAmount = 10 ether;

        address alice = makeAddr("alice");
        // add tokens to PoolSupplier
        deal({to: address(poolSupplier), token: address(WETH), give: suppliedAmount});

        vm.prank(alice);
        poolSupplier.supply({_asset: WETH, _amount: suppliedAmount});
    }
}

After running the test, we encounter the following error:

This is helpful, but what if we want to understand the error in detail and explore the internal implementation? Let's add some logs!

Cloning the Repository with the Deployed Smart Contract

To add logs to the deployed contract, we need to first fetch the smart contract code for editing. We will use the forge clone command for this purpose. We need an Etherscan API key, which you can obtain by following this instruction.

Let’s clone the Aave Pool Implementation contract. The Pool address is 0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2. However, this is the proxy code (if you're unfamiliar with how a proxy works, you can read about it here), and it does not hold the logic. The implementation code is located at 0xef434e4573b90b6ecd4a00f4888381e4d0cc5ccd, and this is the one we will fetch. We can download it to any directory unrelated to our repository. The following command will fetch the Pool code into a folder named AavePoolImplementation.

$ forge clone 0xef434e4573b90b6ecd4a00f4888381e4d0cc5ccd AavePoolImplementation -e <ETHERSCAN-API-KEY>

Modifying the Code and Getting its Bytecode

If we follow the Aave pool code, we will notice that the supply logic is located in the SupplyLogic.sol file. This is the code we want to debug. It is an external library contract and is used by the implementation. External libraries are invoked via delegatecall. If you want to learn more about libraries, you can read about them here.

We will import console.log and add it to the executeSupply function responsible for handling the supply logic.

// SupplyLogic.sol
// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.10;
// ...
import "forge-std/console.sol";

// ...
function executeSupply(
    mapping(address => DataTypes.ReserveData) storage reservesData,
    mapping(uint256 => address) storage reservesList,
    DataTypes.UserConfigurationMap storage userConfig,
    DataTypes.ExecuteSupplyParams memory params
) external {
    // Our added console log
    console.log("Hello from execute!");

Next, we need to build the project. Since the src folder in our downloaded project is empty, we need to change src it to lib in AavePoolImplementation/foundry.toml, which is where the Solidity code is located. This will allow the compiler to compile the files within it.

[profile.default]
src = "lib"
# ...

Great, now let's run the build command.

 $ forge build

Let's find the bytecode in the built folder. Navigate to AavePoolImplementation/out/SupplyLogic.sol/SupplyLogic.json.

If we look at the JSON file, we will see two bytecode-related fields: bytecode and deployedBytecode.

Which one to choose? The bytecode field contains the init code, which is responsible for deploying our runtime code on the blockchain and is not present on the blockchain after the deployment transaction. You can read more about this here. Our runtime code is the deployedBytecode field; it contains the code that is already on the blockchain, and this is the one we want to replace in the deployed smart contract. Copy it and return it to our test.

Including Modified Bytecode into the Test

First, save the fetched bytecode in a file. Create a file named Bytecode.sol.

// Bytecode.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;

bytes constant AAVE_POOL_SUPPLY_LOGIC_LIBRARY_BYTECODE = hex"7300000...

Next, find the address of the SupplyLogic.sol library that we want to replace. To do this, go to AavePoolImplementation/foundry.toml, and in the libraries field, we can see the mapped library names to addresses on the blockchain. We will find the following text:

SupplyLogic.sol:SupplyLogic:0x2b22E425C1322fbA0DbF17bb1dA25d71811EE7ba

The address 0x2b22E425C1322fbA0DbF17bb1dA25d71811EE7ba is the address of the library we want to replace.

We have the bytecode we want to replace and the address where we want to replace it. How do we do this? We will use the etch function from the Foundry framework, which allows us to replace the bytecode of a deployed contract. Let’s modify our test code.

// PoolSupplier.t.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;

import {Test, console} from "forge-std/Test.sol";
import {PoolSupplier} from "../src/PoolSupplier.sol";
import {IAaveV3Pool} from "../src/IAaveV3Pool.sol";
import {IERC20} from "../src/IERC20.sol";

import {AAVE_POOL_SUPPLY_LOGIC_LIBRARY_BYTECODE} from "./Bytecode.sol";

IAaveV3Pool constant ETHEREUM_POOL = IAaveV3Pool(0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2);
IERC20 constant WETH = IERC20(0x0000000000085d4780B73119b644AE5ecd22b376);
address constant AAVE_POOL_SUPPLY_LOGIC_LIBRARY = 0x2b22E425C1322fbA0DbF17bb1dA25d71811EE7ba;

contract PoolSupplierTest is Test {
    PoolSupplier public poolSupplier;

    function setUp() public {
        vm.createSelectFork({
            urlOrAlias: vm.envString("ETHEREUM_RPC_URL"),
            blockNumber: 21_000_950 // 19 Oct 2024
        });
        // set the byte
        vm.etch({target: AAVE_POOL_SUPPLY_LOGIC_LIBRARY, newRuntimeBytecode: AAVE_POOL_SUPPLY_LOGIC_LIBRARY_BYTECODE});

        poolSupplier = new PoolSupplier(ETHEREUM_POOL);
    }

    function test_supply_success() public {
        uint256 suppliedAmount = 10 ether;

        address alice = makeAddr("alice");
        // Set the bytecode of the address
        deal({to: address(poolSupplier), token: address(WETH), give: suppliedAmount});

        vm.prank(alice);
        poolSupplier.supply({_asset: WETH, _amount: suppliedAmount});
    }
}

Now, run the test and see the result:

$ forge test

Here is the result:

Great! Our console.log that we added has appeared!

So, what was the cause of the error? I'll leave that to you, dear readers. You can view the complete code here and explore all the modifications we made in this article.

There remains one more issue: immutable variables that may be present in the contract we want to modify.

Handling Immutable Variables in Deployed Contracts

Immutable variables are stored directly in the bytecode of the deployed contract. The catch is that these values are only substituted during the actual deployment transaction. This means that in the generated bytecode and deployedBytecode, instead of specific values, we will encounter placeholders with a zero value, which will be replaced during the deployment transaction. For instance, if I have an immutable variable that holds an address, the generated bytecode and deployedBytecode will show a zero address instead of the actual address. However, we want to replace these with the true addresses that are already present on the blockchain.

To tackle this issue, we need to replace all immutable variables with constant ones, using the actual values found on the blockchain.

Practical Example

Let’s consider a practical example involving the file AavePoolImplementation/aave-v3-origin/src/contracts/protocol/pool/Pool.sol, which contains the logic for the Aave Pool.

In this file, you may find various immutable variables that reference essential components of the protocol, such as the pool address, token addresses, and other critical parameters. To ensure that our bytecode accurately reflects the state of the blockchain, we will perform the following steps:

  1. Identify Immutable Variables: Review the Pool.sol file to identify all immutable variables that need to be replaced.

  2. Replace with Constants: Change these immutable variables to constant variables, assigning them the actual values from the blockchain.

// Pool.sol
// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.10;

// ...

abstract contract Pool is VersionedInitializable, PoolStorage, IPool {
    using ReserveLogic for DataTypes.ReserveData;

    IPoolAddressesProvider public immutable ADDRESSES_PROVIDER;

    // ...

    constructor(IPoolAddressesProvider provider) {
        ADDRESSES_PROVIDER = provider;
    }

The address ADDRESSES_PROVIDER is 0x2f39d218133AFaB8F2B819B1066c7E434Ad94E9e, so our code before compilation should look as follows:

// Pool.sol
// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.10;

// ...

abstract contract Pool is VersionedInitializable, PoolStorage, IPool {
    using ReserveLogic for DataTypes.ReserveData;

    IPoolAddressesProvider public constant ADDRESSES_PROVIDER =
        IPoolAddressesProvider(0x2f39d218133AFaB8F2B819B1066c7E434Ad94E9e);

    // ...

    constructor() {}

The rest of the process is the same as with other contracts.

Summary

Replacing the code of deployed contracts is a powerful technique that not only allows us to debug smart contracts but also to understand complex systems. This approach enables you to debug both your own deployed smart contracts and those developed by others. I personally use this method daily, and it has been invaluable in my work integrating with complex protocols.

That’s all for today. Stay SAFU, and fly to the moon! 🚀