Foundry实战 - FundMe

本文使用 Foundry 框架,实现了 FundMe 合约的创建,部署与测试的全流程

FundMe 合约实现的功能如下:

  1. 募资功能:

    • 用户可以通过 fund() 函数向合约发送 ETH。
    • 合约要求每次募资的金额不少于 5 USD(使用 Chainlink 预言机进行价格转换)。
    • 记录每个资助者的地址和资助金额。
  2. 提款功能:

    • 只有合约所有者可以提取合约中的所有资金。
    • 提供了两个提款函数:withdraw() 和 cheaperWithdraw(),后者在处理大量资助者时更节省 gas。
    • 提款后会重置所有资助者的记录。
  3. 所有权管理:

    • 使用 onlyOwner 修饰符确保只有合约所有者可以执行某些操作。
  4. 价格转换:

    • 使用 Chainlink 预言机获取 ETH 到 USD 的实时价格。
  5. 辅助功能:

    • 提供了多个 getter 函数来查询合约状态,如获取版本、所有者地址、价格预言机地址等。
  6. 回退功能:

    • 包含 receive() 和 fallback() 函数,允许合约直接接收 ETH 并自动调用 fund() 函数。

FundMe

首先新建一个文件夹,执行forge init。然后将之前的代码复制到 src 目录下

执行 forge install smartcontractkit/[email protected] --no-commit 安装依赖

在 foundry.toml 文件中新增如下配置

remappings = [
    "@chainlink/contracts/=lib/chainlink-brownie-contracts/contracts/",
]

FundMe & PriceConverter

下面这些优化点可以提高代码效率,更加规范:

  • 将价格查询合约地址抽象出来,当成一个构造函数的变量传入进去,方便部署和测试
  • 将没必要的 public 全部换成 private (更省gas)
  • 改成 private 之后补充相应的 get 函数
  • 规范变量命名,storage 类型的变量都以 s 开头
  • 提供一个 cheaperWithdraw 方法,将 length 提升为 memory 变量,避免重复访问 storage,节省 gas

FundMe代码如下

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

import {PriceConverter} from "./PriceConverter.sol";
import {AggregatorV3Interface} from "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";

error FundMe__NotOwner();

contract FundMe {
    using PriceConverter for uint256;
    uint256 public constant MINIMUM_USD = 5e18;

    address[] private s_funders;
    mapping(address funder => uint256 amountFunded)
        private s_addressToAmountFunded;

    address private immutable i_owner;
    AggregatorV3Interface private s_priceFeed;

    constructor(address priceFeed) {
        i_owner = msg.sender;
        s_priceFeed = AggregatorV3Interface(priceFeed);
    }

    function fund() public payable {
        require(
            msg.value.getConversionRate(s_priceFeed) >= MINIMUM_USD,
            "didn't send enough eth"
        ); // 1e18 = 1ETH
        s_funders.push(msg.sender);
        s_addressToAmountFunded[msg.sender] += msg.value;
    }

    function cheaperWithdraw() public onlyOwner {
        uint256 fundersLength = s_funders.length;
        for (
            uint256 funderIndex = 0;
            funderIndex < fundersLength;
            funderIndex++
        ) {
            address funder = s_funders[funderIndex];
            s_addressToAmountFunded[funder] = 0;
        }

        // reset the array
        s_funders = new address[](0);

        // call
        (bool callSuccess, ) = payable(msg.sender).call{
            value: address(this).balance
        }("");
        require(callSuccess, "Call failed");
    }

    function withdraw() public onlyOwner {
        for (
            uint256 funderIndex = 0;
            funderIndex < s_funders.length;
            funderIndex++
        ) {
            address funder = s_funders[funderIndex];
            s_addressToAmountFunded[funder] = 0;
        }
        // reset the array
        s_funders = new address[](0);

        // call
        (bool callSuccess, ) = payable(msg.sender).call{
            value: address(this).balance
        }("");
        require(callSuccess, "Call failed");
    }

    modifier onlyOwner() {
        // require(msg.sender == i_owner, "Sender is not owner!");
        if (msg.sender != i_owner) {
            revert FundMe__NotOwner();
        }
        _;
    }

    receive() external payable {
        fund();
    }

    fallback() external payable {
        fund();
    }

    function getVersion() public view returns (uint256) {
        return s_priceFeed.version();
    }

    function getOwner() public view returns (address) {
        return i_owner;
    }

    function getPriceFeed() public view returns (AggregatorV3Interface) {
        return s_priceFeed;
    }

    function getFunder(uint256 index) public view returns (address) {
        return s_funders[index];
    }

    function getAddressToAmountFunded(
        address fundingAddress
    ) public view returns (uint256) {
        return s_addressToAmountFunded[fundingAddress];
    }
}

