node版本:v16.14.2 yarn版本:1.22.15
1、hardhat配置
(1)创建hardhat-fund-me-fcc
添加hardhatyarn add --dev hardhat
等等
生成目录包括:node_modules
,packages.json
,yarn.lock
yarn hardhat
(2)创建.env
文件,.gitignore
文件
.npmignore
可以在项目推送为NPM
包的时候忽略一些文件 -- del
.prettierrc
和.prettierignore
:分号
.solhint.json
和.solhintignore
solhint
运行一个程序的过程,该程序会分析代码是否存在潜在错误,还会做一些格式化,eslint
是对JavaScript代码进行lint方法,solhint
是对Solidity代码进行lint方法
官网:https://github.com/protofire/solhint
yarn add solhint
版本:solhint": "^4.1.1
简单的小例子:
在合约中定义了uint256 someVar
,这并不好,我们要准确的说出变量的可见性
初始化一个配置文件:
solhint --init
配置文件:
{ "extends": "solhint:recommended", "plugins": [], "rules": { "avoid-suicide": "error", "avoid-sha3": "warn" } }
运行:yarn solhint --init contracts/*.sol
✖ 11 problems (0 errors, 11 warnings)
(3)复制FundMe.sol
和PriceConverter.sol
告诉hardhat
在哪里获取,npmjs.com
从npm
包管理工具下载@chainlink/contracts
yarn add --dev @chainlink/contracts
import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";//这样hardhat就知道从哪里导入了 // 在remix中我们只需要从npm或者Github中直接导入"@chainlink/contracts"
运行yarn hardhat compile
会看到目录新生成artifacts和cache两个目录
(4)部署代码
脚本部署,当使用原生ethers
或只使用hardhat
进行工作时,跟踪所有部署是一件非常棘手的事情,如果只使用一个部署脚本,就不会把我们的部署保存在文件中,可能会使测试和部署脚本之间无法完全兼容
使用hardhat-deploy
yarn add --dev hardhat-deploy
,一个用于可复制的部署及简便测试的hardhat插件
一旦安装完成,在hardhat.config.js
中require
进来
在config
中添加:
require("@nomicfoundation/hardhat-toolbox"); require("hardhat-deploy"); // else require("solidity-coverage") require("hardhat-gas-reporter")
之后就可以删除我们的deploy脚本了
yarn hardhat
会看到:
deploy task是我们部署合约的时候主要用到的task,不在scripts中编写部署脚本
mkdir deploy
是许多hardhat-deploy
模块部署代码和编写脚本的地方,在脚本中要使用ethers.js
,添加:
hardhat-deploy-ethers
yarn add --dev hardhat @nomiclabs/hardhat-ethers@npm:hardhat-deploy-ethers ethers
"@nomiclabs/hardhat-ethers": "npm:hardhat-deploy-ethers",
我们在deploy中添加的所欲脚本都会在执行yarn hardhat deploy
时运行
创建01-deploy-fund-me.js
sample
也可以:
// 从hre中提取出我们需要的函数和变量 // JavaScript语法糖 module.exports = async ({ getNamedAccounts, deployments }) => { // 用于部署智能合约和记录日志 const { deploy, log } = deployments; // 通过getNamedAccounts获取名为deployer的账户对象,这个账户通常是部署者 const { deployer } = await getNamedAccounts; // 获取当前网络的链ID const chainId = network.config.chainId }
require("@nomicfoundation/hardhat-toolbox"); require("hardhat-deploy"); /** @type import('hardhat/config').HardhatUserConfig */ const SEPOLIA_RPC_URL = process.env.SEPOLIA_RPC_URL || "https://eth-rinkeby"; const PRIVATE_KEY = process.env.PRIVATE_KEY || "0xkey"; const ETHERSCAN_API_KEY = process.env.ETHERSCAN_API_KEY || "key"; const COINMARKETCAP_API_KEY = process.env.COINMARKETCAP_API_KEY || "key"; const { ProxyAgent, setGlobalDispatcher } = require("undici"); const proxyAgent = new ProxyAgent("http://192.168.2.15:7890"); setGlobalDispatcher(proxyAgent); module.exports = { solidity: { compilers: [ { version: "0.8.19" }, { version: "0.6.6" } ] }, defaultNetwork: "hardhat", networks: { sepolia: { url: SEPOLIA_RPC_URL, accounts: [PRIVATE_KEY],//在包含私钥的列表中,假如有私钥1,私钥2,私钥3,很容易让人混淆,可以为每一个账户取一个名字 chainId: 11155111, timeout: 60000, blockConfirmations: 6, }, localhost: { url: "http://127.0.0.1:8545/", chainId: 31337, } }, etherscan: { // Your API key for Etherscan // Obtain one at https://etherscan.io/ apiKey: { sepolia: "DZS4JIXINN56WBDCTADA2G92IUCRFJI5JI", } }, sourcify: { enabled: true, apiUrl: "https://sourcify.dev/server", browserUrl: "https://repo.sourcify.dev", }, gasReporter: { //测试时运行 enabled: true, outputFile: "gas-report.txt", noColors: true, }, namedAccounts: { deployer: { default: 0, }, user: { default: 1, }, }, };
Mocking & helper-hardhat-config
部署到Rinkeby
测试网或者主网上很慢,将部署测试网作为本地测试的最后一步,理论情况下,我们应该先部署到本地网络,但是在PriceConverter.sol
中,有一处硬编码处
AggregatorV3Interface priceFeed = AggregatorV3Interface( 0x694AA1769357215DE4FAC081bf1f309aDC325306 );
如果想在hardhat network
上运行,hardhat network
是一个空白的区块链,每次脚本执行完成之后都会被销毁,在本地节点上工作,喂价合约是不存在的,其中的某些代码无法更新数据
(1)通过分叉一个区块链,可以在其中保留硬编码内容
(2)或者使用mocks
来完成所有的操作
什么是mocking
?
主要用于单元测试,受测对象可能依赖于其他复杂的对象,为了隔离某些对象的行为,使用mocks对象来替换其他对象,模拟只是对象的行为。
创建一个模拟真实对象行为的对象(虚拟的喂价合约,在本地能够使用并进行控制)
切换链的时候会发生什么 ?不同网络的喂价会有差异,把喂价合约地址参数化(当使用本地主机或者hardhat-network需要使用到mock)
对FundMe.sol
进行重构:
AggregatorV3Interface public priceFeed; // 接收价格数据的地址,根据所在链传递ETH/USD的喂价地址 constructor(address priceFeedAddress) { priceFeed = AggregatorV3Interface(priceFeedAddress); i_owner = msg.sender; } require( msg.value.getConversionRate(priceFeed) > minimumUsd, "didn't send enough" );
// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol"; library PriceConvert { // 一个库中的所有函数都是internal的 function getPrice( AggregatorV3Interface priceFeed ) internal view returns (uint256) { (, int256 price, , , ) = priceFeed.latestRoundData(); return uint256(price * 10**10); } function getVersion( AggregatorV3Interface priceFeed ) internal view returns (uint256) { return priceFeed.version(); } function getConversionRate( uint256 ethAmount, AggregatorV3Interface priceFeed ) internal view returns (uint256) { uint256 ethPrice = getPrice(priceFeed); uint256 ethAmountInUsd = (ethAmount * ethPrice) / 10**18; return ethAmountInUsd; } }
添加yarn add --dev dotenv
执行yarn hardhat compile
const { network } = require("hardhat"); module.exports = async (getNamedAccounts, deployments) => { // 用于部署智能合约和记录日志 const { deploy, log } = deployments; // 通过getNamedAccounts获取名为deployer的账户对象,这个账户通常是部署这 const { deployer } = await getNamedAccounts; // 获取当前网络的链ID const chainId = network.config.chainId // 想要添加的覆盖选项的列表 const address = "0x694AA1769357215DE4FAC081bf1f309aDC325306"; const fundMe = await deploy("FundMe",{ from: deployer, // 指定谁在部署 args: [address], // 传递给构造函数的参数 log: true, }); }
本质上还是硬编码,解决方法
aave
是存在于多个链上的一个协议,所以它必须把代码部署到多个链上,并使用不同的地址,aave
其中helper-config
能够满足我们的需求
创建helper-hardhat-config.js
,用于定义网络配置
// 定义网络配置 const networkConfig = { 11155111: { name: "sepolia", ethUsdPriceFeed: "0x694AA1769357215DE4FAC081bf1f309aDC325306", } // 同理 }; const developmentChain = ["hardhat", "localhost"]; const DECIMALS = 8; const INITIAL_ANSWER = 2000; // 导出networkConfig,以便其他脚本可以使用 module.exports = { networkConfig, developmentChain, DECIMALS, INITIAL_ANSWER, }
如果使用的是本地网络,应该怎么做呢 ?Mock的思想是如果某个合约不存在,我们就部署一个最小化的版本进行我们的本地测试
在deploy
文件夹下,创建00-deploy-mocks.js
去部署一个自己的喂价合约
const { network, getNamedAccounts, deployments } = require("hardhat"); const { developmentChain, DECIMALS, INITIAL_ANSWER } = require("../helper-hardhat-config"); // 部署一个自己的mock喂价合约 module.exports = async () => { const { deployer } = await getNamedAccounts(); const { deploy, log } = deployments; const chainId = network.config.chainId // 添加mocks虚拟合约到我么的contracts文件夹中 //将虚拟合约和其他合约分离,其只是进行用于测试使用 if (developmentChain.includes(network.name)) { log("local network detected ......"); await deploy("MockV3Aggregator", { contract: "MockV3Aggregator", from: deployer, log: true, args: [DECIMALS, INITIAL_ANSWER], }); log("Mocks deployed"); } } module.exports.tags = ["all", "mocks"];
在contracts
目录下创建test/MockV3Aggregator.sol
,创建虚拟的喂价合约
// SPDX-License-Identifier: MIT pragma solidity ^0.6.0; // 定义我们的Mock喂价聚合器 import "@chainlink/contracts/src/v0.6/tests/MockV3Aggregator.sol";
yarn hardhat compile
使用以下命令部署:
yarn hardhat deploy --network hardhat --tags mocks
在01-deploy-fund-me.js
中
const { network } = require("hardhat"); const { networkConfig, developmentChain } = require("../helper-hardhat-config"); module.exports = async ({ getNamedAccounts, deployments }) => { const { deployer } = await getNamedAccounts(); const { deploy, log } = deployments; const chainId = network.config.chainId; console.log(network.name); // const address = "0x694AA1769357215DE4FAC081bf1f309aDC325306"; // const ethUsdPriceFeedAddress = networkConfig[chainId]["ethUsdPriceFeed"]; let ethUsdPriceFeedAddress; if (developmentChain.includes(network.name)) { const ethUsdAggrator = await deployments.get("MockV3Aggregator"); ethUsdPriceFeedAddress = ethUsdAggrator.address; console.log(`1234567890:${ethUsdPriceFeedAddress}`); } else { ethUsdPriceFeedAddress = networkConfig[chainId].ethUsdPriceFeed; console.log(`1234567890:${ethUsdPriceFeedAddress}`); } const args = [ethUsdPriceFeedAddress]; const fundMe = await deploy("FundMe", { from: deployer, // 指定谁在部署 args: args, // 传递给构造函数的参数 log: true, }); log("fundMe deployed ----------------") } module.exports.tags = ["all", "fundme"];
使用以下命令部署:
yarn hardhat deploy --network sepolia --tags fundme
自动验证:
在utils
文件夹中写验证的脚本
const { run } = require("hardhat"); const verify = async (contractAddress, args) => { console.log("Verifying contract ...."); try { await run("verify:verify", { address: contractAddress, constructorArguments: args, }); } catch (e) { if (e.message.toLowerCase().includes("verified")) { console.log("ok"); } else { console.log(e); } } }; module.exports = { verify }
// 01-deploy-fund-me.js if (!developmentChain.includes(network.name) && process.env.ETHERSCAN_API_KEY) { await verify(fundMe.address, args); } log("verify ok ------------");
其中验证合约的时候会出现连接超时的问题,参考以下可以解决:
https://github.com/smartcontractkit/full-blockchain-solidity-course-js/discussions/2247#discussioncomment-5496669
在hardhat-config.js
中添加:
// 其中127.0.0.1,如果是物理主机是Windows,换成Windows主机的IP地址 const { ProxyAgent, setGlobalDispatcher } = require("undici"); const proxyAgent = new ProxyAgent("http://127.0.0.1:7890"); setGlobalDispatcher(proxyAgent); // 关闭正在使用的防火墙
Solidity代码风格:
https://docs.soliditylang.org/en/v0.8.19/style-guide.html
// pragma // import // Error Code FundMe__NotOwner() // 定位错误的位置 // Interface Library contract // NatSpec 以太坊自然语言规范格式 // @title @autor @notice @dev @param @return natSpec能自动的帮我们创建文档 // 合约内部 // 类型声明(using PriceConverter for uint256)、状态变量(大小写混合、下划线)、事件、修饰器、构造器、fallback()、recieve()
测试fundMe
合约
优化我们的智能合约,使其更快速、更加节省gas,至少要进行两种类型的测试
创建staging
和uint
文件夹
单元测试是一种软件测试方法,用于测试源代码的各个单元,测试代码的最小部分,确保所有代码在本地都可以工作,之后在实际的测试网上进行Staging测试,确保我们的代码能和其他的合约正常工作
local hardhat
forked hardhat
单元测试之后,我们就要进行暂存(Staging)测试或者集成(Integration)测试
创建fundMe.test.js
describe("fundMe", async function () { beforeEach(); });
运行yarn hardhat coverage
,我们漏掉了很多东西
FundMe.test.js
const { deployments, ethers, getNamedAccounts } = require("hardhat"); const { assert, expect } = require("chai"); describe("fundMe", async function () { let fundMe; let mockV3Aggregator; beforeEach(async () => { deployer = (await getNamedAccounts()).deployer; await deployments.fixture(["all"]);// 换一下位置 fundMe = await ethers.getContract("FundMe", deployer); mockV3Aggregator = await ethers.getContract("MockV3Aggregator", deployer); }); describe("constructor", async () => { it("sets the aggregator address correctly", async function () { console.log("1234567890"); const response = await fundMe.priceFeed(); assert.equal(response, mockV3Aggregator.target); }); }); });
结果:
describe("fund", async () => { it("Fails if you don't send enough ETH", async function () { await fundMe.fund(); }); }); // 仅仅这样写的话会报错,waffle
waffle
可以通过expect
关键字来预期交易回滚和交易失败
it("update the amount funded data structure", async function () { // 发送1ETH await fundMe.fund({ value: sendValue }); const response = await fundMe.addressToAmountFonded(deployer); // f39Fd6e51aad88F6F4ce6aB8827279cffFb92266 assert.equal(response.toString(), sendValue); });
可以看到范围小了一些:
it("Adds funder to array of funders", async function () { await fundMe.fund({ value: sendValue }); const funder = await fundMe.funders(0); console.log(funder); assert.equal(funder, deployer); });
接下来写取款的测试:
// 完整代码 describe("withdraw", async () => { // 希望执行it之前账户是由一些钱在里面的 beforeEach(async () => { await fundMe.fund({ value: sendValue }); }); it("withdraw ETH from a single founder", async () => { // 获取fundMe合约的初始金额以及deployer的初始金额 const startingFundMeBalance = await ethers.provider.getBalance(fundMe.target); const startingdeployerBalance = await ethers.provider.getBalance(deployer); const transactionResponse = await fundMe.withdraw(); const transactionReceipt = await transactionResponse.wait(1); const { gasUsed, gasPrice } = transactionReceipt; const gasCost = gasUsed * gasPrice; const endingFundMeBalance = await ethers.provider.getBalance(fundMe.target); const endingdeployerBalance = await ethers.provider.getBalance(deployer); assert.equal(endingFundMeBalance, 0); assert.equal((startingFundMeBalance + startingdeployerBalance).toString(), (endingdeployerBalance + gasCost).toString() ); }); });
Debug
在debug
的时候,可以看到:
其中GasUsed * GasPrice
就是我们为gas
支付的全部金额了
调试工具
在solidity中使用hardhat的console.log( )
import "hardhat/console.log"
测试fundMe
:
测试多个资助者
it("allow us withdraw with multiple funders", async () => { const accounts = await ethers.getSigners(); // 从1开始,账户0是deployer for (let i = 1; i < 6; i++) { // const fundMeConnectedContract = await fundMe.connect(accounts[i]); console.log(accounts[i]); await fundMeConnectedContract.fund({ value: sendValue }); } // 获取合约余额 const startingFundMeBalance = await ethers.provider.getBalance(fundMe.target); const startingdeployerBalance = await ethers.provider.getBalance(deployer); // 取钱 const transactionResponse = await fundMe.withdraw(); const transactionReceipt = await transactionResponse.wait(1); const { gasUsed, gasPrice } = transactionReceipt; const gasCost = gasUsed * gasPrice; const endingFundMeBalance = await ethers.provider.getBalance(fundMe.target); const endingdeployerBalance = await ethers.provider.getBalance(deployer); assert.equal(endingFundMeBalance, 0); assert.equal((startingFundMeBalance + startingdeployerBalance).toString(), (endingdeployerBalance + gasCost).toString() ); await expect(fundMe.funders(0)).to.be.reverted; for (i = 1; i < 6; i++) { assert.equal(await fundMe.addressToAmountFonded(accounts[i].address), 0); } });
测试OnlyOwner
是否有效:
it("Only the owner to withdraw", async () => { const accounts = await ethers.getSigners() const attacker = accounts[1]; // 连接到账户 const attackerConnectContract = await fundMe.connect(attacker); await expect(attackerConnectContract.withdraw()).to.be.revertedWith("FundMe__NotOwner"); });
error FundMe__NotOwner(); // 确定错误来自哪里
Storage:
将gasReporter
设置为true
,执行yarn hardhat test,看我们消耗的gas
如何减小我们的gas
消费呢?
uint256 public minimumUsd = 50 * 10 * 18; address[] public funders; //记录发送资金的funder mapping(address => uint256) public addressToAmountFonded; // 记录每个地址资金发送的数量 //部署合约后立刻调用,通过构造函数设置合约的拥有者 address public immutable i_owner; AggregatorV3Interface public priceFeed; // 喂价
当我们保存或存储这些全局变量或Storage变量,会发生什么 ?
https://docs.soliditylang.org/en/v0.8.24/internals/layout_in_storage.html
可以将Storage理解为一个包含我们创建的所有变量的数组或者链表
uint256 favoriteNumber
能永久存在是由于被存储在了一个Storage的地方,每一个Storage类型的变量都被放在了一个32字长的槽位上,favoriteNumber
的值假如是25,那么存储在槽位中的就是uint256的16进制表示
对于动态数组或者mapping,内部的元素是以哈希函数来存储的,对象本身会占一个槽位,如果执行push操作,我们将在Storage的位置上存储值
constant变量本身已经成为合约字节码的一部分
如果有一些变量只存在于合约的运行期间,在合约中并不是永久存在的,会添加到Memory数据结构中,在函数运行结束后就会删除
字符串是一个动态大小的数组,需要将其存储在Storage中还是Memory(消耗gas)中,需不需要去清除它
读取或写入Storage都会消耗大量的gas,代码编译后的操作码和字节码,将字节码拆开解析就会发现字节码是由许多操作码组成,操作码表示需要执行多少计算才能运行我们的代码并完成所需的操作
gas费用是通过操作码的计算成本来计算的,每个操作码都有一个在以太坊网络中预定义的固定的gas成本
artifacts/build-info
不同的操作码对应的不同gas:https://github.com/crytic/evm-opcodes
命名规范:
添加s_前缀表示Storage存储变量,并且访问会产生gas费用
i_表示immutable
不可变变量,要比普通变量要便宜的多
function withdraw() public onlyOwner { for ( uint256 funderIndex = 0; funderIndex < s_funders.length; funderIndex = funderIndex + 1 ) { address funder = s_funders[funderIndex]; s_addressToAmountFonded[funder] = 0; } // 重置s_funders数组 s_funders = new address[](0); //零表示初始只有零个元素 (bool callSuccess, ) = payable(msg.sender).call{ value: address(this).balance }(""); require(callSuccess, "call failed"); }
s_funders.length
每一次都要从Storage中读取,gas费就会很高
function cheaperWithdraw() public payable onlyOwner { address[] memory funders = s_funders; for ( uint256 funderIndex = 0; funderIndex < funders.length; funderIndex++ ) { address funder = funders[funderIndex]; s_addressToAmountFonded[funder] = 0; } s_funders = new address[](0); (bool success, ) = i_owner.call{value: address(this).balance}(""); require(success); }
变量设置成internal或private会更加节省gas费
uint256 public s_minimumUsd = 50 * 10 * 18; address[] private s_funders; //记录发送资金的funder mapping(address => uint256) private s_addressToAmountFonded; // 记录每个地址资金发送的数量 //合约所有者不需要对其他人或者其他合约公开 address private immutable i_owner; AggregatorV3Interface private s_priceFeed; // 喂价
对于所有private
类型的变量可以写上对应的getter
方法