【额外分享】How To Test The Smart Contract Of iParking NFT With Foundry

tags: swfLAB

⚠️ Of course this is only my personal thoughts, don't be too serious haha... ⚠️

Final Updated: 2022/3/17


Table of Contents

  • Intro. of the event
  • Cast an eye over the contract
  • What is Foundry!?
  • Testing Time
  • Conclusion
  • Reference

Synchronization Link Tree


Intro.

iParking NFT & What happened?

简单来说我对事件的起因和过程也不是有着什麽深度的了解,主要接收资讯的来源也就是以下两者,在这边我就附上连结让大家自行参考了!

Motivation

我发现好像没有看到人选择以在合约部属「前」会进行「测试」的角度来看这次的事件,而是在错误发生「後」用「肉眼」观察合约来找到问题在哪。讲这些并不是要说任何评论这件事情的好前辈和好夥伴们的不是!真正错的也就只有开发团队,还有没有做好事後处理的 MOD 而已。

固然这次的错误是肉眼清晰可见的,而且水汪汪的大眼睛和大脑某些时候确实是比手打程序码好用许多的哈哈哈哈。

不过 Testing 是我认为成为一个职业工程师一个很重要的槛,我自认为距离一个真正的工程师还有不小的距离,所以想要藉此机会来练习一下用之前陈品大大告诉我的 Foundry 撰写测试!

这篇文章的主轴其实还是我自己学习 Foundry 的小小笔记,嘟嘟房只是一个活例而已 :D

那在开始之前,先回到整个文章最一开始的宣告:

⚠️ Of course this is only my personal thoughts, don't be too serious haha... ⚠️

大家如果发现文中的任何错误或有任何想法,请不吝、大方地告诉我,因为还在学习中,我会无条件接受所有意见和想法的!


Cast an eye over the contract

现在我们参考上述前辈和媒体的资料,大概率可以先把技术层面的问题定位在:

  1. 使用 array 这个资料结构来实作白名单系统
  2. Gas Limit 的设定问题

那是时候来看程序码啦!

iParking NFT Source Code in Etherscan.io

The path of the Contract Inheritance

这边基本上我是顺过去的,我猜嘟嘟房的工程师应该,应该,应该没有特别去改这些继承而来的合约内容吧!如果有的话我先道歉><

%%{init: {'theme': 'forest' } }%%
graph TD;
    library-{Strings, Address}
    interface-IERC721Receiver-->ERC721
    IERC165-->IERC721;
    IERC721-->IERC721Enumerable;
    IERC165-->ERC165;
    IERC721-->IERC721Metadata;
    IERC721Metadata-->ERC721;
    IERC721-->ERC721;
    ERC165-->ERC721;
    Context-->ERC721;
    IERC721Enumerable-->ERC721Enumerable;
    ERC721-->ERC721Enumerable;
    Context--->Ownable;
    ERC721Enumerable-->CarMan;
    Ownable-->CarMan;

但其实 OpenZeppelin 里面有许多用不到的东西(变数、资料结构、宣告等)可以删掉,能藉此来把 Gas Fee 降低。所以我自己在写 Project 的时候都会习惯不要直接继承 Github 上面的内容,而是把需要的东西贴过来一个一个改成想要的样子。

OpenZeppelin 的安全性和便利性是许多人所称许的,可对我们这些科学家/工程师(像我这种菜鸡可能是半个)来说,细细的斟酌一下我们要使用的东西也蛮重要的对吧!

如果自己随便乱改然後合约反而出现 Bug,那确实是拿石头砸自己脚。我就很常这样,哈哈...

不过毕竟工作是有收薪水的,不管是继承函式库还是呼叫 API 都得要更小心。随着我自己更深入地了解这门技术,才发现很多时候程序码不是只有 Copy-Paste 那麽简单。

CarMan

那我们就直接看到继承了历代先祖先烈们的遗产,准备迎来人生曙光的最後主合约吧!

我并不会非常仔细的一一讲解合约里面的每个变数、函式的细节,这边就是去大概摸出合约里面有什麽东西而已。那希望大家随着我文中的引用程序码一起来看看里面到底都写了什麽吧!

最开始都是宣告版本、宣告合约以及继承,进到合约之後先宣告了一些常数:

pragma solidity >=0.7.0 <0.9.0;