PriceConverter代码如下

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

import {AggregatorV3Interface} from "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";

library PriceConverter {
    function getPrice(
        AggregatorV3Interface priceFeed
    ) internal view returns (uint256) {
        // The answer on the ETH / USD feed uses 8 decimal places,
        (, int256 price, , , ) = priceFeed.latestRoundData();
        // Price of ETH in terms of USD
        return uint256(price * 1e10);
    }

    function getConversionRate(
        uint256 ethAmount,
        AggregatorV3Interface priceFeed
    ) internal view returns (uint256) {
        uint256 ethPrice = getPrice(priceFeed);
        uint256 ethAmountInUsd = (ethPrice * ethAmount) / 1e18;
        return ethAmountInUsd;
    }
}

这时执行 forge build ,就可以编译成功了,代码目录结构如下

code

Script

在 script 目录下新建一个 HelperConfig.s.sol,用于根据环境配置对应的 ETH/USD price feed address

逻辑跟简单,就是根据部署的 chainid 来判断,然后返回对应的配置即可,代码如下(MockV3Aggregator相关逻辑在下面)

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

import {Script} from "forge-std/Script.sol";
import {MockV3Aggregator} from "../test/mocks/MockV3Aggregator.sol";

contract HelperConfig is Script {
    NetworkConfig public activeNetworkConfig;
    uint8 public constant DECIMALS = 8;
    int256 public constant INITIAL_PRICE = 2000e8;

    struct NetworkConfig {
        address priceFeed; // ETH/USD price feed address
    }

    constructor() {
        if (block.chainid == 11155111) {
            // Sepolia Testnet
            activeNetworkConfig = getSepoliaEthConfig();
        } else {
            activeNetworkConfig = getOrCreateAnvilEthConfig();
        }
    }

    function getSepoliaEthConfig() public pure returns (NetworkConfig memory) {
        // prive feed address
        NetworkConfig memory sepoliaConfig = NetworkConfig({
            priceFeed: 0x694AA1769357215DE4FAC081bf1f309aDC325306
        });
        return sepoliaConfig;
    }

    function getOrCreateAnvilEthConfig() public returns (NetworkConfig memory) {
        // if price feed is already set, return it
        if (activeNetworkConfig.priceFeed != address(0)) {
            return activeNetworkConfig;
        }

        vm.startBroadcast();
        MockV3Aggregator mockPriceFeed = new MockV3Aggregator(
            DECIMALS,
            INITIAL_PRICE
        );
        vm.stopBroadcast();

        NetworkConfig memory anvilConfig = NetworkConfig({
            priceFeed: address(mockPriceFeed)
        });

        return anvilConfig;
    }
}

因为 Foundry 默认的 anvil 环境是没有 price feed 数据的,所以需要我们自己模拟

只需要在 test/mocks 目录下新建一个文件 MockV3Aggregator.sol

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

/**
 * @title MockV3Aggregator
 * @notice Based on the FluxAggregator contract
 * @notice Use this contract when you need to test
 * other contract's ability to read data from an
 * aggregator contract, but how the aggregator got
 * its answer is unimportant
 */
