Testing smart contracts in Foundry (part 3)

tyk1 And tyk2). I assume that you understand the basic theory and you don’t need to dwell on it too much. Let’s create a simple example proxy contract (CounterV1.sol):

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

import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

/**
 * Создаём абсолютно тривиальный контракт, который может
 * доставать одну переменную из хранилища
 * и возвращать одно константное значение
 */
contract CounterV1 is Initializable, OwnableUpgradeable, UUPSUpgradeable {
    uint256 internal number;

    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers();
    }

    function initialize() public initializer {
        __Ownable_init(); //делаем transferOwnership на msg.sender
        __UUPSUpgradeable_init(); // Ничего не делаем :)
    }

    function getNumber() external view returns (uint256) {
        return number;
    }

    function version() external pure returns (uint256) {
        return 1;
    }

    /**
     * Данная функция является обязательной, так как она объявлена в
     * абстрактном классе UUPSUpgradeable, но не определена
     * Здесь нам нужно лишь указать, какие ограничения мы ставим на
     * возможность обновлять наш контракт
     * В нашем случае используем onlyOwner
     * @param newImplementation адрес нового проксируемого адреса
     */
    function _authorizeUpgrade(address newImplementation) internal override onlyOwner {}
}

CounterV2.sol:

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

import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";

contract CounterV2 is Initializable, OwnableUpgradeable, UUPSUpgradeable {
    uint256 internal value;

    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers();
    }

    function initialize() public initializer {
        __Ownable_init();
        __UUPSUpgradeable_init();
    }

    /**
     * Добавляем новую функцию, чтобы проверять корректность
     * работы апргрейда
     */
    function increment() public {
        value++;
    }

    function getValue() public view returns (uint256) {
        return value;
    }

    /**
     * Меняем версию на актуальную
     */
    function version() public pure returns (uint256) {
        return 2;
    }

    function _authorizeUpgrade(address newImplementation) internal override onlyOwner {}
}

So, we have two almost identical contracts, which differ in two functions and name. Both of them are connected to updatable proxy contracts.
Our task:

  • Write a script to deploy the contract and connect a proxy

  • Write a script to upgrade a contract to V2

  • Test the operation of these scripts and contracts in general

So, in the script folder we create our first script – DeployCounter.s.sol:

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

//Данный контракт отвечает за имплементацию скриптов, он обязательно должен наследоваться
// в любом скрипте
import {Script} from "forge-std/Script.sol";
import {CounterV1} from "../src/CounterV1.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";

/**
 * Контракты по структур практически ничем не отличаются от тестов
 */
contract DeployCounter is Script {
    /**
     * Задача функции run() максимально проста:
     * Задеплоить наш контракт
     * Она будет запускаться автоматически при вызове
     * скрипта, что-то вроде конструктора
     */
    function run() external returns (address) {
        address proxy = deployCounter();
        return proxy;
    }

    /**
     * Здесь мы встречаем интересный метод: vm.startBroadcast()
     * Данный метод позволяет контракту создать
     * настоящую транзакцию он-чейн
     * Здесь не будут работать чит-коды и транзакция будет "как настоящая"
     * В данном случае мы хотим, чтобы настоящей транзакцией у нас задеплоился
     * контракт и прокси к нему
     */
    function deployCounter() public returns (address) {
        vm.startBroadcast();
        //Деплоим контракт первой версии
        CounterV1 counter = new CounterV1();
        //Определяем селектор функции инициализации с нужными аргументами
        //В нашем случае аргументов в функции нет, поэтому ()
        bytes memory data = abi.encodeCall(CounterV1.initialize, ());
        //Деплоим прокси-контракт указывая адрес имплементации и данные
        //об инициализирующей функции
        ERC1967Proxy proxy = new ERC1967Proxy(address(counter), data);
        vm.stopBroadcast();
        return address(proxy);
    }
}

Hurray, the deployment script is ready! All that remains is to write a script for the upgrade. However, this script should already know which contract to update. On the one hand, you can hardcode the address of the deployed proxy in the upgrade script, but on the other hand, in large projects this will take a lot of time and is not at all our way 🙂

Foundry-devops

When we run some script, Foundry saves all information about deployments in the appropriate folder, separating calls from each network. Let’s imagine a tool that parses this information (finds the address of the last deployed proxy) and displays it in the upgrade script, this way we will get rid of the hard code and achieve maximum automation. That’s exactly what the team did Cyfrin and made a very convenient tool for Foundry – foundry-devops. Let’s evaluate his work:

Install foundry-devops:

$ forge install Cyfrin/foundry-devops@0.0.11 --no-commit

Using it, we write a script to update the proxy (UpgradeCounter.s.sol):

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

import {Script, console} from "forge-std/Script.sol";
import {CounterV1} from "../src/CounterV1.sol";
import {CounterV2} from "../src/CounterV2.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {Helper} from "./Helper.s.sol";

//Импортируем данный контракт, он поможет нам в работе с недавно задеплоенными контрактами
import {DevOpsTools} from "lib/foundry-devops/src/DevOpsTools.sol";