contract CarMan is ERC721Enumerable, Ownable {
  using Strings for uint256;

  string public baseURI;
  string public baseExtension = ".json";
  string public notRevealedUri;
  uint256 public cost = 0.5 ether;
  uint256 public maxSupply = 2000;
  uint256 public maxMintAmount = 10;
  uint256 public nftPerAddressLimit = 10;
  uint256 public currentPhaseMintMaxAmount = 110;

  uint32 public publicSaleStart = 1647136800;
  uint32 public preSaleStart = 1646964000;
  uint32 public vipSaleStart = 1646618400;

  bool public publicSalePaused = true;
  bool public preSalePaused = true;
  bool public vipSalePaused = true;

马上就遇到第一个被大家拿出来鞭的小夥伴,array 型态的 whitelistedAddresses

  bool public revealed = false;
  bool public onlyWhitelisted = true;
  address[] whitelistedAddresses;

  mapping(address => uint256) addressMintedBalance;
  mapping(address => uint256) vipMintAmount;

  // addresses to manage this contract
  mapping(address => bool) controllers;

负责接收初始化参数们的建构子,还有指到 NFT Metadata 的 baseURI,如果是存在 IPFS 的话那就是他的 CID:

  constructor(
    string memory _name,
    string memory _symbol,
    string memory _initBaseURI,
    string memory _initNotRevealedUri
  ) ERC721(_name, _symbol) {
    baseURI = _initBaseURI;
    notRevealedUri = _initNotRevealedUri;
  }

  // internal
  function _baseURI() internal view virtual override returns (string memory) {
    return baseURI;
  }

给 VIP 们 mint 的函式:

  // public
  function vipSaleMint(uint256 _mintAmount) public {
    require(_mintAmount > 0, "Mint Amount should be bigger than 0");
    require((!vipSalePaused)&&(vipSaleStart <= block.timestamp), "Not Reach VIP Sale Time");
  
    uint256 supply = totalSupply();
    require(_mintAmount > 0, "need to mint at least 1 NFT");
    require(_mintAmount <= maxMintAmount, "max mint amount per session exceeded");
    require(supply + _mintAmount <= currentPhaseMintMaxAmount, "reach current Phase NFT limit");
    require(supply + _mintAmount <= maxSupply, "max NFT limit exceeded");

    require(vipMintAmount[msg.sender] != 0, "user is not VIP");
    uint256 ownerMintedCount = addressMintedBalance[msg.sender];
    uint256 vipMintCount = vipMintAmount[msg.sender];
 
    require(ownerMintedCount + _mintAmount <= vipMintCount, "max VIP Mint Amount exceeded");
    require(ownerMintedCount + _mintAmount <= nftPerAddressLimit, "max NFT per address exceeded");
    
    for (uint256 i = 1; i <= _mintAmount; i++) {
        addressMintedBalance[msg.sender]++;
      _safeMint(msg.sender, supply + i);
    }
  }

预售用的 mint 函式:

  function preSaleMint(uint256 _mintAmount) public payable {
    require(_mintAmount > 0, "Mint Amount should be bigger than 0");
    require((!preSalePaused)&&(preSaleStart <= block.timestamp), "Not Reach Pre Sale Time");
  
    uint256 supply = totalSupply();
    require(_mintAmount > 0, "need to mint at least 1 NFT");
    require(_mintAmount <= maxMintAmount, "max mint amount per session exceeded");
    require(supply + _mintAmount <= currentPhaseMintMaxAmount, "reach current Phase NFT limit");
    require(supply + _mintAmount <= maxSupply, "max NFT limit exceeded");

    if (msg.sender != owner()) {
        if(onlyWhitelisted == true) {
            require(isWhitelisted(msg.sender), "user is not whitelisted");
            uint256 ownerMintedCount = addressMintedBalance[msg.sender];
            require(ownerMintedCount + _mintAmount <= nftPerAddressLimit, "max NFT per address exceeded");
        }
        require(msg.value >= cost * _mintAmount, "insufficient funds");
    }
    
    for (uint256 i = 1; i <= _mintAmount; i++) {
        addressMintedBalance[msg.sender]++;
      _safeMint(msg.sender, supply + i);
    }
  }

公售用的 mint 函式:

  function publicSaleMint(uint256 _mintAmount) public payable {
    require(_mintAmount > 0, "Mint Amount should be bigger than 0");
    require((!publicSalePaused)&&(publicSaleStart <= block.timestamp), "Not Reach Public Sale Time");
  
    uint256 supply = totalSupply();
    require(_mintAmount > 0, "need to mint at least 1 NFT");
    require(_mintAmount <= maxMintAmount, "max mint amount per session exceeded");
    require(supply + _mintAmount <= currentPhaseMintMaxAmount, "reach current Phase NFT limit");
    require(supply + _mintAmount <= maxSupply, "max NFT limit exceeded");

    if (msg.sender != owner()) {
        if(onlyWhitelisted == true) {
            require(isWhitelisted(msg.sender), "user is not whitelisted");
            uint256 ownerMintedCount = addressMintedBalance[msg.sender];
            require(ownerMintedCount + _mintAmount <= nftPerAddressLimit, "max NFT per address exceeded");
        }
        require(msg.value >= cost * _mintAmount, "insufficient funds");
    }
    
    for (uint256 i = 1; i <= _mintAmount; i++) {
        addressMintedBalance[msg.sender]++;
      _safeMint(msg.sender, supply + i);
    }
  }

判断当前讯息传递者(如果是交易或者 mintmsg.sender 自然是消费者)是否为白名单成员:

  function isWhitelisted(address _user) public view returns (bool) {
    for (uint i = 0; i < whitelistedAddresses.length; i++) {
      if (whitelistedAddresses[i] == _user) {
          return true;
      }
    }
    return false;
  }

  function walletOfOwner(address _owner) public view returns (uint256[] memory)
  {
    uint256 ownerTokenCount = balanceOf(_owner);
    uint256[] memory tokenIds = new uint256[](ownerTokenCount);
    for (uint256 i; i < ownerTokenCount; i++) {
      tokenIds[i] = tokenOfOwnerByIndex(_owner, i);
    }
    return tokenIds;
  }

正常的 NFT 合约有的 tokenURI

  function tokenURI(uint256 tokenId) public view virtual override returns (string memory)
  {
    require(
      _exists(tokenId),
      "ERC721Metadata: URI query for nonexistent token"
    );
    
    if(revealed == false) {
        return notRevealedUri;
    }

    string memory currentBaseURI = _baseURI();
    return bytes(currentBaseURI).length > 0
        ? string(abi.encodePacked(currentBaseURI, tokenId.toString(), baseExtension))
        : "";
  }

关於一些铸造函式的启动 & 关闭条件。

  function publicSaleIsActive() public view returns (bool) {
    return ( (publicSaleStart <= block.timestamp) && (!publicSalePaused) );
  }

  function preSaleIsActive() public view returns (bool) {
    return ( (preSaleStart <= block.timestamp) && (!preSalePaused) );
  }

  function vipSaleIsActive() public view returns (bool) {
    return ( (vipSaleStart <= block.timestamp) && (!vipSalePaused) );
  }

  function checkVIPMintAmount(address _account) public view returns (uint256) {
    return vipMintAmount[_account];
  }

是一些拥有管理权的人们才能呼叫的函式。通常这种函式越多我觉得就越中心化,不过既然一切都是透明且清楚明白的写在程序码上,其实 sign 了就代表我们同意接受这个合约对吧!

  // for controller
  function reveal(bool _state) public {
    require(controllers[msg.sender], "Only controllers can operate this function");
    revealed = _state;
  }
  
  function setNftPerAddressLimit(uint256 _limit) public {
    require(controllers[msg.sender], "Only controllers can operate this function");
    nftPerAddressLimit = _limit;
  }
  
  function setCost(uint256 _newCost) public {
    require(controllers[msg.sender], "Only controllers can operate this function");
    cost = _newCost;
  }

  function setmaxMintAmount(uint256 _newmaxMintAmount) public {
    require(controllers[msg.sender], "Only controllers can operate this function");
    maxMintAmount = _newmaxMintAmount;
  }

  function setcurrentPhaseMintMaxAmount(uint256 _newPhaseAmount) public {
    require(controllers[msg.sender], "Only controllers can operate this function");
    currentPhaseMintMaxAmount = _newPhaseAmount;
  }

  function setPublicSaleStart(uint32 timestamp) public {
    require(controllers[msg.sender], "Only controllers can operate this function");
    publicSaleStart = timestamp;
  }
  
  function setPreSaleStart(uint32 timestamp) public {
    require(controllers[msg.sender], "Only controllers can operate this function");
    preSaleStart = timestamp;
  } 

  function setVIPSaleStart(uint32 timestamp) public {
    require(controllers[msg.sender], "Only controllers can operate this function");
    vipSaleStart = timestamp;
  }

  function setBaseURI(string memory _newBaseURI) public {
    require(controllers[msg.sender], "Only controllers can operate this function");
    baseURI = _newBaseURI;
  }

  function setBaseExtension(string memory _newBaseExtension) public {
    require(controllers[msg.sender], "Only controllers can operate this function");
    baseExtension = _newBaseExtension;
  }
  
  function setNotRevealedURI(string memory _notRevealedURI) public {
    require(controllers[msg.sender], "Only controllers can operate this function");
    notRevealedUri = _notRevealedURI;
  }

  function setPreSalePause(bool _state) public {
    require(controllers[msg.sender], "Only controllers can operate this function");
    preSalePaused = _state;
  }

  function setVIPSalePause(bool _state) public {
    require(controllers[msg.sender], "Only controllers can operate this function");
    vipSalePaused = _state;
  }

  function setVIPMintAmount(address[] memory _accounts, uint256[] memory _amounts) public {
    require(controllers[msg.sender], "Only controllers can operate this function");
    require(_accounts.length == _amounts.length, "accounts and amounts array length mismatch");

    for (uint256 i = 0; i < _accounts.length; ++i) {
      vipMintAmount[_accounts[i]]=_amounts[i];
    }
  }

  function setPublicSalePause(bool _state) public {
    require(controllers[msg.sender], "Only controllers can operate this function");
    publicSalePaused = _state;
  }
  
  function setOnlyWhitelisted(bool _state) public {
    require(controllers[msg.sender], "Only controllers can operate this function");
    onlyWhitelisted = _state;
  }
  
  function whitelistUsers(address[] calldata _users) public {
    require(controllers[msg.sender], "Only controllers can operate this function");
    delete whitelistedAddresses;
    whitelistedAddresses = _users;
  }

控制权,在这个合约里面也算是管理权的人员管理函式:

  //only owner
 
   /**
   * enables an address for management
   * @param controller the address to enable
   */
  function addController(address controller) external onlyOwner {
    controllers[controller] = true;
  }

  /**
   * disables an address for management
   * @param controller the address to disbale
   */
  function removeController(address controller) external onlyOwner {
    controllers[controller] = false;
  }

赚了钱记得要把钱拿出来的 withdraw 函式:

  function withdraw() public onlyOwner {
    (bool success, ) = payable(msg.sender).call{value: address(this).balance}("");
    require(success);
  }
}