contract MockV3Aggregator {
    uint256 public constant version = 4;

    uint8 public decimals;
    int256 public latestAnswer;
    uint256 public latestTimestamp;
    uint256 public latestRound;

    mapping(uint256 => int256) public getAnswer;
    mapping(uint256 => uint256) public getTimestamp;
    mapping(uint256 => uint256) private getStartedAt;

    constructor(uint8 _decimals, int256 _initialAnswer) {
        decimals = _decimals;
        updateAnswer(_initialAnswer);
    }

    function updateAnswer(int256 _answer) public {
        latestAnswer = _answer;
        latestTimestamp = block.timestamp;
        latestRound++;
        getAnswer[latestRound] = _answer;
        getTimestamp[latestRound] = block.timestamp;
        getStartedAt[latestRound] = block.timestamp;
    }

    function updateRoundData(
        uint80 _roundId,
        int256 _answer,
        uint256 _timestamp,
        uint256 _startedAt
    ) public {
        latestRound = _roundId;
        latestAnswer = _answer;
        latestTimestamp = _timestamp;
        getAnswer[latestRound] = _answer;
        getTimestamp[latestRound] = _timestamp;
        getStartedAt[latestRound] = _startedAt;
    }

    function getRoundData(
        uint80 _roundId
    )
        external
        view
        returns (
            uint80 roundId,
            int256 answer,
            uint256 startedAt,
            uint256 updatedAt,
            uint80 answeredInRound
        )
    {
        return (
            _roundId,
            getAnswer[_roundId],
            getStartedAt[_roundId],
            getTimestamp[_roundId],
            _roundId
        );
    }

    function latestRoundData()
        external
        view
        returns (
            uint80 roundId,
            int256 answer,
            uint256 startedAt,
            uint256 updatedAt,
            uint80 answeredInRound
        )
    {
        return (
            uint80(latestRound),
            getAnswer[latestRound],
            getStartedAt[latestRound],
            getTimestamp[latestRound],
            uint80(latestRound)
        );
    }

    function description() external pure returns (string memory) {
        return "v0.6/test/mock/MockV3Aggregator.sol";
    }
}

再新建一个 DeployFundMe.s.sol ,用于部署合约

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

import {Script} from "forge-std/Script.sol";
import {FundMe} from "../src/FundMe.sol";
import {HelperConfig} from "./HelperConfig.s.sol";

contract DeployFundMe is Script {
    function run() external returns (FundMe) {
        // Before startBroadcast, it's not a real transaction
        HelperConfig helperConfig = new HelperConfig();
        address ethUsdPriceFeed = helperConfig.activeNetworkConfig();

        // After startBroadcast, it's a real transaction
        vm.startBroadcast();
        FundMe fundme = new FundMe(ethUsdPriceFeed);
        vm.stopBroadcast();
        return fundme;
    }
}

到此,我们就能直接部署并测试合约代码了,脚本会根据 rpc_url 对应的 chainid 返回对应的配置并部署

Test

因为合约一旦发布就很难变更,所以合约测试是非常重要的

Foundry 也有对应的测试框架,我们在 test 目录下新建一个 FundMeTest.t.sol 文件,这里面的合约会继承 Test

这个 contract 下的每个函数,都是一个单元测试(setUp除外)

每个单元测试之间互不干扰,即上一个测试执行完了,环境就全部清空,下一个测试运行时重新开始

而 setUp 函数会在每个测试用例运行之前被调用,一般用来初始化合约

下面的代码中提到了 deal 这个作弊码,这是用来给地址充钱的,不然新地址余额为空,不能发送交易

(为了避免疑惑,这里提前说明,在 anvil 链上,是不需要消耗 gas 费的,所有操作的 gas 都为零)

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

import {Test, console} from "forge-std/Test.sol";
import {FundMe} from "../src/FundMe.sol";
import {DeployFundMe} from "../script/DeployFundMe.s.sol";

contract FundMeTest is Test {
    FundMe fundMe;

    address USER = makeAddr("user");
    uint256 constant SEND_VALUE = 0.1 ether;
    uint256 constant STARTING_BALANCE = 100 ether;
    uint256 public constant GAS_PRICE = 1;

    // setUp always runs before each test
    function setUp() external {
        // fundMe = new FundMe(0x694AA1769357215DE4FAC081bf1f309aDC325306);
        DeployFundMe deployFundMe = new DeployFundMe();
        fundMe = deployFundMe.run();
        vm.deal(USER, STARTING_BALANCE);
    }
}

下面是两个简单的测试案例

    function testPriceFeedVersionIsAccurate() public {
        uint256 version = fundMe.getVersion();
        console.log("Price Feed Version: ", version);
        assertEq(version, 4);
    }

    function testFundFailsWithoutEnoughETH() public {
        vm.expectRevert(); // next line, should revert!
        fundMe.fund(); // send 0 value
    }

在测试环节,有许多场景需要指定发起交易的地址,这里就涉及到作弊码的概念

参考资料:https://learnblockchain.cn/docs/foundry/i18n/zh/cheatcodes/index.html

prank

假如我们需要指定接下来的交易由某个特定的地址发起,我们就可以用 prank 关键词

https://learnblockchain.cn/docs/foundry/i18n/zh/cheatcodes/prank.html

    function testFundUpdateFundedDataStructure() public {
        vm.prank(USER); // The next TX will be send by USER
        fundMe.fund{value: SEND_VALUE}();

        uint256 amountFunded = fundMe.getAddressToAmountFunded(USER);
        assertEq(amountFunded, SEND_VALUE);
    }

modifier

由于每个测试之间没有关联,但是很多测试都需要执行同样的代码(比如发送一笔交易)

那这里就可以活用之前提到的 modifier 关键词,以此来减少重复代码

    modifier funded() {
        vm.prank(USER);
        fundMe.fund{value: SEND_VALUE}();
        _;
    }

    function testAddsFunderToArrayOfFunders() public funded {
        address funder = fundMe.getFunder(0);
        assertEq(funder, USER);
    }

expectRevert

为了验证所有可能的逻辑,有时候我们会期望测试不通过

    function testOnlyOwnerCanWithdraw() public funded {
        vm.prank(USER);
        vm.expectRevert(); // next line, should revert!
        fundMe.withdraw();
    }

一些逻辑稍微复杂点的测试

下面有提到 hoax 这个作弊码,hoax 就是 prank 和 deal 的集合,新建一个地址并往里面充钱

    function testWithDrawWithASingleFunder() public funded {
        // Arrange
        uint256 startingOwnerBalance = fundMe.getOwner().balance;
        uint256 startingFundMeBalance = address(fundMe).balance;

        // Act
        vm.prank(fundMe.getOwner());
        fundMe.withdraw();

        // Assert
        uint256 endingOwnerBalance = fundMe.getOwner().balance;
        uint256 endingFundMeBalance = address(fundMe).balance;
        assertEq(endingFundMeBalance, 0);
        assertEq(
            startingFundMeBalance + startingOwnerBalance,
            endingOwnerBalance
        );
    }

    function testWithdrawFromMultipleFunders() public funded {
        // Arrange
        uint160 numberOfFunders = 10;
        for (uint160 i = 1; i < numberOfFunders; i++) {
            // we get hoax from stdcheats
            // hoax = prank + deal
            hoax(address(i), SEND_VALUE);
            fundMe.fund{value: SEND_VALUE}();
        }

        uint256 startingOwnerBalance = fundMe.getOwner().balance;
        uint256 startingFundMeBalance = address(fundMe).balance;

        // Act
        vm.startPrank(fundMe.getOwner());
        fundMe.withdraw();
        vm.stopPrank();

        // Assert
        assert(address(fundMe).balance == 0);
        assert(
            startingFundMeBalance + startingOwnerBalance ==
                fundMe.getOwner().balance
        );
    }

集成测试