/**
 * Мы создаём контракт, у которого будет лишь одна функция:
 * Обновлять наш проксируемый контракт
 */
contract UpgradeCounter is Helper {
    function run() external returns (address) {
        // Метод get_most_recent_deployment()
        // позволяет достать адрес последнего задеплоенного контракта
        // по заданным требованиям: Название контракта и id цепи,
        // где данный контракт деплоился
        address mostRecentlyDeployedProxy = DevOpsTools.get_most_recent_deployment("ERC1967Proxy", block.chainid);

        //Мы хотим по-настоящему задеплоить новую версию контракта,
        //поэтому startBroadcast()
        vm.startBroadcast();
        CounterV2 newCounter = new CounterV2();
        vm.stopBroadcast();
        //Обновляем контракт
        address proxy = upgradeCounter(mostRecentlyDeployedProxy, address(newCounter));

        return proxy;
    }

    function upgradeCounter(address proxyAddress, address newCounter) public returns (address) {
        vm.startBroadcast();
        //payable - это важный параметр, не забываем про него
        //(нюанс проки-контрактов)
        CounterV1 proxy = CounterV1(payable(proxyAddress));
        proxy.upgradeTo(address(newCounter));
        vm.stopBroadcast();
        return address(proxy);
    }
}

Hooray! Now we can run the test, but before that we should test it. We already know how to write tests, we won’t find anything new here except for small tricks related to proxy contracts (CounterTest.t.sol):

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.19;

import {DeployCounter} from "../script/DeployCounter.s.sol";
import {UpgradeCounter} from "../script/UpgradeCounter.s.sol";
import {Test, console} from "forge-std/Test.sol";
import {CounterV1} from "../src/CounterV1.sol";
import {CounterV2} from "../src/CounterV2.sol";
import {Helper} from "../script/Helper.s.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

/**
 * Очень важно тестировать работу не только основных контрактов,
 * но и контрактов-скриптов, ведь они тоже будут принимать участие
 * в создании реальных транзакций
 * В рамках этого теста мы проверим работу скриптов на деплой и апгрейд
 * контрактов
 */
contract DeployAndUpgradeTest is Test {
    DeployCounter public deployCounter;
    UpgradeCounter public upgradeCounter;

    function setUp() public {
        deployCounter = new DeployCounter();
        upgradeCounter = new UpgradeCounter();
    }

    /**
     * В этом тесте мы деплоим первую версию нашего контракта
     * и проверяем корректность работы через просмотр параметра
     * version
     */
    function testCounterWorks() public {
        address proxyAddress = deployCounter.deployCounter();
        uint256 expectedValue = 1;
        assertEq(expectedValue, CounterV1(proxyAddress).version());
    }

    /**
     * В этом тесте идёт дополнительная проверка засчёт
     * попытки вызова функции из другой версии
     * В Foundry работа с UUPS максимально тривиальна:
     * Для обращения к прокси мы просто оборачиваем его адрес
     * в интересующий нас контракт, т.е.
     * вся ответсвенность за корректность лежит на нас
     */
    function testDeploymentIsV1() public {
        address proxyAddress = deployCounter.deployCounter();
        vm.expectRevert();
        CounterV2(proxyAddress).increment();
    }

    /**
     * Аналогично здесь мы вызываем функцию upgradeCounter и после этого
     * обращаемся к адресу уже как ко второй версии и убеждаемся, что всё работает
     */
    function testUpgradeWorks() public {
        address proxyAddress = deployCounter.deployCounter();

        CounterV2 Counter2 = new CounterV2();

        address proxy = upgradeCounter.upgradeCounter(proxyAddress, address(Counter2));

        uint256 expectedValue = 2;
        assertEq(expectedValue, CounterV2(proxy).version());

        CounterV2(proxy).increment();
        assertEq(1, CounterV2(proxy).getValue());
    }
}

We run the tests and make sure that the work is correct.

Deploy to testnet

It’s time to turn everything into a real blockchain! Before that, let’s do a few preparatory steps:

  • Create a .env file that will store the environment variables:
    (ATTENTION: Add this file to .gitignore, do not allow this file to go somewhere outside your computer)

SEPOLIA_RPC=
PRIVATE_KEY=
ETHERSCAN_API_KEY=

Great, now we have a private key, RPC and even an API for automated verification!

It’s time to deploy our proxy.

First, let’s connect the environment variables that we created:

$ source .env

After this, we run our script:

$ forge script script/DeployCounter.s.sol:DeployCounter --rpc-url $SEPOLIA_RPC --private-key $PRIVATE_KEY --broadcast --verify --etherscan-api-key $ETHERSCAN_API_KEY -vvvv
  • forge script script/DeployCounter.s.sol:DeployCounter – here we indicate which contract to run as a script, because there can be several contracts in one file

  • --rpc-url $SEPOLIA_RPC – we indicate to Foundry where to contact all on-chain commands

  • --private-key $PRIVATE_KEY – indicate the private key with which you need to sign all transactions

  • --verify --etherscan-api-key $ETHERSCAN_API_KEY – we indicate to Foundry that all contracts must be verified during deployment and by what API key