What is Foundry!?

Unit Testing of Smart Contract

过往我们在进行 Testing 的时候无非是使用 Hardhat, Truffle, DappTools 等撰写 Javascript/ Typescript 语法的测试,最後搭配 Ganache, Infura 等来服用。其他药物还会包含 ethers.js, mocha, waffle, chai 之类的 blablabla,大家自行体会 :D

但有写过的人可能都知道,在我们的 node_modules 里面应该充满了各式各样的 dependencies,做任何开发之前都要先来一套删套件、重载套件、降版本、环境变数之类的组合拳,也算是够恼人的...

虽然 Javascript 已经是一个人手一把的利器,但身为一个撰写 Solidity 的工程师测试却要用另外一个语言来写,总是会觉得哪里怪怪的(吗)。更不用说 Big Number 这个套件某些时候还会造成一些问题。

不过现在能够用 Solidity 一剂打天下的疫苗出现了,那就是效果快狠准的 Foundry!(掌声欢迎??)

Introduction

Foundry 是一个使用 Rust 建置的开发工具,它自称为以太坊所有开发环境中最快、最有弹性、扩充性最强的一款。连官方的 github 中都自己拿来和知名工具 dapptools 互相比较(理所当然是大胜,不然不会拿出来比)。

Foundry 能够从众多工具中脱颖而出的特点除了快速之外,还有以 Solidity 撰写测试这个特质,待会我们会有机会细细品味的!

