本文使用 Foundry 框架,实现了 FundMe 合约的创建,部署与测试的全流程
FundMe 合约实现的功能如下:
-
募资功能:
- 用户可以通过
fund()
函数向合约发送 ETH。 - 合约要求每次募资的金额不少于 5 USD(使用 Chainlink 预言机进行价格转换)。
- 记录每个资助者的地址和资助金额。
- 用户可以通过
-
提款功能:
- 只有合约所有者可以提取合约中的所有资金。
- 提供了两个提款函数:
withdraw()
和cheaperWithdraw()
,后者在处理大量资助者时更节省 gas。 - 提款后会重置所有资助者的记录。
-
所有权管理:
- 使用
onlyOwner
修饰符确保只有合约所有者可以执行某些操作。
- 使用
-
价格转换:
- 使用 Chainlink 预言机获取 ETH 到 USD 的实时价格。
-
辅助功能:
- 提供了多个 getter 函数来查询合约状态,如获取版本、所有者地址、价格预言机地址等。
-
回退功能:
-
包含
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
,就可以编译成功了,代码目录结构如下
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); } }
作者:加密鲸拓
版权:此文章版权归 加密鲸拓 所有,如有转载,请注明出处!