Dwarves
Memo
Type ESC to close search bar

Using Foundry for EVM smart contract development

Introduction

Foundry is a blazing-fast, all-in-one toolkit built by developers and for developers. Crafted with the speed of Rust, Foundry streamlines your entire workflow:

To help everyone to adopt Foundry in your next projects and start forging the future of decentralized applications. This article will walk through the core concepts of Foundry.

Why choosing Foundry?

Streamlined Development Workflow:

Blazing fast performance: Foundry is built with Rust, a language renowned for its performance. This translates to significantly faster compilation and test execution times compared to tools like Truffle or Hardhat, which rely on JavaScript.

Managing Dependencies

Currently, there are two ways of managing dependencies in a Foundry projects

By default, Foundry uses Git Submodules for managing dependencies. However, I prefer Solder for my EVM repository template because it integrates well with forge - A command-line tool included with Foundry.

Toolbox Overview

Foundry offers a powerful suite of tools to resolve our development development needs:

In the scope of this memo, we’ll focus on Forge for constructing a template EVM contract repository.

Showcase Preparation

Let’s create a fresh repository with the following files and folders:

- src
  - contracts
    - IcySwap.sol
  - scripts
    - IcySwap.s.sol
  - test
    - IcySwap.t.sol
- foundry.toml

Folder structure explaination:

Configure Foundry

To make this guide more practical, a step closer to Mainnet deployments, we’ll work on Base Sepolia testnet.

If you want to verify your contracts on Basescan, you must create a Block Explorer API Key and set it in .env file.

BLOCK_EXPLORER_API_KEY=<YOUR_KEY>

Then, insert below code into foundry.toml file

[profile.default]
src = 'contracts'
out = 'out'
script = 'scripts'
libs = ['node_modules', 'dependencies']
test = 'test'
cache_path  = 'cache_forge'

[rpc_endpoints]
base = "https://mainnet.base.org"
base_sepolia = "https://sepolia.base.org"
base_goerli = "https://goerli.base.org"

[etherscan]
base = { key = "${BLOCK_EXPLORER_API_KEY}" }
base_sepolia = { key = "${BLOCK_EXPLORER_API_KEY}" }
base_goerli = { key = "${BLOCK_EXPLORER_API_KEY}" }

Install dependencies

Make sure you have Foundry toolchain installed, if not, please follow this guide to install it.

Soldeer is a Solidity native package manager that helps us to manage dependencies in a more efficient way, just like using npm in Node.js.

Libraries that we’ll use in this tutorial:

Steps to install above dependencies:

1/ Append the following code into existing foundry.toml file:

[dependencies]
"@openzeppelin-contracts" = { version = "5.0.2", url = "https://soldeer-revisions.s3.amazonaws.com/@openzeppelin-contracts/5_0_2_14-03-2024_06:11:59_contracts.zip" }
"@openzeppelin-contracts-upgradeable" = { version = "5.0.2", url = "https://soldeer-revisions.s3.amazonaws.com/@openzeppelin-contracts-upgradeable/5_0_2_14-03-2024_06:12:07_contracts-upgradeable.zip" }
forge-std = { version = "1.9.1", url = "https://soldeer-revisions.s3.amazonaws.com/forge-std/v1_9_1_03-07-2024_14:44:59_forge-std-v1.9.1.zip" }

2/ Run forge soldeer install command to install libraries from [dependencies] section.

3/ Replace all content in remappings.txt file with the following code:

@openzeppelin/contracts=dependencies/@openzeppelin-contracts-5.0.2
@openzeppelin-contracts-upgradeable=dependencies/@openzeppelin-contracts-upgradeable-5.0.2
@forge-std=dependencies/forge-std-1.9.1

Prepare some contracts

In this showcase, we’ll utilize IcySwap contract, let’s create a file called IcySwap.sol in src/contracts folder.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/utils/Pausable.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract IcySwap is Ownable, Pausable, ReentrancyGuard {
    using SafeERC20 for IERC20;

    IERC20 public immutable usdc;
    IERC20 public immutable icy;

    // This conversion is follow usdc decimals: 10**6
    // Let say we want 1 icy equal 2 usdc -> conversion rate should be 2 * 10**6
    uint256 public icyToUsdcConversionRate;

    event Swap(IERC20 indexed fromToken, uint256 indexed fromAmount);
    event ConversionRateChanged(uint256 conversionRate);
    event WithdrawToOwner(IERC20 indexed token, uint256 amount);

    constructor(address initialOwner, IERC20 _usdc, IERC20 _icy, uint256 _conversionRate)
        Ownable(initialOwner)
    {
        usdc = _usdc;
        icy = _icy;
        icyToUsdcConversionRate = _conversionRate;
    }

    // Swap methods
    function swap(uint256 _amountIn) external nonReentrant whenNotPaused {
        uint256 amountOut = (_amountIn * icyToUsdcConversionRate) / (10 ** 18);
        _swap(icy, _amountIn, usdc, amountOut);
        emit Swap(icy, _amountIn);
    }

    // Moderate methods
    function setConversionRate(uint256 _conversionRate) external onlyOwner {
        icyToUsdcConversionRate = _conversionRate;
        emit ConversionRateChanged(_conversionRate);
    }

    function pause() external onlyOwner {
        _pause();
    }

    function unpause() external onlyOwner {
        _unpause();
    }

    function withdrawToOwner(IERC20 _token) external onlyOwner {
        uint256 balance = _token.balanceOf(address(this));
        require(balance > 0, "contract has no balance");
        _token.safeTransfer(msg.sender, balance);
        emit WithdrawToOwner(_token, balance);
    }

    // Internal methods
    function _swap(IERC20 _fromToken, uint256 _fromAmount, IERC20 _toToken, uint256 _toAmount)
        internal
    {
        require(_toToken.balanceOf(address(this)) >= _toAmount, "out of money");
        _fromToken.safeTransferFrom(msg.sender, address(this), _fromAmount);
        _toToken.safeTransfer(msg.sender, _toAmount);
    }
}

Next, we’ll create 2 sample ERC20 tokens to test our swapping logic.

//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.24;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract IcyToken is ERC20 {
    uint256 constant _initial_supply = 1000000000 * (10 ** 18);

    constructor() ERC20("IcyToken", "ICY") {
        _mint(msg.sender, _initial_supply);
    }

    function mint(address to, uint256 amount) public {
        _mint(to, amount);
    }
}
//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.24;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract UsdcToken is ERC20 {
    uint256 constant _initial_supply = 1000000000 * (10 ** 18);

    constructor() ERC20("UsdcToken", "USDC") {
        _mint(msg.sender, _initial_supply);
    }

    function mint(address to, uint256 amount) public {
        _mint(to, amount);
    }
}

The fun part begins

In this part, we’ll use forge-std library which provides a set of helpful Cheatcodes to:

Testing IcySwap contract

Create a file called IcySwap.t.sol in src/test folder.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.24;

import "@forge-std/src/Test.sol";
import "@forge-std/src/Vm.sol";
import "@forge-std/src/console2.sol";
import "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol";
import "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";

import "../contracts/IcySwap.sol";
import "../contracts/ICY.sol";
import "../contracts/USDC.sol";