Comparison

以下是一些比较基准和相关叙述,翻译於 Foundry 官方文件:

Forge 利用 ethers-solc ,在编译和测试的表现上都有非常快的速度。

Project Forge DappTools Speedup
guni-lev 28.6s 2m36s 5.45x
solmate 6s 46s 7.66x
geb 11s 40s 3.63x
vaults 1.4s 5.5s 3.9x

当我们在测试(tested) 着名的函式库 openzeppelin-contracts 时,Hardhat 耗费了 15.244s 的时间编译,而 Forge 只需要 9.449s (~4s cached)。

What features can use

Foundry 由以下两者组成:

  • Forge: 就像我们平常使用的其他开发工具一样,是一个 Ethereum 的测试框架。
  • Cast:支援多种客户端功能,像是与 EVM 智能合约互动、传递交易、取得链上资讯等,就像一把瑞士刀一样(官方文件写的)。

来自官方的 Foundry 特性:

  • Fast & flexible compilation pipeline
    • Automatic Solidity compiler version detection & installation (under ~/.svm)
    • Incremental compilation & caching: Only changed files are re-compiled
    • Parallel compilation
    • Non-standard directory structures support (e.g. Hardhat repos)
  • Tests are written in Solidity (like in DappTools)
  • Fast fuzz testing with shrinking of inputs & printing of counter-examples
  • Fast remote RPC forking mode, leveraging Rust's async infrastructure like tokio
  • Flexible debug logging
    • Dapptools-style, using DsTest's emitted logs
    • Hardhat-style, using the popular console.sol contract
  • Portable (5-10MB) & easy to install without requiring Nix or any other package manager
  • Fast CI with the Foundry GitHub action.

Download Foundry

如果作业系统是 Linux 或 macOS 最简单的方法就是使用以下方法下载 Foundry:

curl -L https://foundry.paradigm.xyz | bash
foundryup

下载完成之後再执行一次 foundryup 会将 Foundry 更新至最新版本,如果想要返回到指定版本,也可以使用指令 foundryup -v $VERSION

