Foundry

开始

使用 Foundry 框架来开发或测试智能合约时,第一步需要在空白文件夹中使用命令:

forge init

然后,Foundry 会在项目目录下生成一些文件,这些文件有一些是用于测试合约的 Solidity 脚本库。

注意,在 Foundry 框架中,所有的“真正的”合约要放在 src目录中,脚本合约要放在script目录中,测试合约要放在test目录中。同时,Foundry 还规定,脚本合约的后缀名为.s.sol,测试合约的后缀名为.t.sol

使用 Foundry 进行编译

在 Foundry 框架中:

forge compile(或者 forge build)

使用该命令后,会在项目目录里生成两个新的目录:out·cache

在本地区块链上进行合约测试

  • Foundry 内置 anvil
  • Ganache

使用 Foundry 对合约进行部署

在命令行

forge create "合约名称" --rpc-url "URL" --interactive (私钥,测试时可以,注意堤防私钥泄露)

部署合约后,会在工作目录中生成两个新的目录

使用 solidity 脚本部署

使用 Solidity 脚本部署合约,需要在script目录中新建一个.sol文件,以下面的例子举例:

需要部署的文件为SimpleStorage.sol(位于 src目录下)合约代码如下:

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.19;

// pragma solidity ^0.8.0;
// pragma solidity >=0.8.0 <0.9.0;

contract SimpleStorage {
    uint256 myFavoriteNumber;

    struct Person {
        uint256 favoriteNumber;
        string name;
    }
    // uint256[] public anArray;
    Person[] public listOfPeople;

    mapping(string => uint256) public nameToFavoriteNumber;

    function store(uint256 _favoriteNumber) public {
        myFavoriteNumber = _favoriteNumber;
    }

    function retrieve() public view returns (uint256) {
        return myFavoriteNumber;
    }

    function addPerson(string memory _name, uint256 _favoriteNumber) public {
        listOfPeople.push(Person(_favoriteNumber, _name));
        nameToFavoriteNumber[_name] = _favoriteNumber;
    }
}

Solidity 脚本书写