contract StreamPointsTest is Test {
    IcySwap internal icySwap;
    IcyToken internal icy;
    UsdcToken internal usdc;
    
    address internal user;
    address internal icySwapOwner;
    address internal ICY_ADDRESS;
    address internal USDC_ADDRESS;

    function setUp() public virtual {
        user = address(1);
        icySwapOwner = address(2);

        icy = new IcyToken();
        usdc = new UsdcToken();

        vm.startPrank(icySwapOwner);
        icySwap = new IcySwap(
            IERC20(address(usdc)),
            IERC20(address(icy)),
            2 * 10 ** 6
        );
        vm.stopPrank();
    }

    function test_swap() public virtual {
        vm.startPrank(user);
        
        // prepare balance
        icy.mint(user, 150 * 10 ** 18);
        usdc.mint(address(icySwap), 150000 * 10 ** 18);
        icy.approve(address(icySwap), type(uint256).max);
        
        // start swap
        icySwap.swap(100 * 10 ** 18);
        
        vm.stopPrank();
        assertEq(
            icy.balanceOf(address(icySwap)),
            100 * 10 ** 18,
            "failed to swap"
        );
    }

    function test_setConversionRate() public virtual {
        vm.prank(icySwapOwner);
        icySwap.setConversionRate(3 * 10 ** 6);
        assertEq(
            icySwap.icyToUsdcConversionRate(),
            3 * 10 ** 6,
            "failed to set conversion rate"
        );
    }

    function test_withdrawToOwner() public virtual {
        vm.startPrank(icySwapOwner);

        icy.mint(address(icySwap), 150 * 10 ** 18);
        icySwap.withdrawToOwner(icy);

        vm.stopPrank();
        assertEq(
            icySwap.icy().balanceOf(address(icySwap)),
            0,
            "failed to withdraw to owner"
        );
    }

    function test_RevertWhen_CallerIsNotOwner() public {
        vm.expectRevert();
        vm.prank(user);
        icySwap.setConversionRate(3 * 10 ** 6);
    }
}

Explaination

1/ In setUp() function, we deploy:

2/ In each test function, we also use cheatcode vm.startPrank(user) to start a transaction from user account to interact with IcySwap contract. Then make assertions to check the expected results using provided methods from forge-std library.

Run the test

You can run the test with traces by using the following command:

forge test -vvvv

Traces is a feature that allows you to see the internal calls of a transaction. It’s useful for debugging and understanding how a contract works.

Deploying IcySwap contract to Base Sepolia testnet

Before deploying IcySwap contract to Base Sepolia testnet, we need to have following environment variables in .env file:

WALLET_PRIVATE_KEY=<your_wallet_private_key>
BLOCK_EXPLORER_API_KEY=<your_block_explorer_api_key_from_basescan>

Create a file called IcySwap.s.sol in src/scripts folder with the following content:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import { Script, console2 } from "@forge-std/src/Script.sol";
import "../contracts/IcySwap.sol";

contract IcySwapScript is Script {
    function setUp() public { }

    function run() public {
        uint256 privateKey = vm.envUint("WALLET_PRIVATE_KEY");
        address ICY_ADDRESS= 0x78a3f816a8e26af8c09F6Da3995Ee19bd69bf7fF;
        address USDC_ADDRESS = 0x036CbD53842c5426634e7929541eC2318f3dCF7e;
        vm.startBroadcast(privateKey);
        
        IcySwap icySwap = new IcySwap(IERC20(USDC_ADDRESS), IERC20(ICY_ADDRESS), 2 * 10**6);
        
        vm.stopBroadcast();
        console2.log("IcySwap address: ", address(icySwap));
    }
}

In above script:

Now, we can run the deployment script to deploy & verify our contract, by running:

forge script scripts/IcySwap.s.sol:IcySwapScript --broadcast --verify --rpc-url base_sepolia

And our IcySwap contract will be deployed to Base Sepolia testnet & will automatically be verified.

Other Usages

Foundry is not just be here to resolve our common tasks like compiling, testing and deploying smart contracts. It also provides a lot of other features that can be used to enhance our development workflow. One of them is Fork testing.

Fork testing is like a time machine that allows us to test our contracts on a forked mainnet and move to a specific block for testing.

I found an interesting repository that use Foundry to reproduce a lot of DeFi hacked incidents in the past - It’s DeFiHackLabs.

References