然而我自己是使用 Windows,下载的方式如下。

在下载 Foundry 之前,我们的需要先准备好 Rust 和 Cargo,首先到 rustup.rs 下载 rust,然後执行:

rustup-init

这样就可以同时准备好 Rust 和 Cargo,最後打开 CMD 使用以下指令就可以成功安装 Foundry。

cargo install --git https://github.com/gakonst/foundry --bins --locked

如果在下载过程中像我一样遇到以下错误:

error: linker link.exe not found
  |
  = note: program not found

note: the msvc targets depend on the msvc linker but link.exe was not found

note: please ensure that VS 2013, VS 2015, VS 2017 or VS 2019 was installed with the Visual C++ option

error: could not compile proc-macro2 due to previous error
warning: build failed, waiting for other jobs to finish...
error: failed to compile `foundry-cli v0.1.0 (https://github.com/gakonst/foundry#d66f9d58)`, intermediate artifacts can be found at C:\Users\qazws\AppData\Local\Temp\cargo-installe6Rd6Y

Caused by:
  build failed

那就要下载 Visual Studio 2019 Build tools,选择 C++ Build Tools 然後重开机就可以解决了!下载大小约是 6 GB。

First Foundry Test

首先我们使用 init 初始化一个专案。

$ forge init hello_foundry

进到 hello_foundry 看看初始化之後在资料夹里面出现了什麽:

$ cd hello_foundry
$ tree .
.
├── lib
│   └── ds-test
└── src
    ├── Contract.sol
    └── test
        └── Contract.t.sol

6 directories, 7 files

forge CLI 将会创建两个档案目录:libsrc

  • lib 包含了 testing contract (lib/ds-test/src/test.sol),同时也有其他各式各样测试合约的实作 demo(lib/ds-test/demo/demo.sol)
  • src 放了我们写的智能合约和测试的原始码

编译:

$ forge build
>
[⠰] Compiling...
[⠃] installing solc version "0.8.10"
[⠒] Successfully installed solc 0.8.10
[⠊] Compiling 3 files with 0.8.10
Compiler run successful

进行测试:

$ forge test
>
No files changed, compilation skipped
Running 1 test for ContractTest.json:ContractTest
[PASS] testExample() (gas: 120)

More Foundry Test

$ iParking_foundry\hello_foundry\src\Contract.sol 中我们把合约修改成我们想要撰写的其他合约:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.10;

contract Foo {
  uint256 public x = 1;
  function set(uint256 _x) external {
    x = _x;
  }

  function double() external {
    x = 2 * x;
  }
}

在档案 test/Contract.t.sol 中:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.10;

import "ds-test/test.sol";
import 'src/Contract.sol';

contract FooTest is DSTest {
  Foo foo;

  // The state of the contract gets reset before each
  // test is run, with the `setUp()` function being called
  // each time after deployment. Think of this like a JavaScript
  // `beforeEach` block
  function setUp() public {
    foo = new Foo();
  }

  // A simple unit test
  function testDouble() public {
    require(foo.x() == 1);
    foo.double();
    require(foo.x() == 2);
  }

  // A failing unit test (function name starts with `testFail`)
  function testFailDouble() public {
    require(foo.x() == 1);
    foo.double();
    require(foo.x() == 4);
  }
}

进行测试:

forge test
>
[⠰] Compiling...
[⠒] Compiling 2 files with 0.8.10
Compiler run successful

Running 2 tests for FooTest.json:FooTest
[PASS] testDouble() (gas: 9316)
[PASS] testFailDouble() (gas: 9290)

除了以上我们最熟悉的 require 之外,也可以使用以下方式进行测试:

function testDouble() public {
    assertEq(foo.x(), 1);
    foo.double();
    assertEq(foo.x(), 2);
}

我们还有更多酷炫的 assertions 语法可以用来测试合约,在 lib/ds-test/src/test.sol 中可以找到他们:

  • 逻辑运算 - assertTrue
  • 十进制相等 - assertEqDecimal
  • 大於、小於 - assertGt, assertGe, assertLt, assertLe

More features can use

Foundry 同样也支持 Fuzzing 测试。因为当我们一个一个函式都进行测试时,即便全部都成功 PASS,但在边际测资中其实也很有可能会出现一些问题,导致 Under/Overflow 或其他 RE/ME 之类的错误。

我们在测试函式中增加参数之後,Fuzzing 能够让 Solidity test runner 随机选择大量的参数输入我们的函式。

function testDoubleWithFuzzing(uint256 x) public {
    foo.set(x);
    require(foo.x() == x);
    foo.double();
    require(foo.x() == 2 * x);
}

在以上例子中 fuzzer 会自动地对 x 尝试各种随机数,如果他发现当前输入会导致测试失败,便会回传错误,这时候就可以开始 debug 啦!