而要使用 Solidity 脚本部署这个合约,可以在script目录下新建一个合约(我将其命名为DeploySimpleStorage.s.sol注意,后缀名最好为.s.sol,因为这是一个脚本文件。DeploySimpleStorage.s.sol代码如下:

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.18;

import {Script} from "forge-std/Script.sol";
import {SimpleStorage} from "../src/SimpleStorage.sol";

contract DeploySimpleStorage is Script {
    function run() external returns(SimpleStorage) {
        vm.startBroadcast(); 
        // 要部署的合约代码
        SimpleStorage simplestprage = new SimpleStorage();
        vm.stopBroadcast();
        return (simplestprage);
    }
}

上述脚本代码中,vm.startBroadcast()vm.stopBroadcast()是一块出现的,中间的部分即为要部署的合约,可以 new 关键字新建合约,同时,记得使用 return 关键字返回部署的合约的地址。

Solidity 脚本运行

要运行上述脚本,需要在 bash 中使用下面的指令:

forge script script/DeploySimpleStorage.s.sol

这样会将合约部署在一个临时的 anvil 链上。如果想要模拟真正的交易,需要传递一个 rpc-url:

forge script script/DeploySimpleStorage.s.sol --rpc-url "rpc" 

上面的指令会模拟部署到 anvil 链上,同时还创建了一个新的目录,他会存放我们之前部署的信息

如果想要广播这个消息,需要加上--broadcast

forge script script/DeploySimpleStorage.s.sol --rpc-url "rpc" --broadcast --private-key "key"

上述的命令中DeploySimpleStorage.s.sol可以替换为其他部署合约的脚本。

使用上述命令部署合约后同样在目录 broadcast下,存储部署这个合约时的信息。不同的是,没使用--broadcast选项的信息会存储在broadcast的子文件夹dry-run下。

使用 thiedweb 部署合约

npx thirdweb deploy

cast命令

cast --to-base将十六进制转换为十进制

cast --to-base 0x714c2 dec

返回结果:464066

cast send 发送签名和交易(调用非只读函数)

Foundry 内置工具 cast 中有一个可以使用的命令send用于签名和发布交易:

cast send "合约地址" "函数签名" "参数值" --rpc-url "rpc" --private-key "privatekey"

比如:

cast send 0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0 "store(uint256)" 123 --rpc-url $rpc --private-key $key0

cast call 从区块中读取数据(调用只读函数)

call 命令类似于 Remix 中的蓝色按钮,只能调用函数,不能发送交易

cast call "合约地址" "函数名/变量名" --rpc-url "rpc"
cast call 0xf0a005d0697a6518a09A378f371f0058a69F5c3B "retrieve()" --rpc-url $rpc

加快与合约的交互方法(仅在开发调试时使用,实际生产中不可取)

在文件根目录新建一个.env文件。该文件格式可类似为:

key0=0x517e11ba636227ffbdb3a4c8e2f2db11c9cc7fef4c290f6c88f28f76f5c9f789
rpc=http://127.0.0.1:7545
key1=0xc4c1bae53fb65f8549a2034f8dcc92f5be0840981912db5701435def7167099a

创建这个文件后,打开.gitignore文件,确保.env文件在其中被忽略。

.env中,我们可以设置“环境变量”。设置后,在终端运行:

source .env

在测试网上模拟在主网中的操作

部署合约到测试网

要将合约部署到测试网上,需要有测试网的 rpc ,这里使用 Alchemy 来构建一个在 Sepolia 测试网上的项目,便会得到一个我们自己的在 Sepolia 测试网上的 rpc,同时,我们部署了合约后,可以在 Alchemy 面板上查看我们部署的合约。

而部署的过程和刚刚在本地部署的过程没有很大的区别,唯一的区别就是需要把参数中的rpcprivate-key换为我们在 Alchemy 生成的 rpc ,自己Meta Mask钱包中拥有 Sepolia ETH 的私钥。注意,如果要把自己的 Meta Mask 钱包的私钥放进 .env 环境变量之前,确保这个私钥在 ETH 主网上没有余额!!!

合约验证

手动验证

在 Etherscan 或者其他区块链浏览器上进行验证。

image-20231014153755212

点击上方的合约验证

image-20231014153831492

按照上述要求进行验证

image-20231014153952813

继续操作,即可验证成功。

验证成功后,可以在 Etherscan 上看到我们的合约源码,并且可以在区块链浏览器上与合约进行交互

清理项目

当我们的项目基本上完成后,我们就可以对我们的项目进行最后一步的加工

格式化 solidity 代码

可以通过在终端使用forge fmt命令对 solidity 代码进行格式化。**这是一个可以格式化我们所有的 solidity 代码的命令。

创建 README.md

创建 README.md 文件对我们的项目进行描述,可以包含:

项目信息、使用说明、联系方式等。

Foundry 调试

在 Foundry 中进行测试时,如果不指定链,Foundry 会自动启动一个 anvil 链。

解决在 Remix 导入合约报错的问题

在 Remix 编译器中,可以通过使用"@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol"这种方式导入合约,但是使用其他编译器时编译器会报错,原因在于:在 Remix 收纳柜使用@chainlink/contracts时,Remix 会自动在 npm 包存储库中查找,而 Foundry 需要明确地知道从哪里获取依赖项。所以,我们要直接从 Github 下载,我们使用forge install命令安装这个依赖项:

forge install smartcontractkit/chainlink-brownie-contracts

在大多数情况下,还需要添加--no-commit命令,同时还可以使用@来选择版本,所以最终的代码为:

forge install smartcontractkit/chainlink-brownie-contracts@0.6.1 --no-commit

接下来要在 foundry.toml文件中创建一个叫做remappings的重映射:

profile.dedfault下面创建一个叫做remappings的新部分:

[profile.default]
src = "src"
out = "out"
libs = ["lib"]
remappings = ["@chainlink/contracts/=lib/chainlink-brownie-contracts/contracts"]


# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options

最好的做法是,在命名错误时,使用合约名称加上两个下划线,这样当遇到这个错误时,可以很容易地知道是哪个合约出了问题

forge test 参数

forge test # 进行所有的测试
forge test -vv # 改变可见性,v越多可见性越高
# 如果我们在测试的时候没有为其提供 RPC URL 时,他会启动一个全新的空白 anvil 链进行测试
fork test --fork-url "url" 
# 上述命令中,anvil 被启动,但他会使用 Sepolia RPC URL 的副本来运行,他会启动一个 anvil 链,并模拟所有的交易,就像它们在 Sepolia 链上运行一样,他会假装部署和读取 Sepolia 链上的内容,而不是一个完全空白的链
--fail-fast 快速失败,第一次失败后停止测试
--gas-report 打印 gas 报告
--match-test <正则表达式>,仅运行与指定正则表达式匹配的测试函数,(别名-mt)
--no-match-test <正则表达式>,仅运行与指定正则表达式不匹配的合约测试,(别名-nmt)
--match-contract <正则表达式>,仅运行与指定正则表达式匹配的合约测试,(别名-mc)
--NO-match-contract <正则表达式>,仅运行与指定正则表达式不匹配的合约测试,(别名-nmc)
-f
--fork-url <URL> 通过远程端点获取状态而不是从一个空的状态
--fork-block-number <> 通过远程从特定块号获取状态端点。
-v EVM 的详细程度。
多次传递以增加详细程度(例如 -v、-vv、-vvv)。
详细程度:
- 2:打印所有测试的日志
- 3:打印失败测试的执行跟踪
- 4:打印所有测试的执行跟踪以及设置跟踪对于失败的测试
- 5:打印所有测试的执行和设置跟踪
forge coverage : 可以查看实际测试套件中实际测试了多少代码

基本测试

在 Foundry 框架中,有一些内置库和工具可以十分方便的测试合约,所以在编写一个测试合约的时候大致框架如下:

pragma solidity ^0.8.0;

import {Test} from "forge-std/Test.sol"

contract Name is Test {
	function setUp() external {}
}
    // 我们可以进行以下四种测试:
    // 1. 单元测试
    //   - 测试代码中的一个特定部分
    // 2. 集成测试
    //   - 测试多个不同合约是否能够正确地协同工作
    // 3. Fork测试(可以认为时单元/集成测试的一部分)
    //   - 模拟在真实环境中对代码进行测试
    // 4. Staging 测试
    //   - 将代码部署到测试网或者主网之类的环境中,并在这个真实环境中运行所有测试以确保事情能够正常工作

简单的测试

在所有的测试中,第一件都是设置 setUp() 函数,在这里部署合约,然后就可以写需要进行的测试,举例:

pragma solidity ^0.8.0;

import {Test} from "forge-std/Test.sol"

contract Name is Test {
	uint number = 1;
	
	function setUp() external {
		number = 2;
	}
	
	function testDemo() public {
		assertEq(number, 2);
	}
}

测试结果:

image-20231016145433438

console.log 测试、调试

另一种测试和调试的方法是使用console.log,Test是一个很大的库,还有另一个库叫做 console

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import {Test, console} from "forge-std/Test.sol";

contract FundMeTest is Test {

    uint number = 1;

    function setUp() external {
        number = 2;
    }

    function testDemo() external {
        console.log(number);
        console.log("Hello!");
        assertEq(number, 2);
    }

}

测试结果:

image-20231016145651779

对 FundMe 合约进行简单的调试

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

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

contract FundMeTest is Test {
    FundMe fundMe; // 定义 FundMe 合约变量

    function setUp() external {
        fundMe = new FundMe(); // 实例化一个 FundMe 合约 fundme
    }

    function testMininumDollarIsFive() public {
        assertEq(fundMe.MINIMUM_USD(), 5e18);
        // 对 FundMe 合约进行调试
    }
}

检验合约所有者是否为 msg.sender

可以这样写:

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

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

contract FundMeTest is Test {
    FundMe fundMe; // 定义 FundMe 合约变量

    function setUp() external {
        fundMe = new FundMe(); // 实例化一个 FundMe 合约 fundme
    }

    function testMininumDollarIsFive() public {
        assertEq(fundMe.MINIMUM_USD(), 5e18);
        // 对 FundMe 合约进行调试
    }
    
    function testOwnerIsMsgSender() public {
    	assertEq(fundMe.i_owner(), msg.sender);
    }
}

经过 forge 的调试,我们发现msg.sender不是合约的所有者,我们可以对上面的程序进行进一步的调整:

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

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

contract FundMeTest is Test {
    FundMe fundMe; // 定义 FundMe 合约变量

    function setUp() external {
        fundMe = new FundMe(); // 实例化一个 FundMe 合约 fundme
    }

    function testMininumDollarIsFive() public {
        assertEq(fundMe.MINIMUM_USD(), 5e18);
        // 对 FundMe 合约进行调试
    }
    
    function testOwnerIsMsgSender() public {
    	console.log(fundMe.i_owner());
    	console.log(msg.sender);
    	assertEq(fundMe.i_owner(), msg.sender);
    }
}

image-20231016151352815

经过测试发现:我 -> FundMeTest -> FundMe,我们不应该检验我们和是否为所有者,而是检测 FundMeTest 是不是所有者,所以将上面的testOwnerIsMsgSender函数修改为下面的形式即可:

function testOwnerIsMsgSender() public {
	console.log(fundMe.i_owner());
	console.log(msg.sender);
	assertEq(fundMe.i_owner(), address(this));
}

检验版本(使用—fork-url 模拟实际链上的内容)

function testPriceFeedVersionIsAccurate() public { // 
    uint256 version = fundMe.getVersion();
    assertEq(version, 4);
}

对这个函数进行测试时会报错,原因在于,FundMe 合约中的参数地址是 Sepolia 链上的地址,而我们在 Foundry 上测试其实是部署在一个新建的 anvil 链上,并在经过测试和自动关闭 anvil 链的。所以,这里就需要用到了 --fork-url选项,详见forge test参数

然后,执行下面的bash命令会发现程序成功进行:

forge test --match-test  testPriceFeedVersionIsAccurate --fork-url https://eth-sepolia.g.alchemy.com/v2/htfp6AGYmR20uiC5S-rZfe7IZvuxManb

当我们使用 Frok-URL 时,我们会模拟实际链上的内容,这是一个在实际网络上轻松测试合约的一个好方法。但是,这些 Fork 的缺点是会对 Alchemy 节点进行大量的 API 调用,这可能会产生更多的费用,所以尽可能多的进行测试而不使用 Fork 是很重要的,当然除了一些必须在 Fork 上运行的测试。或者使用模拟来确保我们有足够的覆盖率来测试所有的合约。

测试一个是否发生回滚

有些时候我们需要交易回滚,所以我们可以通过Foundry来书写测试脚本来检查这个合约是否回滚:

function testFundFailsWithoutEnoughETH() public{
    vm.expectRevert(); // 这个函数的下一行应该回滚
    // 等价于: assert(This tx fails/reverts)
    uint256 cat = 1;
}

在 bash 中 对上面这个函数进行测试:

$ forge test --mt testFundFailsWithoutEnoughETH
[] Compiling...
[] Compiling 1 files with 0.8.21
[] Solc 0.8.21 finished in 670.25ms
Compiler run successful with warnings:
Warning (2072): Unused local variable.
  --> test/FundeMeTest.t.sol:44:9:
   |
44 |         uint256 cat = 1;
   |         ^^^^^^^^^^^


Running 1 test for test/FundeMeTest.t.sol:FundMeTest
[FAIL. Reason: Call did not revert as expected] testFundFailsWithoutEnoughETH() (gas: 3047)
Test result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 1.32ms
 
Ran 1 test suites: 0 tests passed, 1 failed, 0 skipped (1 total tests)

Failing tests:
Encountered 1 failing test in test/FundeMeTest.t.sol:FundMeTest
[FAIL. Reason: Call did not revert as expected] testFundFailsWithoutEnoughETH() (gas: 3047)

Encountered a total of 1 failing tests, 0 tests succeeded

如上所示,上面的测试失败,因为vm.expectRevert()的下一行语句没有发生回滚。

function testFundFailsWithoutEnoughETH() public{
    vm.expectRevert(); // the next line, should revert!
    // 等价于: assert(This tx fails/reverts)
    fundMe.fund(); // send 0 value,根据fundme合约中的fund函数,会发生回滚。
}

结果如下:

$ forge test --mt testFundFailsWithoutEnoughETH
[] Compiling...
[] Compiling 1 files with 0.8.21
[] Solc 0.8.21 finished in 727.78ms
Compiler run successful!

Running 1 test for test/FundeMeTest.t.sol:FundMeTest
[PASS] testFundFailsWithoutEnoughETH() (gas: 13305)
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.92ms
 
Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)

