Cover photo

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",一路回车即可。

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

post image
  • contracts为合约文件夹,用于存档各种你写的sol文件

  • script为脚本文件夹,里面可以存放各种自定义js脚本,比如合约部署脚本等等

  • test为单元测试

  • hardhat.config.js文件用来配置hardhat的基本信息和各种自动化任务,一般我们把它放在根目录

关于hardhat.config.js配置项,可以参考官方文档

此时如果再次运行 npx hardhat,则会显示出所有可用的指令:

post image

最后不要忘了运行一下命令安装依赖:

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

post image

测试通过🥳

部署合约

接下来我们准备部署合约。首先,进入 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

post image

它将启动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

post image

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代币:

post image

我们来看一下ERC20的源码:

https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/ERC20.sol

太长了我就不往文章里贴了,大家可以打开上面链接自行查看。

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)
  })

然后根据部署成功后生成的合约地址将代币导入钱包:

post image

恭喜你,你已经完成了你的第一个ERC20代币!

总结

本文讲了一些Solidity中比较基础的东西,发散了一些方面但又没太深入,所以很多朋友可能看得比较一知半解。不过没关系,因为我自己也还在一知半解状态。后面的文章将会深入探讨Solidity的语言,也会带领大家编写一些更为复杂机制的合约。

由于笔者也是初学者,如果文中出现了谬误还望各位读者批评指正,也欢迎大家来与我交流,一起成长进步!

post image

来自爱你的DFarm Club~

欢迎关注我的推特:

https://twitter.com/KirisakiAria

邮箱:dalaoge@outlook.com