进行测试:

$ forge test
>
[⠆] Compiling...
[⠔] Compiling 1 files with 0.8.10
Compiler run successful

Running 3 tests for FooTest.json:FooTest
[PASS] testDouble() (gas: 9384)
[FAIL. Reason: Arithmetic over/underflow. Counterexample: calldata=0xc80b36b68000000000000000000000000000000000000000000000000000000000000000, args=[57896044618658097711785492504343953926634992332820282019728792003956564819968]] testDoubleWithFuzzing(uint256) (runs: 4, μ: 2867, ~: 3823)
[PASS] testFailDouble() (gas: 9290)

Failed tests:
[FAIL. Reason: Arithmetic over/underflow. Counterexample: calldata=0xc80b36b68000000000000000000000000000000000000000000000000000000000000000, args=[57896044618658097711785492504343953926634992332820282019728792003956564819968]] testDoubleWithFuzzing(uint256) (runs: 4, μ: 2867, ~: 3823)

Encountered a total of 1 failing tests, 2 tests succeeded

从以上错误可以发现当参数输入为 57896044618658097711785492504343953926634992332820282019728792003956564819968 之後会出现错误,来到 wolframe 贴上这个数字之後会发现这个数字为 5.789 * 10^76 ~= 2^255

听起来十分合理因为 x 的型态就是 uint256,所以如果要避免程序出现问题,势必要在函式里面增加一些关於型态的异常处理叙述。

未来 Foundry 除了Fuzz Testing 之外,还会支援:

  • Invariant Testing
  • Symbolic Execution
  • Mutation Testing

Give me more information!!

Give me more more more documentation!!!


⚗ Testing Time

Initialization & Preparation

打开一个空资料夹,使用 init 来初始化专案:

$ forge init iParking_foundry

因为我们要继承许多 OpenZeppelin 的合约,所以这边先将其导入到专案的 src 里面。

其实感觉不是要这样子做,但这边如果有正确的做法拜托提供给我 ?

$ cd src
$ tree
>
├─contracts
│  ├─access
│  ├─finance
│  ├─governance
│  │  ├─compatibility
│  │  ├─extensions
│  │  └─utils
│  ├─interfaces
│  ├─metatx
│  ├─mocks
│  │  ├─compound
│  │  ├─ERC165
│  │  ├─UUPS
│  │  └─wizard
│  ├─proxy
│  │  ├─beacon
│  │  ├─ERC1967
│  │  ├─transparent
│  │  └─utils
│  ├─security
│  ├─token
│  │  ├─common
│  │  ├─ERC1155
│  │  │  ├─extensions
│  │  │  ├─presets
│  │  │  └─utils
│  │  ├─ERC20
│  │  │  ├─extensions
│  │  │  ├─presets
│  │  │  └─utils
│  │  ├─ERC721
│  │  │  ├─extensions
│  │  │  ├─presets
│  │  │  └─utils
│  │  └─ERC777
│  │      └─presets
│  └─utils
│      ├─cryptography
│      ├─escrow
│      ├─introspection
│      ├─math
│      └─structs
└─test

$ iParking_foundry\src\Contract.sol 中我们把合约修改成嘟嘟房的 NFT 合约(记得要 import 要继承的列祖列宗们):

// SPDX-License-Identifier: MIT
pragma solidity >=0.7.0 <0.9.0;

import "./contracts/access/Ownable.sol";
import "./contracts/token/ERC721/extensions/ERC721Enumerable.sol";

contract CarMan is ERC721Enumerable, Ownable {

    // skip the contract here...
}

同时也先把 Contract.t.sol 档案中的测试合约准备好:

// SPDX-License-Identifier: MIT
pragma solidity >=0.7.0 <0.9.0;

import "ds-test/test.sol";
import 'src/Contract.sol';

contract CarManTest is DSTest {
  CarMan carman;

  // The state of the contract gets reset before each
  // test is run, with the `setUp()` function being called
  // each time after deployment. Think of this like a JavaScript
  // `beforeEach` block
  function setUp() public {
    carman = new CarMan("CarMan_Metaverse", "CMM", "ipfs://QmYvJEw4LHBpaehH6mYZV9YXC372QSWL4BPFVJvUkKqRCg/", "ipfs://.../");
  }
}

建置并测试专案看编译有没有出现问题:

$ forge test
>
[⠆] Compiling...
[⠘] Compiling 1 files with 0.8.10
Compiler run successful

Unit Testing

目标主要是看 preSaleMint() 这个函式的运作状况,说到底我这边也是属於一种马後炮的行为,因为我也懒得写别的测试哈哈哈哈哈。

废话不多说,直接开测:

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

import "ds-test/test.sol";
import '../Contract.sol';