After running the command, if everything went well, you will see many different logs, two deployed contracts and their verification status. Now you can go to etherscan testnet and look at your contracts.

After you’ve poked around the contract and made sure that this is definitely the first version, let’s update it!

$ forge script script/UpgradeCounter.s.sol:UpgradeCounter --rpc-url $SEPOLIA_RPC --private-key $PRIVATE_KEY --broadcast --verify --etherscan-api-key $ETHERSCAN_API_KEY --ffi -vvvv
  • --ffi flag allows Foundry use external calls to any external contracts. Designed for safety.

    After successfully completing the script, we make sure that the update is correct!

Dealing with errors with Foundry-Devops

In my work experience, I encountered two errors when working with the method get_most_recent_deployment():

  • First error (Error: No contract deployed) was due to missing package jq (description and solution)

  • Second error (‘\r’: command not found) is related to the nuances of WSL (solution – do doc2unix to the executable file (…lib/foundry-devops/src/get_recent_deployment.sh))

Makefile

The commands for running scripts are a bit cumbersome and it would be nice to shorten them. There are special Makefiles for this. For the curious herefor the rest – in this example we will use make as a tool that allows you to run long commands with shorter commands.
Create a file (Makefile) in the project folder:

-include .env

build:; forge build

deploy-sepolia:
	forge script script/DeployCounter.s.sol:DeployCounter --rpc-url $(SEPOLIA_RPC) --private-key $(PRIVATE_KEY) --broadcast --verify --etherscan-api-key $(ETHERSCAN_API_KEY) -vvvv

upgrade-sepolia:
	forge script script/UpgradeCounter.s.sol:UpgradeCounter --rpc-url $(SEPOLIA_RPC) --private-key $(PRIVATE_KEY) --broadcast --verify --etherscan-api-key $(ETHERSCAN_API_KEY) --ffi -vvvv

I think you can understand what’s going on here:

In the first line we include environment variables, and then we declare the commands and the instructions that should be executed by these commands. Now, to run the same deployment script, we just need to do:

$ make deploy-sepolia

Comfortable!

Fork tests (–fork-url)

Sometimes for testing we need up-to-date data from real networks (mainnet, testnet), but we don’t want to waste gas, deploy contracts and check everything separately.
There is an interesting switch in the test run command – (–fork-url), after which you need to specify the RPC address of the desired network
If you specify it, during testing Foundry will be able to access external contracts from another network to obtain up-to-date data. Let’s look at a small example and expand our test:

...
    /**
     * Данная тестовая функция не относится к тестированию прокси-контрактов,
     * однако она очень наглядно демонстрирует работу форков
     * В данном случае мы будем рабоатать с форком тестнета Sepolia
     * Суть работы проста:
     * При работе с форком наши обращения будут идти к RPC-ноде нашего тестнета
     * При этом реально мы ничего не деплоим, а просто читаем информацию
     * Это позволяет тестировать сценарии с продакшна без надобности что-то
     * деплоить или вызывать в реальных транзакциях
     */

    function testForkTotalSupply() public {
        //За пример возьмём такой параметр токенов, как decimals
        //Это может быть абсолютно любой другой параметр
        //Главное, чтобы он хранился в какой-то сети.
        //Нам его нужно прочитать
        uint256 decimals;
        //chainId - id цепи, в которой мы сечас работаем
        // 11155111 - это id от Sepolia
        // различные chainId можно без проблем найти в интернетах
        if (block.chainid == 11155111) {
            console.log("Fork testing!");
            //Если мы работаем с форком сеполии, то мы обращаемся к реально
            //существующему контракту токена ERC20 на тестнете и читаем параметр
            //decimals()
            decimals = ERC20(0x94a9D9AC8a22534E3FaCa9F4e7F2E2cf85d5E4C8).decimals();
            assertEq(decimals, 6);
        } else {
            console.log("Standart testing!");
            //В противном случае нам нужно убедиться, что такого контракта вообще
            //не существует - он даже не задеплоен
            //Хитрость: у адреса есть поле code, где хранится код контракта
            //(в случае, если этот адрес относится к смарт-контракту)
            //В нашем случае он должен быть пустым
            assertEq(address(0x94a9D9AC8a22534E3FaCa9F4e7F2E2cf85d5E4C8).code.length, 0);
        }
    }
...
  

Now let’s update our MakeFile:

...
test-sepolia:
	forge test --fork-url $(SEPOLIA_RPC) -vvv

Run the test with and without a fork and make sure the program works correctly!

Conclusion

Dear friends! Now you have all basic skills of working with Foundry. You can create projects, install dependencies, configure configurations, write tests to check features, events and errors. You can also easily set up your development environment for comfortable work. You know how to manage the balance and change the current block time. You can prepare a project for the release of contracts, write special scripts and test their operation not only on the built-in node, but also on the testnet (for example)! With the help of forks, you can get up-to-date information from another network for testing. And with the help of a Makefile, you can compose large forge commands very conveniently.

You are great guys, keep it up!

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *