Solidity进修之路(一):初识合约及ERC20

大家好,我是大老鸽。之前因为学习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",一路回车即可。

命令运行完毕后会构建出一个基本的项目目录:

  • contracts为合约文件夹,用于存档各种你写的sol文件
  • script为脚本文件夹,里面可以存放各种自定义js脚本,比如合约部署脚本等等
  • test为单元测试
  • hardhat.config.js文件用来配置hardhat的基本信息和各种自动化任务,一般我们把它放在根目录

关于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 主网

你可以启动一个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合约规范。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.

实现一个简单的ERC20代币

在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~

欢迎关注我的推特:

邮箱:dalaoge@outlook.com

Subscribe to dalaoge.eth
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.