contract CarManTest is DSTest {
  CarMan carman;
  address DEPLOYER_ADDRESS;
  address[] public temp;
  // skip the code...
}

首先就是先宣告版本,这边我除了把主合约宣告进来之外,来宣告了 DEPLOYER_ADDRESS 来存合约 deployer 的地址;以及 temp 来暂存之後要增长的白名单。

  // The state of the contract gets reset before each
  // test is run, with the `setUp()` function being called
  // each time after deployment. Think of this like a JavaScript
  // `beforeEach` block
  function setUp() public {
    carman = new CarMan("CarMan_Metaverse", "CMM", "ipfs://QmYvJEw4LHBpaehH6mYZV9YXC372QSWL4BPFVJvUkKqRCg/", "ipfs://.../");
    DEPLOYER_ADDRESS = carman.owner();
    carman.addController(DEPLOYER_ADDRESS); // deployer can addController
    carman.setPreSalePause(false); // deployer/controller can setPreSalePause
    for(uint i = 0; i < 800; i++){
      address randomish = address(uint160(uint(keccak256(abi.encodePacked(i, blockhash(block.number))))));
      temp.push(randomish);
    }
    temp.push(DEPLOYER_ADDRESS);
    carman.whitelistUsers(temp);
  }

这段程序码有很多个重点:

一、DEPLOYER_ADDRESS = carman.owner();

这个叙述中我们首先需要知道 msg.sender 是谁,因为在 deploy 合约的时候决定 owner 是谁的方法就是看最一开始的 msg.sender
我们可以从 foundry.toml 以及 foundry.toml Reference 中得到各个环境变数、全域变数的设定档与其预设值是多少。

二、carman.addController(DEPLOYER_ADDRESS);

这是除了阵列存白名单外我觉得最吊诡的地方,那就是我在主合约里面没有看见他们把 owner 在建构子里面就设定为 Controller。然而大部分的功能函式居然都是需要 require(Controller) 而不是使用 onlyOwnermodifier。所以我就在这边帮自己(deployer)宣告了。

三、增长白名单:

for(uint i = 0; i < 800; i++){
  address randomish = address(uint160(uint(keccak256(abi.encodePacked(i, blockhash(block.number))))));
  temp.push(randomish);
}
temp.push(DEPLOYER_ADDRESS);
carman.whitelistUsers(temp);

主要的重点为如何在合约中随机制造帐户地址,然後把他们都加进去 temp 这个阵列,最後再一次 pushwhitelistedAddresses 中。

接下来下一个程序码让我苦恼超级久,因为如果没有实作 _checkOnERC721Received 的话,在直接宣告 _safeMint() 以後会疯狂出现以下错误:

