大家好,我是大老鸽。之前因为学习web3.js接触过以太坊智能合约方面的的东西,不过没有正式且系统地学习过Solidity这门语言。这次开个新坑,记录一下接下来一段时期内的学习经验和成果。其实这篇也算不上什么科普技术文章,主要目的是与大家交流和分享,与各位一起成长进步。
关于区块链、智能合约、EVM相关概念以及前端工程化开发、Node.js等开发基础本文不再赘述,这里默认读者对这些概念都已经有一定的理解。
没接触过区块链这方面的知识,在阅读本文前可以先深度阅读一下登链汉化的Solidity官方文档-入门智能合约章节。这些基础概念非常重要,建议学习Solidity之前一定要好好消化一下。
如果之前从未接触过Javascript和Node.js,请先去认真学习这部分知识,因为用到的开发环境工具需要你对这方面的东西有一定的熟练度。
Win10x64,Node.js版本16.13.2,智能合约的测试、部署工具我选择了Hardhat。
Hardhat是一个编译、部署、测试和调试以太坊应用的开发环境。它可以帮助开发人员管理和自动化构建智能合约和DApps过程中固有的重复性任务,并围绕这一工作流程轻松引入更多功能。这意味着hardhat在最核心的地方是编译、运行和测试智能合约。
Hardhat内置了Hardhat网络,这是一个专为开发设计的本地以太坊网络。主要功能有Solidity调试,跟踪调用堆栈、
console.log()
和交易失败时的明确错误信息提示等。Hardhat Runner是与Hardhat交互的CLI命令,是一个可扩展的任务运行器。它是围绕任务和插件的概念设计的。每次你从CLI运行Hardhat时,你都在运行一个任务。例如,
npx hardhat compile
运行的是内置的compile
任务。任务可以调用其他任务,允许定义复杂的工作流程。用户和插件可以覆盖现有的任务,从而定制和扩展工作流程。Hardhat的很多功能都来自于插件,而作为开发者,你可以自由选择想使用的插件。Hardhat不限制使用什么工具的,但它确实有一些内置的默认值。所有这些都可以覆盖
编辑器我使用的是vscode(毕竟老前端嘛),安装Solidity扩展。
Solidity扩展的默认配置可能会导致导入模块时报错,在首选项里加入如下配置即可:
"solidity.remappings": [
"hardhat/=/Project/contract_project/node_modules/hardhat",
"@openzeppelin/=/Project/contract_project/node_modules/@openzeppelin"
],
"solidity.defaultCompiler": "localNodeModule"
有时候你配置好了他偶尔也会报错,然后过一会又自己好了,不是很稳定,不过不影响正常用🐶
最后提供一下与本文相关的开发文档,遇到问题可以查阅:Hardhat中文文档、Solidity中文文档
创建项目文件夹,初始化并安装hardhat:
npm init
npm i hardhat --save-dev
安装完毕后,运行:
npx hardhat
选择第一项 "Create a basic sample project",一路回车即可。
命令运行完毕后会构建出一个基本的项目目录:
关于hardhat.config.js配置项,可以参考官方文档
此时如果再次运行 npx hardhat,则会显示出所有可用的指令:
最后不要忘了运行一下命令安装依赖:
npm install --save-dev "hardhat@^2.8.3" "@nomiclabs/hardhat-waffle@^2.0.0" "ethereum-waffle@^3.0.0" "chai@^4.2.0" "@nomiclabs/hardhat-ethers@^2.0.0" "ethers@^5.0.0"
首先进入contracts文件夹,删掉自动生成的Greeter.sol,新建HelloWorld.sol。
// SPDX-License-Identifier: GPL-3.0
//规定Solidity编译器版本,不同版本可能有不同的语法和特性
pragma solidity ^0.8.9;
//引入hardhat自带的console模块,可以使用js中的console.log方法输入日志
import 'hardhat/console.sol';
//contract关键字用于创建合约,类似于标准OOP语言中的“类(Class)”
contract HelloWorld {
//声明一个字符串类型的message属性,public标识它可以在合约外部被读取
string public message;
//构造函数,与标准OOP语言中的构造函数一样,在创建合约时(类似于类的实例化)执行一次
//memory关键字表示临时存储,不持久化保存数据(对应storage关键字)
constructor(string memory _message) {
message = _message;
}
//这里定义了一个可以被公开访问say方法,view代表可以读取状态变量但不可修修改
//在0.4.17版本以前的constant关键字等同于后来的view,后来constant关键字被废弃
function say() public view returns (string memory) {
console.log('Hello World!');
return message;
}
}
注意:第一行的版权许可不要删掉!
上面实现了一个非常简单的智能合约,只定义了一个变量、一个方法和一个构造函数。
智能合约非常像标准面向对象语言中的”类“,合约中可以包括变量、方法、事件、结构体等,其也有继承(Solidity支持多重继承)、重写等特性。创建合约类似于将一个类实例化,此时会调用构造函数,初始化各种持久化的变量。执行完成后代码会部署到链上,这时候我们就可以与合约交互了。
Solidity规定一个合约中最多定义一个构造函数,方法的书写方式是function关键字+方法名(参数)在前,后面再写修饰符,有返回值的方法最后要加上returns关键字+返回值类型(说实话有点别扭...)。
运行以下指令即可:
npx hardhat compile
编译完成后会多出两个文件夹:artifacts和cache。artifacts存放编译好的工件文件、调试文件以及一些构建信息文件,cache里的solidity-files-cache.js则保存了一些编译合约文件时生成的基本信息。
我们这边要着重关注的是编译出来的工件文件:HelloWorld.json
一个工件拥有部署和与合约交互所需的所有信息。这些信息与大多数工具兼容,包括Truffle的artifact格式。每个工件都由一个以下属性的 json 组成。
contractName
: 合约名称的字符串。abi
:合约ABI的JSON描述。bytecode
:一个0x
前缀的未连接部署字节码的十六进制字符串。如果合约不可部署,则有0x
字符串。deployedBytecode
:一个0x
前缀的十六进制字符串,表示未链接的运行时/部署的字节码。如果合约不可部署,则有0x
字符串。linkReferences
:字节码的链接参考对象由solc返回。如果合约不需要链接,该值包含一个空对象。deployedLinkReferences
:已部署的字节码的链接参考对象由solc返回。如果合约不需要链接,该值包含一个空对象。
hardhat在每次编译时会检查文件,如果距离上次编译没有任何改动的话则默认不会编译。如果要强行重新编译,请加上--force参数:
npx hardhat compile --force
进入test文件夹,删除里面的sample-test.js,新建test.js:
Hardhat默认的测试框架是Waffle,这东西普通的开发者可能之前没听说过(包括我),但是这个框架是基于大名鼎鼎的mocha和chai开发出来的。
const { expect } = require('chai')
const { ethers } = require('hardhat')
describe('合约基本测试', function () {
it('调用say方法,日志应该输出"Hello, world!"', async () => {
//返回一个HelloWorld合约的工厂promise
const HelloWorld = await ethers.getContractFactory('HelloWorld')
//开始部署合约并且返回实例化出来的合约对象的promise
const hw = await HelloWorld.deploy('Hello, world!')
//等待合约部署完毕
await hw.deployed()
//hw可以调用合约的成员方法,上面这一系列操作非常类似标准OOP中的实例化操作
expect(await hw.say()).to.equal('Hello, world!')
})
})
运行命令:
npx hardhat test
测试通过🥳
接下来我们准备部署合约。首先,进入 scripts文件夹,删除里面的文件,新建deploy.js
const { ethers } = require('hardhat')
const main = async () => {
//其实部署方法起来跟之前的测试完全一样
const HelloWorld = await ethers.getContractFactory('HelloWorld')
const hw = await HelloWorld.deploy('HelloWorld', 'HelloWorld')
await hw.deployed()
console.log('合约部署成功,地址:', hw.address)
}
main()
.then(() => process.exit(0))
.catch(error => {
console.error(error)
process.exit(1)
})
接下来我们启动一下Hardhat Network,这是一个为开发而设计的本地以太坊网络。
Hardhat Network是如何工作的?
- 它在收到每笔交易后,立即按顺序出块,没有任何延迟。
- 底层是基于
@ethereumjs/vm
EVM 实现, 与ganache、Remix和Ethereum Studio 使用的相同EVM。- 支持以下的硬分叉:
- byzantium
- constantinople
- petersburg
- istanbul
- muirGlacier
打开一个新终端,运行命令:
npx hardhat node
它将启动Hardhat Network,并作为一个公开的JSON-RPC和WebSocket服务器。
然后,只要将钱包或应用程序连接到
http://localhost:8545
。如果你想把Hardhat连接到这个节点,你只需要使用
--network localhost
来运行命令。Hardhat Network默认用此状态初始化:
一个全新的区块链,只是有创世区块。
220个账户,每个账户有10000个ETH,助记词为:
"test test test test test test test test test test test junk"
在原来的终端运行命令(此命令会自动编译合约):
npx hardhat run --network localhost scripts/deploy.js
你可以启动一个Fork主网的Hardhat Network实例。 Fork主网意思是模拟具有与主网相同的状态的网络,但它将作为本地开发网络工作。 这样你就可以与部署的协议进行交互,并在本地测试复杂的交互。
要使用此功能,你需要连接到存档节点。 建议使用Alchemy
本地测试网络上除了你自己部署的合约,没有其他任何东西。如果想拿主网上的一些现成的东西测试你还需要自己部署,繁琐不说还非常容易出错。本地分叉主网可以使你的本地测试网络模拟主网状态,让你拥有主网上的合约、账户、ERC20 代币等。
要使用Fork主网可以通过以下两种方法。
第一种是直接加--fork参数:
npx hardhat node --fork https://eth-mainnet.alchemyapi.io/v2/<key>
另一种是在hardhat.config.js进行配置,然后再启动节点,不需要加--fork参数:
networks: {
hardhat: {
forking: {
url: "https://eth-mainnet.alchemyapi.io/v2/<key>"
}
}
}
在学习部署了一个简单的智能合约之后,我们再来看一下ERC20合约规范。ERC20可以说是与币圈人关系最密切的一个规范。
什么是 ERC-20?
ERC-20 提供了一个同质化代币的标准,换句话说,每个代币与另一个代币(在类型和价值上)完全相同。 例如,一个 ERC-20 代币就像 ETH 一样,意味着一个代币会并永远会与其他代币一样。
很多常见代币都是ERC20代币:
我们来看一下ERC20的源码:
太长了我就不往文章里贴了,大家可以打开上面链接自行查看。
ERC20规范的代币合约,主要派生自IERC20和IERC20Metadata两个合约接口规范。
IERC20:
pragma solidity ^0.8.9;
interface IERC20 {
//方法
//返回代币总供应量
function totalSupply() external view returns (uint256);
//返回传入的account的余额
function balanceOf(address account) external view returns (uint256);
//转账,从调用合约的地址发送value个代币到地址recipient,触发Transfer事件
function transfer(address recipient, uint256 amount) external returns (bool);
//返回被允许从owner提取到spender余额。
function allowance(address owner, address spender) external view returns (uint256);
//授权spender使用amount数量的代币,触发Approval事件
function approve(address spender, uint256 amount) external returns (bool);
//从地址sender发送value个代币到地址recipient,触发Transfer事件。
function transferFrom(
address sender,
address recipient,
uint256 amount
) external returns (bool);
//事件
//转账时触发
event Transfer(address indexed from, address indexed to, uint256 value);
//授权时触发
event Approval(address indexed owner, address indexed spender, uint256 value);
}
IERC20Metadata:
pragma solidity ^0.8.0;
import "../IERC20.sol";
interface IERC20Metadata is IERC20 {
//返回token名字
function name() external view returns (string memory);
//返回token符号
function symbol() external view returns (string memory);
//返回token精度
function decimals() external view returns (uint8);
}
上面的数据类型是接口,Solidity的接口与其他标准面向对象语言的接口差不多:
接口类似于抽象合约,使用
interface
关键字创建,接口只能包含抽象函数,不能包含函数实现。以下是接口的关键特性:
- 接口的函数只能是外部类型。
- 接口不能有构造函数。
- 接口不能有状态变量。
- 接口可以包含enum、struct定义,可以使用
interface_name.
访问它们。
除了实现上面的接口以外,在ERC20中还定义了如下几个私有属性:
//余额,此值为一个映射
mapping(address => uint256) private _balances;
//授权某地址使用某地址多少token的映射
mapping(address => mapping(address => uint256)) private _allowances;
//最大供应量
uint256 private _totalSupply;
//名字
string private _name;
//符号
string private _symbol;
Solidity中的映射可以理解成一个Map对象,mapping(键=>值) private 对象名字。键类型允许除映射、变长数组、合约、枚举、结构体外的几乎所有类型。值类型没有任何限制,可以为任何类型包括映射类型。可以通过键名来查询对应的值,例如查询0x0000地址的余额:
_balances[0x00000] //返回10000
我们一般使用ERC20合约来自@openzeppelin包,这份合约还定义了_mint、_burn、increaseAllowance、decreaseAllowance等方法。顺便一提,@openzeppelin里的decimals返回的是18。
以下是OpenZeppelin的简单介绍:
OpenZeppelin provides security products to build, automate, and operate decentralized applications. We also protect leading organizations by performing security audits on their systems and products.
在contracts文件夹里新建DLG.sol,我们来整一个大老鸽币🐶
// SPDX-License-Identifier: GPL-3.0
pragma solidity 0.8.9;
//引入ERC20合约
import "@openzeppelin/contracts/代币/ERC20/ERC20.sol";
//is关键字指继承,DLG派生于ERC20,可以访问基类所有的非私有成员,包括内部(internal)函数和状态变量
//顺便一提,Solidity支持多重继承(这玩意会产生很多复杂的问题,很恶心)
contract DLG is ERC20{
//构造函数,需要传入代币名字和符号
//派生合约需要指定基类所有参数,一般来说可以在声明合约时直接传参。但是像是下面这种情况,基类构造函数的参数依赖于派生合约的,必须按如下写法继承:在构造函数后面加上基类+参数。
constructor (string memory name, string memory symbol) ERC20(name, symbol) {
//创建合约时调用_mint方法,此方法会无中生有的生成指定数量的代币并发送给指定账户,这里总供应量为100万
//msg.sender指正在调用此合约的地址
//msg的属性很多,有兴趣可以去查查文档
//decimals()精度返回值默认为18,这边有兴趣可以自行查阅一下以太坊的单位
_mint(msg.sender, 1000000 * 10**uint(decimals()));
}
}
这样我们就完成了一个很代币合约,非常简单的几行。
然后我们再次使用npx hardhat run --network localhost scripts/deploy.js
在本地网络节点(记得开启本地Hardhat本地网络!)部署一下合约。
const hre = require('hardhat')
const main = async () => {
// Hardhat always runs the compile task when running scripts with its command
// line interface.
//
// If this script is run directly using `node` you may want to call compile
// manually to make sure everything is compiled
// await hre.run('compile');
// We get the contract to deploy
const contract = await hre.ethers.getContractFactory('DLG')
const result = await contract.deploy('DLG', 'DLG')
await result.deployed()
console.log('DLG deployed to:', result.address)
}
// We recommend this pattern to be able to use async/await everywhere
// and properly handle errors.
main()
.then(() => process.exit(0))
.catch(error => {
console.error(error)
process.exit(1)
})
然后根据部署成功后生成的合约地址将代币导入钱包:
恭喜你,你已经完成了你的第一个ERC20代币!
本文讲了一些Solidity中比较基础的东西,发散了一些方面但又没太深入,所以很多朋友可能看得比较一知半解。不过没关系,因为我自己也还在一知半解状态。后面的文章将会深入探讨Solidity的语言,也会带领大家编写一些更为复杂机制的合约。
由于笔者也是初学者,如果文中出现了谬误还望各位读者批评指正,也欢迎大家来与我交流,一起成长进步!
来自爱你的DFarm Club~
欢迎关注我的推特: