# Solidity进修之路（一）：初识合约及ERC20

By [dalaoge.eth](https://paragraph.com/@dalaoge) · 2022-02-11

---

大家好，我是大老鸽。之前因为学习web3.js接触过以太坊智能合约方面的的东西，不过没有正式且系统地学习过Solidity这门语言。这次开个新坑，记录一下接下来一段时期内的学习经验和成果。其实这篇也算不上什么科普技术文章，主要目的是与大家交流和分享，与各位一起成长进步。

相关概念
----

关于区块链、智能合约、EVM相关概念以及前端工程化开发、Node.js等开发基础本文不再赘述，这里默认读者对这些概念都已经有一定的理解。

没接触过区块链这方面的知识，在阅读本文前可以先深度阅读一下[登链汉化的Solidity官方文档-入门智能合约章节](https://learnblockchain.cn/docs/solidity/introduction-to-smart-contracts.html#blockchain-basics)。这些基础概念非常重要，建议学习Solidity之前一定要好好消化一下。

如果之前从未接触过Javascript和Node.js，请先去认真学习这部分知识，因为用到的开发环境工具需要你对这方面的东西有一定的熟练度。

开发环境准备
------

Win10x64，Node.js版本16.13.2，智能合约的测试、部署工具我选择了[Hardhat](https://hardhat.org/)。

> 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中文文档](https://learnblockchain.cn/docs/hardhat/getting-started/)、[Solidity中文文档](https://learnblockchain.cn/docs/solidity/introduction-to-smart-contracts.html)

新建项目
----

创建项目文件夹，初始化并安装hardhat：

`npm init`

`npm i hardhat --save-dev`

安装完毕后，运行：

`npx hardhat`

选择第一项 "Create a basic sample project"，一路回车即可。

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

![](https://storage.googleapis.com/papyrus_images/9c1f75d686ea2dcd1e592b7bb469ddda560e05aef8cd6ee07d0260bac03cff0d.png)

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

关于hardhat.config.js配置项，可以参考[官方文档](https://hardhat.org/hardhat-runner/docs/config)

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

![](https://storage.googleapis.com/papyrus_images/9c551d907cca772790dd9cb631fc88c57d5325596f8bea942bcaeffd29fd1f38.png)

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

`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描述**](https://learnblockchain.cn/docs/solidity/abi-spec.html)。
>     
> *   `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`

![](https://storage.googleapis.com/papyrus_images/c543ed207100ac768e07951e16404833bc9ae7df7dddea1fae04fdf3500ff93b.png)

测试通过🥳

### 部署合约

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

![](https://storage.googleapis.com/papyrus_images/b1ad4c9107a276e65243b1817abde2ba40fd022be869e3487322f86c269c3b6a.png)

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

![](https://storage.googleapis.com/papyrus_images/f289dc7ae1ed569ea50e14dccc90f26b5b824e55cf1020ff934ad7d748b2cfa0.png)

#### Fork 主网

> 你可以启动一个Fork主网的Hardhat Network实例。 Fork主网意思是模拟具有与主网相同的状态的网络，但它将作为本地开发网络工作。 这样你就可以与部署的协议进行交互，并在本地测试复杂的交互。
> 
> 要使用此功能，你需要连接到存档节点。 建议使用[Alchemy](https://alchemyapi.io/?r=7d60e34c-b30a-4ffa-89d4-3c4efea4e14b)

本地测试网络上除了你自己部署的合约，没有其他任何东西。如果想拿主网上的一些现成的东西测试你还需要自己部署，繁琐不说还非常容易出错。本地分叉主网可以使你的本地测试网络模拟主网状态，让你拥有主网上的合约、账户、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代币：

![](https://storage.googleapis.com/papyrus_images/e927f6115b1865526406e0cb61f0067e61fe72f80691cc3b42304a735a816787.png)

我们来看一下ERC20的源码：

[https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/ERC20.sol](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 对象名字。键类型允许除映射、[变长数组](https://so.csdn.net/so/search?q=%E5%8F%98%E9%95%BF%E6%95%B0%E7%BB%84&spm=1001.2101.3001.7020)、合约、枚举、结构体外的几乎所有类型。值类型没有任何限制，可以为任何类型包括映射类型。可以通过键名来查询对应的值，例如查询0x0000地址的余额：

\_balances\[0x00000\] //返回10000

我们一般使用ERC20合约来自[@openzeppelin](https://www.npmjs.com/package/@openzeppelin/contracts)包，这份合约还定义了\_mint、\_burn、increaseAllowance、decreaseAllowance等方法。顺便一提，[@openzeppelin](https://www.npmjs.com/package/@openzeppelin/contracts)里的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)
      })
    

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

![](https://storage.googleapis.com/papyrus_images/4c8610bd7c4ab0129bd1d179b02cf61668957462b4852a55a8be07b84fb8607c.png)

恭喜你，你已经完成了你的第一个ERC20代币！

总结
--

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

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

![](https://storage.googleapis.com/papyrus_images/310c06a9441910402a383cd57e8c8f236b92e1ffd7becdf4fb3850c32c5825ce.jpg)

**来自爱你的DFarm Club~**

欢迎关注我的推特：

[https://twitter.com/KirisakiAria](https://twitter.com/KirisakiAria)

邮箱：[dalaoge@outlook.com](mailto:dalaoge@outlook.com)

---

*Originally published on [dalaoge.eth](https://paragraph.com/@dalaoge/solidity-erc20)*
