Debugging in a Forked Environment

In Part 1 we deployed the Lock contract directly from Test1.t.sol. However, in many real-world situations it is more convenient to deploy our contracts using a deploy script. The Hardhat demo project comes with an example of how to do this in scripts/deploy.ts:

import { ethers } from "hardhat";

async function main() {
  const currentTimestampInSeconds = Math.round(Date.now() / 1000);
  const ONE_YEAR_IN_SECS = 365 * 24 * 60 * 60;
  const unlockTime = currentTimestampInSeconds + ONE_YEAR_IN_SECS;

  const lockedAmount = ethers.utils.parseEther("1");

  const Lock = await ethers.getContractFactory("Lock");
  const lock = await Lock.deploy(unlockTime, { value: lockedAmount });

  await lock.deployed();

  console.log(`Lock with 1 ETH and unlock timestamp ${unlockTime} deployed to ${lock.address}`);
}

// We recommend this pattern to be able to use async/await everywhere
// and properly handle errors.
main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

Suppose we wanted to debug an instance of Lock that has already been deployed. We could do that by following these steps:

Run a Hardhat Network Node

$ npx hardhat node
Started HTTP and WebSocket JSON-RPC server at http://127.0.0.1:8545/

Run Deploy Script

$ npx hardhat run scripts/deploy.ts --network localhost
Compiled 1 Solidity file successfully
Lock with 1 ETH and unlock timestamp 1707281396 deployed to 0x5FbDB2315678afecb367f032d93F642f64180aa3

Configure Debugger to Use Hardhat Node

Open the Settings Window and select the Test1 contract which we created in Part 1.

Then configure these settings under Debugging Test1:

SettingValue
Forking→Enabledchecked
Forking→Ethereum RPC URLhttp://127.0.0.1:8545/

Click OK when you're done.

Modify Your Test Contract

Let's rewrite Test1.t.sol with the assumption that Lock is already deployed at address 0x5FbDB2315678afecb367f032d93F642f64180aa3:

// SPDX-License-Identifier: MIT
pragma solidity >= 0.4.21 < 0.9.0;
import "contracts/Lock.sol";

contract DbgEntry {
    constructor() {
        Lock lock = Lock(0x5FbDB2315678afecb367f032d93F642f64180aa3);
        address lockOwner = lock.owner();
        uint256 lockUnlockTime = lock.unlockTime();
        uint256 currentTime = block.timestamp;
    }
}

Put a breakpoint inside the test and click Debug. You should now be able to debug the deployed contract.

Calling lock.withdraw()

If we try calling lock.withdraw() from our test, our transaction would be reverted. This is because we fail to pass the two necessary conditions for a withdrawal:

  1. The current block's timestamp must be larger or equal to the unlock time. But the unlock time was set by deploy.ts to be 1-year into the future: const ONE_YEAR_IN_SECS = 365 * 24 * 60 * 60;
  2. withdraw() can only be called by the owner of the contract, but our test contract is not the owner.
function withdraw() public {
    require(block.timestamp >= unlockTime, "You can't withdraw yet");
    require(msg.sender == owner, "You aren't the owner");
    emit Withdrawal(address(this).balance, block.timestamp);
    owner.transfer(address(this).balance);
}

Bypassing condition 1 can be achieved using EvmSetBlockTimestamp as we've done in Part 1.

Bypassing condition 2 is possible using the cheat code EvmSpoofMsgSender, which instructs the debugger to set all subsequent calls' msg.sender to be the specified address.

// SPDX-License-Identifier: MIT
pragma solidity >= 0.4.21 < 0.9.0;
import "contracts/Lock.sol";

contract DbgEntry {
    event EvmSetBlockTimestamp(uint256);
    event EvmSpoofMsgSender(address);
    event EvmUnspoof();

    constructor() {
        Lock lock = Lock(0x5FbDB2315678afecb367f032d93F642f64180aa3);
        address lockOwner = lock.owner();
        uint256 lockUnlockTime = lock.unlockTime();
        uint256 currentTime = block.timestamp;

        // spoof block.timestamp
        emit EvmSetBlockTimestamp(lockUnlockTime + 1);

        // spoof msg.sender as seen by lock.withdraw()
        emit EvmSpoofMsgSender(lockOwner);

        // test lock.withdraw()
        uint256 balanceBefore = lockOwner.balance;
        lock.withdraw();
        uint256 fundsWithdrawn = lockOwner.balance - balanceBefore;

        emit EvmUnspoof();
    }
}

With both cheat codes in place we can call withdraw() and have it finish successfully.