Running 1 test for CarManTest.json:CarManTest
�[31m[FAIL. Reason: ERC721: transfer to non ERC721Receiver implementer]�[0m testDeployerCanMint() (gas: 192214)

Failed tests:
�[31m[FAIL. Reason: ERC721: transfer to non ERC721Receiver implementer]�[0m testDeployerCanMint() (gas: 192214)

Encountered a total of �[31m1�[0m failing tests, �[32m0�[0m tests succeeded

根据我查到的资料 _checkOnERC721Received 有一个 verification logic 存在,如果今天 to 的地址是一个合约而不是 EOA,那就需要实作它的 body,这样才可以在 ERC-721 的介面里面回传正确的 4 bytes hash。

  function onERC721Received(
      address, 
      address, 
      uint256, 
      bytes calldata
  )external pure returns(bytes4) {
      return bytes4(keccak256("onERC721Received(address,address,uint256,bytes)"));
  } 
  /*
  solved reference: https://docs.klaytn.com/smart-contract/sample-contracts/erc-721/1-erc721#3-safetransferfrom-and-transferfrom
  */

第一个测试,这边先试试水温看自己(Deployer)能不能够铸造几个 NFT。

  function testDeployerCanMint(uint x) public {
    assertEq(carman.totalSupply(), 0); // nothing minted before
    if(x > carman.maxMintAmount()){
      carman.preSaleMint(10);
      assertEq(carman.totalSupply(), carman.maxMintAmount());
    }
    else if(x > 0){
      carman.preSaleMint(x);
      assertEq(carman.totalSupply(), x);
    }
  }

第二个测试,这边要使用 Foundry 提供的一个很酷的功能,那就是我们可以把自己的身分转变成其他帐户,藉此来以不同角度测试合约。

首先我们要宣告 CheatCodes 的介面,之後在测试合约里面宣告 cheats。最後只要在我们想要测试的合约里面加上 cheats.prank(address(0)); 就可以把自己的角度转成 address(0)

interface CheatCodes {
  function prank(address) external;
}

contract CarManTest is DSTest {
  CheatCodes cheats = CheatCodes(HEVM_ADDRESS);
    
  // skip the code...
    
  function testFailNotWLMint() public {
    cheats.prank(address(0));
    carman.preSaleMint(10);
  }  
}

进行测试:

$ forge test
>
[⠒] Compiling...
[⠑] Compiling 1 files with 0.8.10
Compiler run successful

Running 3 tests for CarManTest.json:CarManTest
[PASS] testDeployerCanMint(uint256) (runs: 256, μ: 972407, ~: 1198664)
[PASS] testFailNotWLMint() (gas: 2080543)

Gas Report

Foundry 还有一个非常有趣的功能那就是 Gas Report:

$ forge test --gas-report
>
[2K[⠔] Compiling...
No files changed, compilation skipped

Running 2 tests for CarManTest.json:CarManTest
[PASS] testDeployerCanMint(uint256) (runs: 256, μ: 908620, ~: 1198597)
[PASS] testFailNotWLMint() (gas: 2080543)
╭─────────────────┬─────────────────┬──────────┬──────────┬──────────┬─────────╮
│ CarMan contract ┆                 ┆          ┆          ┆          ┆         │
╞═════════════════╪═════════════════╪══════════╪══════════╪══════════╪═════════╡
│ Deployment Cost ┆ Deployment Size ┆          ┆          ┆          ┆         │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤
│ 3269119         ┆ 16173           ┆          ┆          ┆          ┆         │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤
│ Function Name   ┆ min             ┆ avg      ┆ median   ┆ max      ┆ # calls │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤
│ addController   ┆ 22718           ┆ 22718    ┆ 22718    ┆ 22718    ┆ 1       │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤
│ owner           ┆ 444             ┆ 444      ┆ 444      ┆ 444      ┆ 1       │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤
│ preSaleMint     ┆ 2070405         ┆ 2070405  ┆ 2070405  ┆ 2070405  ┆ 1       │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤
│ setPreSalePause ┆ 858             ┆ 858      ┆ 858      ┆ 858      ┆ 1       │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤
│ whitelistUsers  ┆ 17822466        ┆ 17822466 ┆ 17822466 ┆ 17822466 ┆ 1       │
╰─────────────────┴─────────────────┴──────────┴──────────┴──────────┴─────────╯

这个 Gas 果然是消耗惊人,如果要发现这个错误的话,在 foundry.toml 中就需要把 gasLimit 设定成我们想要的大小来控制,或者使用其他办法之类的 :D


Conclusion

Why do I not correct the contract

市面上有许多实作的例子,无论是最基本的 mapping,酷炫的 Merkle Tree(Hash Tree),或甚至是稍微中心化但更易於管理和节省开销的 Backend Signature Whitelist Data Base,其实多查一点资料应该是会选择避免掉用 array 这个资料结构来存白名单的。

不过因为我是一个菜鸟,我认识的所有会写 Solidity 的人里面有 99% 都比自己强哈哈哈哈哈,所以修正智能合约这种事情我先躲开好了!做 Auditing 的大师不少,还是让他们来吧 :D

My point of view

固然使用 array 和遍历这样子的演算法来寻找白名单能称得上是诡谲,不过从 Web2 跨足到 Web3 的公司们,其实有很多东西都需要学习。

而且在这个才刚开始要蓬勃发展的产业与技术中,许多人也都还在摸索最好的技术模式、商业模式。也许给他们一个改善的机会,累积越来越多这样的先例某种程度上也能够让这个产业迈向更好的发展!

那希望我可以多多多多多多增进自己写测试的实力,毕竟写出一个会动的合约并不难,但要写出一个不会出错的难如登天阿!


Reference

Foundry

ERC-721 Contract Unit Testing


<<:  WSL2, VM, Dual Boot, Proxmox怎麽选?

>>:  VC++6 最小化 Win32 Application

Vue.js 从零开始:var,let,const 傻傻分不清楚

撰写程序的时候,几乎都是用let去撰写,只知道他比var还要安全,不容易出错,更详细的原因,就没办法...

谁温暖了资安部-赛後感想

谢谢iT邦帮忙,今年又办了iT邦帮忙铁人赛! 今年,比较特别,在看到官方的开赛日期、最後发文日期後,...

Android Studio初学笔记-Day22-ToolBar

Toolbar Toolbar是对於顶端横幅栏的设计,不同於之前介面设计的元件,对於整个程序来讲可以...

Day11 - 套用 Tag Helper - 复杂型别 object + collection

本篇 Controller、ViewModel 跟 Day08 范例差不多 依照 View 的差异,...

[Tableau Public] day 8:尝试制作第一张视觉仪表板

第八天,一早去接种了疫苗,趁副作用还没出来前,赶紧补上今天的进度吧! 我们先开启前几次的 covid...