交易发生回滚,是我们预期的结果。所以测试通过。

Alchemy

Alchemy 简述

Alchemy 是一个提供非托管的 Web3 开发者工具和平台,为 Web3 和 Web2 应用程序提供服务。

Alchemy 可以理解为是一个 Web3 的云服务器(类似于 华为云,阿里云),它提供了 API SDK 和库,可以让开发者有更好的体验。

它通过三点实现:

  • 超级节点:
    他是一个基于我的节点的负载均衡器,可以确保始终从区块链中获取到最新的可用数据。在超级节点上,Alchemy 构建了公告 API (Announced APIs:公告 API 是一组 API,可以方便地从区块链中提取数据)

开始构建

登录 Alchemy 官网,选择登录,进入页面就可以看到自己账号的所有项目(前提是已经创建完了)。 点击创建应用程序即可创建新的项目

创建成功后,点击我们创建的项目即可进入应用程序特定的仪表盘

Alchemy 是一个在实际开发 Web3 应用时很实用的一个工具,其余内容可查看 Alchemy 官方文档

源码

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

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

error FundMe_NotOwner();

contract FundMe {
    using PriceConverter for uint256;

    mapping(address => uint256) public addressToAmountFunded;
    address[] public funders;

    // Could we make this constant?  /* hint: no! We should make it immutable! */
    address public /* immutable */ i_owner;
    uint256 public constant MINIMUM_USD = 5 * 10 ** 18;
    
    constructor() {
        i_owner = msg.sender;
    }

    function fund() public payable {
        require(msg.value.getConversionRate() >= MINIMUM_USD, "You need to spend more ETH!");
        // require(PriceConverter.getConversionRate(msg.value) >= MINIMUM_USD, "You need to spend more ETH!");
        addressToAmountFunded[msg.sender] += msg.value;
        funders.push(msg.sender);
    }
    
    function getVersion() public view returns (uint256){
        AggregatorV3Interface priceFeed = AggregatorV3Interface(0x694AA1769357215DE4FAC081bf1f309aDC325306);
        return priceFeed.version();
    }
    
    modifier onlyOwner {
        // require(msg.sender == owner);
        if (msg.sender != i_owner) revert FundMe_NotOwner();
        _;
    }
    
    function withdraw() public onlyOwner {
        for (uint256 funderIndex=0; funderIndex < funders.length; funderIndex++){
            address funder = funders[funderIndex];
            addressToAmountFunded[funder] = 0;
        }
        funders = new address[](0);
        // // transfer
        // payable(msg.sender).transfer(address(this).balance);
        
        // // send
        // bool sendSuccess = payable(msg.sender).send(address(this).balance);
        // require(sendSuccess, "Send failed");

        // call
        (bool callSuccess, ) = payable(msg.sender).call{value: address(this).balance}("");
        require(callSuccess, "Call failed");
    }
    // Explainer from: https://solidity-by-example.org/fallback/
    // Ether is sent to contract
    //      is msg.data empty?
    //          /   \ 
    //         yes  no
    //         /     \
    //    receive()?  fallback() 
    //     /   \ 
    //   yes   no
    //  /        \
    //receive()  fallback()

    fallback() external payable {
        fund();
    }

    receive() external payable {
        fund();
    }

}