在单元测试中,每个测试之间是没有关联的,只是为了验证各个组件的正确性。

而在集成测试中,需要验证多个组件之间的交互,甚至多个合约之间的交互。

所以这里我们用到了 foundry-devops 这个库,它可以根据合约名和 chainid ,从命令行中找到上次部署的合约地址,从而根据这个合约地址来做集成测试,以此来确保集成测试中的环境一致

仓库地址:https://github.com/Cyfrin/foundry-devops

安装命令:forge install Cyfrin/foundry-devops --no-commit

因为这个库是从之前执行的命令中找到合约地址的,所以我们需要在 foundry.toml 中新增如下配置

ffi = true

开启 ffi 是为了让 solidity 能够直接在设备上执行命令(一般建议不要开启)

关于 ffi 的参考资料:https://book.getfoundry.sh/cheatcodes/ffi?highlight=ffi#ffi

代码部分如下

在 script 目录下新建一个 Interactions.s.sol

这个文件创建了两个合约

FundFundMe:找到之前部署的 FuneMe 合约地址,并向其转款

WithdrawFundMe:找到之前部署的 FuneMe 合约地址,并发起提现

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

import {Script, console} from "forge-std/Script.sol";
import {DevOpsTools} from "foundry-devops/src/DevOpsTools.sol";
import {FundMe} from "../src/FundMe.sol";

// this script is for funding in fundme contract
contract FundFundMe is Script {
    uint256 constant SEND_VALUE = 0.01 ether;

    function fundFundMe(address mostRecentlyDeployed) public {
        vm.startBroadcast();
        FundMe(payable(mostRecentlyDeployed)).fund{value: SEND_VALUE}();
        vm.stopBroadcast();
        console.log("Funded FundMe contract with ", SEND_VALUE);
    }

    function run() external {
        address mostRecentlyDeployed = DevOpsTools.get_most_recent_deployment(
            "FundMe",
            block.chainid
        );
        fundFundMe(mostRecentlyDeployed);
    }
}

contract WithdrawFundMe is Script {
    function withdrawFundMe(address mostRecentlyDeployed) public {
        vm.startBroadcast();
        FundMe(payable(mostRecentlyDeployed)).withdraw();
        vm.stopBroadcast();
        console.log("Withdraw FundMe balance!");
    }

    function run() external {
        address mostRecentlyDeployed = DevOpsTools.get_most_recent_deployment(
            "FundMe",
            block.chainid
        );
        withdrawFundMe(mostRecentlyDeployed);
    }
}

在 test 下新建一个 intergration 目录,在这个目录下新建一个 IntergrationsTest.t.sol

测试代码如下

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

import {DeployFundMe} from "../../script/DeployFundMe.s.sol";
import {FundFundMe, WithdrawFundMe} from "../../script/Interactions.s.sol";
import {FundMe} from "../../src/FundMe.sol";
import {HelperConfig} from "../../script/HelperConfig.s.sol";
import {Test, console} from "forge-std/Test.sol";

contract InteractionsTest is Test {
    FundMe public fundMe;
    uint256 public constant SEND_VALUE = 0.1 ether; // just a value to make sure we are sending enough!
    uint256 public constant STARTING_USER_BALANCE = 10 ether;
    uint256 public constant GAS_PRICE = 1;
    address public constant USER = address(1);

    function setUp() external {
        DeployFundMe deployer = new DeployFundMe();
        fundMe = deployer.run();
        vm.deal(USER, STARTING_USER_BALANCE);
    }

    function testUserCanFundAndOwnerWithdraw() public {
        FundFundMe fundFundMe = new FundFundMe();
        fundFundMe.fundFundMe(address(fundMe));

        WithdrawFundMe withdrawFundMe = new WithdrawFundMe();
        withdrawFundMe.withdrawFundMe(address(fundMe));

        assert(address(fundMe).balance == 0);
    }
}

作者:加密鲸拓

版权:此文章版权归 加密鲸拓 所有,如有转载,请注明出处!