// Concepts we didn't cover yet (will cover in later sections)
// 1. Enum
// 2. Events
// 3. Try / Catch
// 4. Function Selector
// 5. abi.encode / decode
// 6. Hash with keccak256
// 7. Yul / Assembly
// PriceConverter.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

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

// Why is this a library and not abstract?
// Why not an interface?
library PriceConverter {
    // We could make this public, but then we'd have to deploy it
    function getPrice() internal view returns (uint256) {
        // Sepolia ETH / USD Address
        // https://docs.chain.link/data-feeds/price-feeds/addresses
        AggregatorV3Interface priceFeed = AggregatorV3Interface(
            0x694AA1769357215DE4FAC081bf1f309aDC325306
        );
        (, int256 answer, , , ) = priceFeed.latestRoundData();
        // ETH/USD rate in 18 digit
        return uint256(answer * 10000000000);
    }

    // 1000000000
    function getConversionRate(
        uint256 ethAmount
    ) internal view returns (uint256) {
        uint256 ethPrice = getPrice();
        uint256 ethAmountInUsd = (ethPrice * ethAmount) / 1000000000000000000;
        // the actual ETH/USD conversion rate, after adjusting the extra 0s.
        return ethAmountInUsd;
    }
}
// FundMeTest.t.sol
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

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

contract FundMeTest is Test {
    FundMe fundMe;

    function setUp() external {
        fundMe = new FundMe(0x694AA1769357215DE4FAC081bf1f309aDC325306);
    }

    function testMininumDollarIsFive() public {
        assertEq(fundMe.MINIMUM_USD(), 5e18);
    }

    function testOwnerIsMsgSender() public {
        console.log(fundMe.i_owner());
        console.log(msg.sender);
        assertEq(fundMe.i_owner(), address(this));
    }
    // 我们可以进行以下四种测试:
    // 1. 单元测试
    //   - 测试代码中的一个特定部分
    // 2. 集成测试
    //   - 测试多个不同合约是否能够正确地协同工作
    // 3. Fork测试(可以认为时单元/集成测试的一部分)
    //   - 模拟在真实环境中对代码进行测试
    // 4. Staging 测试
    //   - 将代码部署到测试网或者主网之类的环境中,并在这个真实环境中运行所有测试以确保事情能够正常工作
    function testPriceFeedVersionIsAccurate() public { // 
        uint256 version = fundMe.getVersion();
        assertEq(version, 4);
    }
}