合约初识

本章首先一笔带过的形式介绍了智能合约相关的区块链知识,这些将影响智能合约的逻辑。然后通过创建一个 Hello World 小例子作为正式学习的开始。引申出合约的注释,结构介绍以及以太币转账,合约的自我销毁。这些基本知识介绍完之后,通过3个小例子进行巩固和加深学习。最后通过一系列的问答题用于自测和以后回顾。

区块链基础

Solidity 是基于以太坊虚拟机(EVM)的智能合约开发语言,我们不需要关心所在区块链的底层实现逻辑,只要是兼容 EVM 的公链,我们都可以使用 Solidity 在上面进行智能合约开发。

在正式内容之前,我们简单的了解以下的基本概念:

  • 事务

  • 交易

  • 地址

  • 区块

  • 存储/内存/栈

事务

事务意味着你想做的一系列操作,要么一点没做,要么全部完成。它具有原子性,不存在仅执行一部分的情况;事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。

比如从 A 地址向 B 地址转账 100 元,那么数据库里 A 减 100 元,B 加 100 元。如果因为某些原因导致 A 已经减了 100 元,但是 B 加 100 元中间出现了异常。因为事务的原子性,发生失败后 A 和 B 地址都不会发生任何修改。这种场景在合约中经常发生,会经常看到 out of gas 异常,这是因为 gas 被耗尽。此时合约中做的所有修改都会被回滚。

gas 是作为用户为当前交易支付的手续费,每一笔交易都会收取 gas 费,目的是限制交易需要做的工作量,需要做的事情越多,所花费的 gas 也就越多;gas 会按照特定规则进行逐渐消耗,如果执行完成后还有剩余,gas 会在当前交易内原路返回到交易发起者的地址中。

交易

交易可以看作一个地址发送到另外一个地址的消息,可能包含一个二进制数据和以太币。

  • 如果目标地址含有代码,则此代码会被执行,并以 payload 作为入参。

  • 如果目标地址是零地址,此交易将创建一个新合约。

    • 这时候用来创建合约的 payload 会被转为 EVM 字节码执行,执行的输出作为合约代码永久存在区块链上。

    • 所以如果创建一个合约,并不需要向链上发送实际的合约代码,只需发送能够产生合约代码的代码就可以。

区块链中的交易遵守事务的特性,交易总是由发送人(创建交易的地址)进行签名。区块链底层会确保只有持有该地址密钥的人才能发起交易。正因为这个特性,所以才能为区块链上特定状态的修改增加保护机制。

比如在合约中指定某一个方法只有管理员账号才可以可以调用,我们只需要验证调用者是否为管理员地址就可以了,至于地址保护的事情并不需要关心,只要是该账号发起的交易,就认为是管理员在操作。(安全方面我们需要考虑的是,如果某一个地址被盗了怎么样,比如用多签钱包来解决。通常这些是业务逻辑决定,而不是当前合约需要关心的事。)

地址

地址很多时候也被称为账户,EVM 中有两类地址,一类是外部地址,一类是合约地址。

  • 外部地址:由公钥-私钥对控制

    • 常用的助记词,keystore 文件等只是方便用户储存,底层还是会转成私钥。

    • 一般是钱包应用创建的地址。公钥就是0xABC的这种以太坊收款地址,私钥可能是助记词生成,可能是 keystore 文件生成,也可能是用户直接保存的。

  • 合约地址:由地址一起存储的代码控制。

无论外部地址,还是合约地址,对于 EVM 来说,都是一样的。每个地址都有一个键值对形式的持久化存储。其中 key 和 value 都是 256 位,我们称为存储。此外每个地址都会有一个以太币的余额,合约地址也是如此;余额会因为发送包含以太币的交易而改变。

比如一个 ERC20 Token 合约地址,它既可以作为普通地址用来接受 ETH 和其他资产,也可以作为合约地址进行对应 Token 资产的流转。

区块

你可能听过区块链的双花攻击,女巫攻击等作恶方式;如果你没有听过也没关系,因为它们对于智能合约开发来说并不重要,我们编写的 Solidity 代码能运行在以太坊网络,也可以运行在 BSC, Matic,Eos EVM 等网络,就像前文说的那样,无论他们采用什么底层逻辑,只要它们支持 EVM 就足够了,底层逻辑不用关心。

我们需要关心的是,区块可能被回滚,交易可能被作废,所以会出现你发起的交易被回滚甚至从区块链中抹除掉的可能。区块链不能保证当前的交易一定包含在下一个区块中。如果你开发的合约有顺序关系,要注意这个特性。合约内的逻辑,不能将某一个块作为依赖。如果业务逻辑与随机数有关系,还需要关心随机数作恶的问题,通常是采用 Chainlink VRF 这类解决方案。

存储/内存/栈

存储:每一个地址都有一个持久化的内存,存储是将 256 位字映射到 256 位字的键值存储区。所以数据类型的最大值是 uint256/int256/bytes32,合约只能读写存储区内属于自己的部分。

内存:合约会试图为每一次消息调用获取一块被重新擦拭干净的内存实例。所以储存在内存中的数据,在函数执行完以后就会被销毁。内存是线性的,可按字节级寻址,但读的长度被限制为 256 位,而写的长度可以是 8 位或 256 位。

:合约的所有计算都在一个被称为栈(stack)的区域执行,栈最大有 1024 个元素,每一个元素长度是 256 bit;所以调用深度被限制为 1024 ,对复杂的操作,推荐使用循环而不是递归。

Hello World

Solidity 合约类似于面向对象语言中的类。合约中有用于数据持久化的状态变量,和可以修改状态变量的函数。 调用另一个合约实例中函数时,会切换执行时的上下文,此时前一个合约的状态变量就不能访问了。后面会逐步展开介绍,国际惯例,使用当前语言的 Hello World 作为第一个例子。

例子代码

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;
contract Hello {
    string public message = "Hello World!"; // 状态变量

    // 返回状态变量
    function fn1() public view returns (string memory) {
        return message;
    }

    // 内存中直接返回数据
    function fn2() public pure returns(string memory){
        return "Hello World!";
    }

    // 函数调用函数的使用方法: 没有 this 直接调用
    function fn3() public pure returns(string memory){
        return fn2();
    }
}

编译和测试Solidity需要一系列的编程环境,比如Node,Web3.js,Ethers.js,Ganache,Truffle,Hardhat 等等。不同的团队有不同的环境标准,我们学习的是最核心最值钱的 Solidity 部分,所以环境部分直接使用 Remix 就好,开箱即用,这样可以最大精力的学习Solidity。

remix 编译,部署,运行

Remix IDE 是学习合约的好帮手,即开即用,无需各种依赖,让我们可以专注于合约的学习和开发。

Remix 在线编辑器地址: remix.ethereum.org

下面是 Remix 编译,部署,测试的简单介绍。

1.编写代码:点击左侧 File explorer,选择 workspace,然后点击 Create new file 图标,创建 helloword.sol 文件,文件内容是上面 Hello World 例子。

2.编译代码:点击左侧 Solidity compile,然后点击 Compile helloword.sol,出现绿色的”对号”就是 compilation successful 了。(小技巧:选中此页面的 Auto compile 可以自动编译最新修改保存后的代码,推荐选中此选项。)

3.部署合约:点击左侧 Deploy & run transactions 页面,点击黄色按钮 「Depoly」,此时按钮下方区域会出现Deployed Contracts,这里就是刚刚部署出来的合约地址。

4.运行合约:展开Deployed Contracts中刚部署的合约,找到刚刚部署的 Hello 合约,点击 message/fn1/fn2/fn3 按钮都可以读取到"Hello World!"内容。

更多用法可以Remix文档内查看,建议不要在运行环境上面浪费超过半小时以上的精力,请珍惜自己的时间。

Hello World 例子分析

上面的代码获取 message 可以得到 "Hello World!",调用 fn1()函数,也可以得到 "Hello World!"; 这是因为 fn1 里面的逻辑是返回 message。通过这个例子可以发现,合约内调用变量并不需要使用 this 之类的关键字,直接使用即可,调用函数也是如此,直接 fnName([x]) 就可以。

通过 Remix 调用详情我们可以发现,他们消耗的 gas 不相同。通常直接获取 message 更省钱,因为 message 储存在状态变量中,而函数fn1是读取了状态变量然后再返回出去。但是在 Remix 中有时候得到的结果却并不相同,不用太相信 Remix 内的 gas。在 Remix 中,代码顺序,变量名/函数名长短的修改都可以影响 gas 消耗,仅仅作为参考即可。

在编写 solidity 代码时,保证安全的前提下,让合约消耗更少的 gas 是一个重要的优化方向。后面会有专门的一章进行 gas 优化的探讨,这里不再多展开。

合约代码中的三种注释

我们看到第一行的代码是 // SPDX-License-Identifier: MIT 这里面的 // 符号,是注释符;用来标记和记录代码开发相关的事情,注释的内容是不会被程序运行的。Solidity 支持单行注释和块注释,注释是为了更好的解释代码。有些人的观点是:我的代码像诗一样优雅,有注释的代码都存在着一些瑕疵,认为足够完美的代码是不需要注释的。请大家不要相信好的代码不需要注释的这种鬼话。代码中加入注释可以更好的团队协作,如有必要甚至还需要说明文档,这可以让自己更好的进行代码开发,让阅读者更快捷的理解代码逻辑。在实际工作中经常会出现自己写的代码一年半载之后再看,可能需要花费不必要的时间在理解复杂的的代码逻辑上,如果再没有设计图或代码注释,简直想骂人。

Solidity 支持 3 种注释方式;

  • 单行注释

  • 块注释

  • NatSpec 描述注释

单行注释

格式: // 注释内容

// SPDX-License-Identifier: MIT
string message = "Hello World!"; // 这是单行注释

如上,// 后面的内容都会被编译器忽略,为了可读性,一般会在 // 后面加一个空格。

块注释

格式如下,在 /**/ 之间的内容,都被编译器忽略

/*
这是块注释
*/

为了可读性,一般块注释的行首都加 * 和空格,如下

/**
  * 这是块注释
  * 这是块注释
  */

NatSpec 描述注释

单行使用 /// 开始,多行使用 /** 开头以 */ 结尾。NatSpec 描述注释的作用非常重要,它是为函数、返回变量等提供丰富的文档。在编写合约的时候,强烈推荐使用 NatSpec 为所有的开放接口(所有在 ABI 里呈现的内容)进行完整的注释。

简单演示

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

/// @title  一个简单的数据存储演示
/// @author Anbang(亚洲洲长,地球球长,银河系的最后守护者,人类文明的唯一指导者)
/// @notice 您智能将此合约用于最基本的演示
/// @dev    提供了存储方法/获取方法
/// @custom:xx    这是实验的测试合约(自定义的描述)
contract  TinyStorage {
    // data
    uint256 storedData;

    /// @notice 储存 x(第一种 NatSpec 写法)
    /// @param _x: storedData 将要修改的值
    /// @dev   将数字存储在状态变量 storedData 中
    function set(uint256 _x) public{
        storedData = _x;
    }

    /**
     * @notice 返回存储的值(第二种 NatSpec 写法)
     * @dev    检索状态变量 storedData 的值
     * @return 储存值
     */
    function get() public view returns(uint256){
        return storedData;
    }
}

上面所有标签都是可选的。下表解释了每个 NatSpec 标记的用途以及可以使用在哪些位置。我们可以选择合适的标记进行记录

标签 说明 语境
@title 描述 contract/interface 的标题 contract, interface, library
@author 作者姓名 contract, interface, library
@notice 向最终用户解释这是做什么的 contract, interface, library,
function, 公共状态变量
event
@dev 向开发人员解释任何额外的细节 contract, interface, library,
function, 状态变量, event
@param 记录参数(后面必须跟参数名称) function, event,
自定义错误
@return 函数的返回变量 function, 公共状态变量
@inheritdoc 从基本函数中复制所有缺失的标签
(必须后跟合约名称)
function, 公共状态变量
@custom:... 自定义标签,语义由应用程序定义 所有位置均可以

文档输出

使用 NatSpec 描述注释的另一个好处是,当被编译器解析时,上面示例中的代码将生成两个不同的 JSON 文件。

  • User Documentation:供最终用户在执行功能时作为通知使用的

  • Developer Documentation:供开发人员使用的。

如果将上述合约另存为,a.sol 则您可以使用以下命令生成文档:

solc --userdoc --devdoc a.sol

如果是在 Remix 内编写的代码。编译合约的时候会在 artifacts 文件夹中自动生成。

继承说明

如果函数是继承别的合约,没有 NatSpec 的函数将自动继承其基本函数的文档。但是下面三种情况是例外的:

  • 当参数名称不同时。

    • 这时候是函数的重载,函数签名已经发生了改变。

  • 当有多个基本功能时。

    • 这时候因为发生了冲突,supper 中有多个父级

  • 当有一个明确的 @inheritdoc 标签指定应该使用哪个合约来继承时。

更多 NatSpec 请参考: https://github.com/aragon/radspec

合约结构介绍

  1. SPDX 版权声明

  2. pragma solidity 版本限制

  3. contract 关键字

  4. import 导入声明

  5. interface: 接口

  6. library:库合约

SPDX 版权声明

Hello World 合约的第 1 行 // SPDX-License-Identifier: MIT 就是合约的版权声明。其中 SPDX-License-Identifier (SPDX 许可标示) 是标注当前的智能合约采用什么样的对外开放标准,该标准规定了别人是否拥有商业开发,学习使用等权利。代码中使用的 MIT 规定了其他人随便用该代码,但出问题不负责。

SPDX 许可标示在文件的任何位置都可以被编译器识别到,按照规范建议把它放在文件的顶部第一行。

如果一个项目开源了智能合约的源代码,可以更好地建立社区信任。但是由于提供源代码就不可避免的涉及到版权或者法律问题。所以 solidity 鼓励开源,但是开源并不等于放弃版权。如果你不想指定任何许可证,或者代码就是不想开源,Solidity 推荐使用 UNLICENSED ;UNLICENSED 不存在于 SPDX 许可证列表中,与 UNLICENSE (授予所有人所有权利)不同,它比 UNLICENSE 多一个 D 字母。

更多的 SPDX-License-Identifier 类型介绍,参照文章 SPDX License List(https://spdx.org/licenses/) 详细阅读,Solidity 遵循 npm 的 license 建议。根据自己的合约情况,选择合适的版权声明可以避免很多不必要的版权麻烦。

需要注意: 编译器不会验证代码中 SPDX 许可标示是否符合规范,比如我可以写为 // SPDX-License-Identifier: ANBANG ,并不会影响代码的运行。但是这里的 ANBANG 标示会被原样打包在 bytecode metadata 里。

bytecode metadata 介绍

当我们使用 remix 编译合约的时候,会在根目录创建 artifacts 文件夹,其中包含 build-info 记录构建信息的文件夹,以及每个合约名字作为文件名的文件夹,比如 contract Hello 将生成

  • Hello.json 文件

  • Hello_metadata.json 文件

Hello.json 文件结构

{
  deploy: {},
  data: {
    bytecode: {},
    deployedBytecode: {},
    gasEstimates: {},
    methodIdentifiers: {},
  },
  abi: [],
};

Hello_metadata.json 文件结构

{
  compiler: {
    version: "0.8.17+commit.xxx",
  },
  language: "Solidity",
  output: {
    abi: [],
    devdoc: {},
    userdoc: {},
  },
  settings: {},
  sources: {
    "aaa.sol": {
      keccak256:
        "0x637c141739144cd991b9350336a1f8c3b948811d7ed743fefb4aad99d7bb362f",
      license: "ANBANG",
      urls: [
        "bzz-raw://9eea517225b90242d6e3761046f5f5a8f0a2393747c89f3af01f34ad00764dc4",
        "dweb:/ipfs/QmXp5wap9ZNC9fihdA7aLMe7bKWBjeAuv7khEuvKrgp9Bx",
      ],
    },
  },
  version: 1,
};

// SPDX-License-Identifier: ANBANG 中的 ANBANG 就是在 sources -> filename.sol -> license

pragma solidity 版本限制

Hello World 合约的第 2 行 pragma solidity ^0.8.18; 指令,它是告诉编译器,我当前的合约代码采用大于等于 0.8.17,小于 0.9.0 的版本进步编译,解析部署时需要在匹配的版本下进行,在区块链浏览器上进行合约验证时,也需要选择部署时的编译版本。

使用 ^ 的意义和优点

^0.8.17 中的 ^ 表示小版本兼容,大版本不兼容,相当于 pragma solidity >= 0.8.17 < 0.9.0;。他既不允许低于0.8.17的编译器编译,也不允许大于等于 0.9.0 版本的编译器进行编译。之所以这么写,不写死 pragma solidity 0.8.17; 是为了可以享受到编译器的补丁版,比如以后出来了 0.8.40 版本,那么当前合约是可以运行在未来的 0.8.40 这个新版本的编译器。但是如果是大版本升级到了 0.9.0,那么编译器不会用新版的0.9.x解析,会使用 0.8 的最后一个稳定版本来进行解析编译。这里如果不加 ^,直接写pragma solidity 0.8.17;,就是告诉编译器,当前合约只选择在 0.8.17 版本来编译和部署;这样做的缺点就是享受不到以后出的补丁版的编译器。

跨大版本的合约

如果你打算跨大版本的合约,可以使用>>=<<=来操作,比如 pragma solidity >=0.7.0 <0.9.0;

注意:pragma 指令只对当前的源文件起作用,如果把文件 B 导入到文件 A,文件 B 的 pragma 将不会自动应用于文件 A。

  • 总结:

    1. pragma solidity ^0.8.18; 是用来告诉编译器当前代码的适用版本情况。

    2. pragma 指令只对当前的源文件起作用。

注:一份源文件可以包含多个版本声明、多个导入声明和多个合约声明。

contract 关键字

Hello World 合约的第 3 行的 contract Hello {} 是合约的基本结构;其中 contract 声明了当前代码块内是一个完整的合约。而 Hello 是当前合约的名字,合约名称是必须的,首字母一般采用大写字母开头。

contract 代表特殊的意义,这种有特殊意义的词,在编程界里一般被称为 保留关键字;保留关键字是现在或者将来被用到的特殊代指,都有固定意义,所以保留关键字不能作为变量名和函数名字。

  1. contract 基本结构是 contract ContractName {}

  2. Solidity 合约中,合约的名字是必须的。

  3. 合约的名称,一般约定为 大驼峰命名方式

  4. contract 是保留关键字

  5. 保留关键字不能作为变量名和函数名

注:一份源文件可以包含多个版本声明、多个导入声明和多个合约声明

变量

合约内的 message 叫做状态变量,状态变量是永久地存储在合约存储中的值。关于变量的更多信息,会在后续 变量 那一章详细介绍

函数

函数是代码的可执行单元,是一组逻辑的集合。关于变量的更多信息,会在后续 函数 那一章详细介绍

this 关键字

Solidity 中 this 代表合约对象本身;

  • 可以通过 address(this) 获取合约地址。

  • 可以通过 this.fnName 获取 external 函数

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

contract Demo {
    function contractAds() external view returns (address) {
        return address(this);
    }

    function testExternal() external view returns (address) {
        return this.contractAds();
    }
}

三种地址身份

  • 合约地址

  • 合约创建者地址

  • 合约调用者地址

这三个地址概念要完全理解。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

// 这三个地址的概念一定要理解清楚
contract Demo {
    address public owner;

    constructor() {
        // 可以用在 constructor 内获取当前合约地址
        owner = address(this);

        // 不可以在构造函数内调用函数,因为此时合约还没有完成构建好。
        // this.caller(); 相当于从外部调用 caller 方法
        // owner = this.caller();
    }

    function caller() external view returns (address) {
        return this.contractAds(); // 内部调用 external 可见性的函数
    }

    function contractAds() external view returns (address) {
        return address(this);
    }
}

合约属性:type 关键字

  • type(C).name:获得合约名

  • type(C).creationCode:获得包含创建合约字节码的内存字节数组。它可以在内联汇编中构建自定义创建例程,尤其是使用 create2 操作码。 不能在合约本身或派生的合约访问此属性。 因为会引起循环引用。

  • type(C).runtimeCode:获得合约的运行时字节码的内存字节数组。这是通常由 C 的构造函数部署的代码。 如果 C 有一个使用内联汇编的构造函数,那么可能与实际部署的字节码不同。 还要注意库在部署时修改其运行时字节码以防范定期调用(guard against regular calls)。 与 .creationCode 有相同的限制,不能在合约本身或派生的合约访问此属性。 因为会引起循环引用。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

contract Hello {
    string public message = "Hello World!";
}

contract Demo {
    function name() external pure returns (string memory) {
        return type(Hello).name;
    }

    function creationCode() external pure returns (bytes memory) {
        return type(Hello).creationCode;
    }
    function runtimeCode() external pure returns (bytes memory) {
        return type(Hello).runtimeCode;
    }
}

除了上面介绍的版权声明,版本限制,contract 外,合约文件还包括 import, interface,library,一起展开介绍下

import 导入声明

功能:从其他文件内倒入需要的变量或者函数。

导入方式

既可以导入本地文件,也可以导入 url (网络上的 ipfs,http 或者 git 文件)

  1. 导入所有的全局标志 import "filename"; 到当前全局范围

    1. 导入本地文件: import "./ERC20.sol"; ,其中 ./ 表示当前目录,查找路径参考

    2. 导入网络文件: import "https://github.com/aaa/.../tools.sol";

    3. 导入本地 NPM 库:

      1. $ npm install @openzeppelin/contracts

      2. import "@openzeppelin/contracts/token/ERC721/ERC721.sol";

  2. 导入所有的全局标志,并创建新的全局符号

    1. 方式一: import * as symbolName from "filename";

    2. 方式二: import "filename" as symbolName;

  3. 按需导入,按需修改名称

    1. import {symbol1 as aliasName, symbol2} from "filename";

不推荐导入变量标示名到当前全局范围的方式,因为一份源文件可以包含多个版本声明、多个导入声明和多个合约声明,不可控,容易污染当前的命名空间。如果全局导入,推荐使用 import "filename" as symbolName;

导入时候的本地路径

上文中的 filename 总是会按路径来处理,以 / 作为目录分割符、以 . 标示当前目录、以 .. 表示父目录。 当 ... 后面跟随的字符是 / 时,它们才能被当做当前目录或父目录。 只有路径以当前目录 . 或父目录 .. 开头时,才能被视为相对路径。

import "./x.sol" as x; 语句导入当前源文件同目录下的文件 x.sol。 如果用import "x.sol" as x; 代替,可能会引入不同的文件(在全局 include directory 中)。

最终导入哪个文件取决于编译器是怎样解析路径的。 通常,目录层次不必严格映射到本地文件系统, 它也可以映射到能通过诸如 ipfs,http 或者 git 发现的资源。

interface: 接口

接口使用案例

在下面的例子中,定义了 cat 合约以及 dog 合约。他们都有 eat 方法.以此他们都可以被上面的 animalEat 接口所接收。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

contract Cat {
    uint256 public age;

    function eat() public returns (string memory) {
        age++;
        return "cat eat fish";
    }

    function sleep1() public pure returns (string memory) {
        return "sleep1";
    }
}

contract Dog {
    uint256 public age;

    function eat() public returns (string memory) {
        age += 2;
        return "dog miss you";
    }

    function sleep2() public pure returns (string memory) {
        return "sleep2";
    }
}

interface AnimalEat {
    function eat() external returns (string memory);
}

contract Animal {
    function test(address _addr) external returns (string memory) {
        AnimalEat general = AnimalEat(_addr);
        return general.eat();
    }
}

type(I).interfaceId

返回接口Ibytes4 类型的接口 ID,接口 ID 被定义为 XOR (异或) 接口内所有的函数的函数选择器(除继承的函数。

上面的代码中,可以增加如下的函数来查看 interfaceId;

contract Animal {
    // ...
    function interfaceId() external pure returns (bytes4) {
        return type(AnimalEat).interfaceId;
    }
}

更多内容在 Interface:接口 那一章详细介绍,这里仅作初识了解。

library:库合约

库与合约类似,但它的目的是在一个指定的地址,且仅部署一次,然后通过 EVM 的特性来复用代码。

library Set {
    struct Data { mapping(uint => bool) flags; }
    function test(){
    }
}

其他合约调用库文件的内容直接通过库文件名.方法名例如:Set.test()

更多内容在 Library:库 那一章详细介绍,这里仅作初识了解。

全局的以太币单位

现实生活中经常会听说例如从提现 1.5 个以太币,或者某笔交易手续费花了 0.02 个以太坊等。这些带有小数点的数字是日常交流使用的。但是在合约内,却没有这种小数概念的货币金额。比如 1 个 ETH 的金额是 10**18 wei

基础单位

为了方便合约开发者操作,也提供如下这种便捷的换算方式。

以太币单位之间的换算就是在数字后边加上 weigweiether 来实现的,如果后面没有单位,缺省为 wei。例如 1 ether == 1e18 的逻辑判断值为 true。

  • 1 ether = 1e18

  • 1 gwei = 1e9

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

contract Demo {
    // 返回 true
    function test() public pure returns (bool a,bool b,bool c) {
        a = 1 wei == 1;
        b = 1 gwei == 1e9;
        c = 1 ether == 1e18;
    }
}

变量使用以太币单位

以太币单位不能直接用在变量后边。如果想用以太币单位来计算输入参数,你可以使用算术来转换,比如: amountEth * 1 ether

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

contract Demo {
    uint256 public amount;

    constructor() {
        amount = 1;
    }

    function fnEth() public view returns (uint256) {
        return amount + 1 ether; // 1000000000000000001
    }

    function fnGwei() public view returns (uint256) {
        return amount + 1 gwei; // 1000000001
    }

    // 这些后缀不能直接用在变量后边。如果想用以太币单位来计算输入参数,你可以用如下方式来完成:
    function testVar(uint256 amountEth) public view returns (uint256) {
        return amount + amountEth * 1 ether;
    }
}

接收 ETH

三个关键字

  • payable

    • 使用 payable 标记的函数可以用于发送和接收 Eth。

    • 使用 payable 标记的地址变量,允许发送和接收 Eth。

  • fallback

    • 一个合约可以最多有一个回退函数。

  • receive

    • 一个合约最多有一个 receive 函数

fallback 和 receive 不是普通函数,而是新的函数类型,有特别的含义,它们前面不需要加 function 这个关键字。加上 function 之后,它们就变成了一般的函数,只能按一般函数来去调用。同时 receivefallback 需要注意 gas 消耗。

本节介绍的是合约如何接收 ETH,至于合约如何发送 ETH,请阅读 两种形式的地址 这一节。

payable

  • 使用 payable 标记的函数可以用于发送和接收 Eth。

    • payable 意味着在调用这个函数的消息中可以附带 Eth。

  • 使用 payable 标记的 地址变量,允许发送和接收 Eth。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

contract Payable {
    // payable 标记函数
    function deposit1() external payable {}

    function deposit2() external {}

    // payable 标记地址
    function withdraw() external {
        payable(msg.sender).transfer(address(this).balance);
    }

    // 通过 balance 属性,来查看余额。
    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }
}

可以使用 deposit1 存款,但是如果使用 calldata 转账,则会失败,报错 In order to receive Ether transfer the contract should have either ‘receive’ or payable ‘fallback’ function,我们接下来介绍 fallback 和 receive。

fallback

fallback 函数是调用合约没有的方法时候执行,同时也可以设置为允许接收网络主币。

  • 语法

    • 不带参数: fallback () external [payable]

    • 带参数: fallback (bytes calldata input) external [payable] returns (bytes memory output)

    • 没有function关键字。必须是external可见性,

    // function fallback() external payable {}
    // 正确写法不带 function,直接写 fallback,fallback 如果使用 function 修饰,则有警告
    // This function is named "fallback" but is not the fallback function of the contract.
    // If you intend this to be a fallback function, use  "fallback(...) { ... }" without
    // the "function" keyword to define it.
    
  • fallback 函数类型可以是 payable,也可以不是 payable 的;

    • 如果不是 payable 的,可以往合约发送非转账交易,如果交易里带有转账信息,交易会被 revert;

    • 如果是 payable 的,自然也就可以接受转账了。

  • 尽管 fallback 可以是 payable 的,但并不建议这么做,声明为 payable 之后,其所消耗的 gas 最大量就会被限定在 2300。

  • 它可以是virtual的,可以被重载也可以有修改器(modifier)。

回退函数在两种情况被调用

  • 向合约转账;

    • 如果使用 call 转账,会执行 fallback。

    • 如果使用合约内已有的 deposit 转账,不会执行 fallback

  • 执行合约不存在的方法

    • 就会执行 fallback 函数。(执行合约不存在的方法时执行)

向合约转账

fallback 函数始终会接收数据,但为了同时接收以太时,必须标记为 payable

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

contract Payable {
    event Log(string funName, address from, uint256 value, bytes data);

    function deposit() external payable {}

    // 通过 balance 属性,来查看余额。
    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }

    fallback() external payable {
        emit Log("fallback", msg.sender, msg.value, msg.data);
    }
}

执行合约不存在的方法

如果在一个对合约调用中,没有其他函数与给定的函数标识符匹配 fallback 会被调用.或者在没有 receive 函数时,而没有提供附加数据对合约调用,那么 fallback 函数会被执行。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

contract StoneCat {
    uint256 public age = 0;
    event eventFallback(string);

    // 发送到这个合约的所有消息都会调用此函数(因为该合约没有其它函数)。
    // 向这个合约发送以太币会导致异常,因为 fallback 函数没有 `payable` 修饰符
    fallback() external {
        age++;
        emit eventFallback("fallbak");
    }
}

interface AnimalEat {
    function eat() external returns (string memory);
}

contract Animal {
    function test1(address _addr) external returns (string memory) {
        AnimalEat general = AnimalEat(_addr);
        return general.eat();
    }
    function test2(address _addr) external returns (bool success) {
        AnimalEat general = AnimalEat(_addr);
        (success,) = address(general).call(abi.encodeWithSignature("eat()"));
        require(success);
    }
}

上面例子种,执行 StoneCat 合约 calldata,参数 0x00 可以成功,但是如果发送了以太币,则会失败,因为没有 paybale。

直接使用方法是不行的,但是可以通过 call 调用,因为 call 不检查,这也官方是不推荐使用 call 的原因。

带参数的 fallback

fallback 可以有输入值和输出值,都是 bytes 类型的数据。如果使用了带参数的版本,input 将包含发送到合约的完整数据,参数 input 等于msg.data,可以省略,并且通过 output 返回数据。 返回数据不是 ABI 编码过的数据,相反,它返回不经过修改的数据。与任何其他函数一样,只要有足够的 gas 传递给它,回退函数就可以执行复杂的操作。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

contract Demo {
    bytes public inputData1;
    bytes public inputData2;

    fallback (bytes calldata input) external  returns (bytes memory output){
        inputData1 = input;
        inputData2 = msg.data; // input 等于 msg.data
        return input;
    }
}

⚠️ 如果想要解码输入数据,那么前四个字节用作函数选择器,然后用abi.decode 与数组切片语法一起使用来解码 ABI 编码的数据: (c, d) = abi.decode(_input[4:], (uint256, uint256));请注意,这仅应作为最后的手段,而应使用对应的函数。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

contract StoneCat {
    uint256 public age = 0;
    bytes public inputData1;
    bytes public inputData2;
    uint256 public c;
    uint256 public d;

    event eventFallback(string);

    fallback (bytes calldata input) external  returns (bytes memory output){
        age++;
        inputData1 = input;
        inputData2 = msg.data;
        (c, d) = abi.decode(msg.data[4:], (uint256, uint256));
        emit eventFallback("fallbak");
        return input;
    }
}

interface AnimalEat {
    function eat() external returns (string memory);
}

contract Animal {
    function test2(address _addr) external returns (bool success) {
        AnimalEat general = AnimalEat(_addr);
        (success, ) = address(general).call(abi.encodeWithSignature("eat()",123,456));
        require(success);
    }
}

receive 以太函数

receive 只负责接收主币,一个合约最多有一个 receive 函数

  • 语法 receive() external payable {}

    • 没有 function 关键字

      // function receive() external payable {}
      // receive 如果使用 function 修饰,则有如下警告
      // This function is named "receive" but is not the receive function of
      // the contract. If you intend this to be a receive function,
      // use "receive(...) { ... }" without the "function" keyword to define it.
    
  • 没有参数、没有返回值。

  • external payable 是必须的

    • receive 函数类型必须是 payable 的,并且里面的语句只有在通过外部地址往合约里转账的时候执行。

  • 它可以是 virtual 的,可以被重载也可以有 修改器(modifier) 。

  • 如果没有定义 接收函数 receive,就会执行 fallback 函数。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

contract Demo {
    event Log(string funName, address from, uint256 value, bytes data);

    receive() external payable {
        // receive 被调用的时候不存在 msg.data,所以不使用这个,直接用空字符串
        emit Log("receive", msg.sender, msg.value, "");
    }

    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }
}

上面例子通过 calldata 执行转账,无参数时候会触发 receive 函数。但是如果有参数,比如0x00,则会报错 ‘Fallback’ function is not defined

在对合约没有任何附加数据调用(通常是对合约转账)是会执行 receive函数.例如 通过 .send() or .transfer()

声明为 payable 之后,其所消耗的 gas 最大量就会被限定在 2300。除了基础的日志输出之外,进行其他操作的余地很小。下面的操作消耗会操作 2300 gas :

  • 写入存储

  • 创建合约

  • 调用消耗大量 gas 的外部函数

  • 发送以太币

扩展阅读 ⚠️: 一个没有 receive 函数的合约,可以作为 coinbase 交易 (又名矿工区块回报 )的接收者或者作为 selfdestruct 的目标来接收以太币。一个合约不能对这种以太币转移做出反应,因此也不能拒绝它们。这是 EVM 在设计时就决定好的,而且 Solidity 无法绕过这个问题。这也意味着 address(this).balance 可以高于合约中实现的一些手工记帐的总和(例如在 receive 函数中更新的累加器记帐)。

receive 和 fallback 共存的调用

注意:这里 fallback 需要是 payable 类型的。如下图:

/**

    调用时发送了ETH
            |
判断 msg.data 是否为空
          /     \
               
是否存在 receive   fallbak()
      /   \
    存在   不存在
    /        \
receive()   fallbak()

 */

总结: 只有 msg.data 为空,并且存在 receive 的时候,才会运行 receive

如果不存在 receive 以太函数,payablefallback 函数也可以在纯以太转账的时候执行。但是推荐总是定义一个 receive 函数,而不是定义一个 payable 的 fallback 函数。否则会报警告 ⚠️:Warning: This contract has a payable fallback function, but no receive ether function. Consider adding a receive ether function.


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

// 这个合约会保留所有发送给它的以太币,没有办法返还。
contract Demo {
    uint256 public x;
    uint256 public y;

    event Log(string funName, address from, uint256 value, bytes data);

    // 纯转账调用这个函数,例如对每个空empty calldata的调用
    receive() external payable {
        x = 1;
        y = msg.value;
        emit Log("receive", msg.sender, msg.value, "");
    }

    // 除了纯转账外,所有的调用都会调用这个函数.
    // (因为除了 receive 函数外,没有其他的函数).
    // 任何对合约非空calldata 调用会执行回退函数(即使是调用函数附加以太).
    fallback() external payable {
        x = 2;
        y = msg.value;
        emit Log("fallback", msg.sender, msg.value, msg.data);
    }

    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }
}
  • 输入以太币,然后无参数 calldata 调用

  • 输入以太币,然后参数0x00 进行 calldata 调用

如果 receive函数不存在,但是有 payable 的 fallback 回退函数 那么在进行纯以太转账时,fallback 函数会调用.如果两个函数都没有,这个合约就没法通过常规的转账交易接收以太(会抛出异常)。

注意:receive 函数可能只有 2300 gas 可以使用(如,当使用 sendtransfer 时),

⚠️: 一个没有定义 fallback 函数或 receive 函数的合约,直接接收以太币(没有函数调用,使用 sendtransfer)会抛出一个异常, 并返还以太币。所以如果你想让你的合约在任何情况下都可以接收以太币,必须实现 receive 函数(使用 payable fallback 函数不再推荐,因为它会让借口混淆)。

selfdestruct:合约自毁

合约代码从区块链上移除的唯一方式是合约在合约地址上的执行自毁操作 selfdestructselfdestruct 作用是 销毁合约,并把余额发送到指定地址类型 Address

做了两件事:

  • 销毁合约:它使合约变为无效,删除该地址地字节码。

  • 它把合约的所有资金强制发送到目标地址。

    • 如果接受的地址是合约,即使里面没有 fallbackreceive 也会发送过去

  • 除非必要,不建议销毁合约。

    • 如果有人发送以太币到移除的合约,这些以太币可能将永远丢失

    • 如果要禁用合约,可以通过修改某个内部状态让所有函数无法执行,这样也可以达到目的。

  • 即便一个合约的代码中没有显式地调用 selfdestruct,它仍然有可能通过 delegatecallcallcode 执行自毁操作。

⚠️:即使一个合约被 selfdestruct 删除,它仍然是区块链历史的一部分,区块链的链条中不可能无缘无故消失一个块,这样他们就没办法做校验了。 因此,使用 selfdestruct 与从硬盘上删除数据是不同的。

请注意 selfdestruct 具有从 EVM 继承的一些特性:

  • 接收合约的 receive 函数 不会执行。

  • 合约仅在交易结束时才真正被销毁,并且 revert 可能会“撤消”销毁。此外,当前合约内的所有函数都可以被直接调用,包括当前函数。

在 0.5.0 之前, 还有一个 suicide ,它和 selfdestruct 语义是一样的。

自我毁灭

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

contract Kill {
    uint256 public aaa = 123;

    constructor() payable {}

    function kill() external{
        selfdestruct(payable(msg.sender));
    }

    function bbb() external pure returns(uint256){
        return 1;
    }

    fallback() external {}

    receive() external payable {}
}
  1. 先调用 aaa/bbb,查看输出值

  2. calldata 形式进行转账

  3. kill 销毁合约

  4. 查看收到的金额

  5. 查看 aaa 的值

通过合约调用毁灭

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

contract Kill {
    uint256 public aaa = 123;

    constructor() payable {}

    function kill() external {
        selfdestruct(payable(msg.sender));
    }

    function bbb() external pure returns(uint256){
        return 1;
    }

    fallback() external {}

    receive() external payable {}
}

contract Helper {
    // 没有 `fallback` 和 `receive`,正常没办法接受ETH主币
    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }

    // kill 后,此时 Helper 余额就会强制收到ETH主币
    function kill(Kill _kill) external {
        _kill.kill();
    }
}
  1. 部署 Kill

  2. 先调用 Kill.aaa,查看输出值

  3. calldata 形式进行转账,查看余额

  4. 部署 Helper

  5. 查看 Helper.getBalance 返回值

  6. calldata 形式进行转账,此时会失败

  7. 调用 Helper.kill

  8. 查看 Helper.getBalance 返回值

  9. 查看 Kill.aaa 的值

实战 1: 同志们好

为了让学习的内容,可以更好的使用,每一章后面都会最少有一个实战练习。当前是第一章,学习的内容比较简单和浅显,所以做一个简单的小联系就可以了。

为此我写了这个阅兵式里同志们好的场景合约,用于能力自检,相当于加强版的 Hello World。再次提醒,本教程默认读者已经掌握了 Solidity 基本语言的使用方法。这并不是针对初次学习 Solidity 的教程。

同志们好的场景:

  • 领导说“同志们好”,回复“领导好”

  • 领导说“同志们辛苦了”,回复“为人民服务”

合约代码

个人习惯是将代码按照功能进行区域划分,每一个区域使用如下注释标记。

/*
 * ========================================
 * State Variables
 * ========================================
 */

废话不多说,直接上代码:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

/// @title 一个模拟同志们好的简单演示
/// @author Anbang
/// @notice 您只能将此合约用于最基本的模拟演示
/// @dev 本章主要内容的实战练习
/// @custom:experimental 这是实验的合约。
contract HelloComrades {
    /*
     * ========================================
     * State Variables
     * ========================================
     */

    /// @notice 用于标记当前进度
    /// @dev 0:等待领导说"同志们好",
    /// @dev 1:等待同志们说"领导好",
    /// @dev 2:等待领导说"同志们辛苦了"
    /// @dev 3:等待同志们说"为人民服务"
    /// @dev 4:等待销毁。
    /// @return 当前进度
    uint8 public step = 0;

    /// @notice 用于标记领导地址
    /// @dev 不可变量,需要在构造函数内指定,以后就不能修改了
    /// @return 当前领导的地址
    address public immutable leader;

    /// @notice 用于遇到错误时的无脑复读机式回复
    string internal constant UNKNOWN =
        unicode"我不知道如何处理它,你找相关部门吧!";

    /*
     * ========================================
     * Events
     * ========================================
     */

    /// @notice 用于对当前 step 被修改时的信息通知
    /// @dev 只要发生 step 修改,都需要抛出此事件
    /// @param 当前修改的最新 step
    event Step(uint8);

    /// @notice 用于对当前合约的金额变动通知
    /// @dev 只要发生金额修改,都需要抛出此事件
    /// @param tag: 标记内容
    /// @param from: 当前地址
    /// @param value: 当前发送金额
    /// @param data: 当前调用的data内容
    event Log(string tag, address from, uint256 value, bytes data);

    /*
     * ========================================
     * Modifier
     * ========================================
     */

    /// @notice 检查只能领导调用
    /// @dev 用于领导专用函数
    modifier onlyLeader() {
        require(msg.sender == leader, unicode"必须领导才能说");
        _;
    }

    /// @notice 检查只能非领导调用
    /// @dev 用于非领导地址检测
    modifier notLeader() {
        require(
            msg.sender != leader,
            unicode"不需要领导回答,需要同志们来回答"
        );
        _;
    }

    /*
     * ========================================
     * Errors
     * ========================================
     */

    /// @notice 自定义的错误,这种注释内容会在错误时显示出来
    /// @dev 用于所有未知错误
    /// This is a message des info.需要上方空一行,才可以显示出来
    error MyError(string msg);

    /*
     * ========================================
     * Constructor
     * ========================================
     */

    /// @dev 用于领导地址的指定,后续不可修改
    constructor(address _leader) {
        require(_leader != address(0), "invalid address");
        leader = _leader;
    }

    /*
     * ========================================
     * Functions
     * ========================================
     */

    /// @notice 用于领导说"同志们好"
    /// @dev 只能在 step 为 0 时调用,只能领导调用,并且只能说"同志们好"
    /// @param content: 当前领导说的内容
    /// @return 当前调用的状态,true 代表成功
    function hello(string calldata content) external onlyLeader returns (bool) {
        if (step != 0) {
            revert(UNKNOWN);
        }
        if (!review(content, unicode"同志们好")) {
            revert MyError(unicode"必须说:同志们好");
        }
        step = 1;
        emit Step(step);
        return true;
    }

    /// @notice 用于同志们说"领导好"
    /// @dev 只能在 step 为 1 时调用,只能非领导调用,并且只能说"领导好"
    /// @param content: 当前同志们说的内容
    /// @return 当前调用的状态,true 代表成功
    function helloRes(string calldata content)
        external
        notLeader
        returns (bool)
    {
        if (step != 1) {
            revert(UNKNOWN);
        }
        if (!review(content, unicode"领导好")) {
            revert MyError(unicode"必须说:领导好");
        }
        step = 2;
        emit Step(step);
        return true;
    }

    /// @notice 用于领导说"同志们辛苦了"
    /// @dev 只能在 step 为 2 时调用,只能领导调用,并且只能说"同志们辛苦了",还需给钱
    /// @param content: 当前领导说的内容
    /// @return 当前调用的状态,true 代表成功
    function comfort(string calldata content)
        external
        payable
        onlyLeader
        returns (bool)
    {
        if (step != 2) {
            revert(UNKNOWN);
        }
        if (!review(content, unicode"同志们辛苦了")) {
            revert MyError(unicode"必须说:同志们辛苦了");
        }
        if (msg.value < 2 ether) {
            revert MyError(unicode"给钱!!!最少2个以太币");
        }
        step = 3;
        emit Step(step);
        emit Log("comfort", msg.sender, msg.value, msg.data);
        return true;
    }

    /// @notice 用于同志们说"为人民服务"
    /// @dev 只能在 step 为 3 时调用,只能非领导调用,并且只能说"为人民服务"
    /// @param content: 当前同志们说的内容
    /// @return 当前调用的状态,true 代表成功
    function comfortRes(string calldata content)
        external
        notLeader
        returns (bool)
    {
        if (step != 3) {
            revert(UNKNOWN);
        }
        if (!review(content, unicode"为人民服务")) {
            revert MyError(unicode"必须说:为人民服务");
        }
        step = 4;
        emit Step(step);
        return true;
    }

    /// @notice 用于领导对
    /// @dev 只能在 step 为 4 时调用,只能领导调用
    /// @return 当前调用的状态,true 代表成功
    function destruct() external onlyLeader returns (bool) {
        if (step != 4) {
            revert(UNKNOWN);
        }
        emit Log("selfdestruct", msg.sender, address(this).balance, "");
        selfdestruct(payable(msg.sender));
        return true;
    }

    /*
     * ========================================
     * Helper
     * ========================================
     */

    /// @notice 用于检查调用者说的话
    /// @dev 重复检测内容的代码抽出
    /// @param content: 当前内容
    /// @param correctContent: 正确内容
    /// @return 当前调用的状态,true 代表内容相同,通过检测
     function review(string calldata content, string memory correctContent)
     private
     pure
     returns(bool){
         return keccak256(abi.encodePacked(content)) == keccak256(abi.encodePacked(correctContent));
     }

    receive() external payable {
        emit Log("receive", msg.sender, msg.value, "");
    }

    fallback() external payable {
        emit Log("fallback", msg.sender, msg.value, msg.data);
    }

    /// @notice 用于获取当前合约内的余额
    /// @dev 一个获取当前合约金额的辅助函数
    /// @return 当前合约的余额
    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }
}

Remix 部署测试

  • 部署合约

    • 需要输入 Leader 地址作为参数

  • 点击 【leader】 查看信息

  • 点击 【destruct】 进行销毁

    • 此时报错,因为步骤不对

  • 【hello】

    • 输入错的内容

    • 输入同志们好

    • 查看 step 值

  • 【helloRes】

    • 输入错的内容,此时提示,账号不对。

    • 切换账号后输入错的内容,提示必须说:领导好

    • 输入领导好

    • 查看 step 值

  • 【comfort】

    • 输入错的内容,此时提示账号权限不对

    • 切换账号后,输入错的内容,提示必须说:同志们辛苦了

    • 点击【hello】,此时说提示我不知道如何处理它,你找相关部门吧!,因为 step 不对。

    • 输入同志们辛苦了,此时提示必须给钱;(只有给了 2 个以上的以太币,才能说同志们辛苦了。)

    • 我们给 2 个 wei,假装是 2 个 ETH,看能否通过。(结果还是不能通过)

    • 给 2 个以太,并输入同志们辛苦了。此时可以通过了

    • 点击【getBalance】查看合约的余额

    • 查看 step 值

  • 【comfortRes】

    • 点击【helloRes】,此时说提示我不知道如何处理它,你找相关部门吧!,因为 step 不对。

    • 切换账号后,输入错的内容,提示必须说:为人民服务

    • 输入为人民服务

  • 【calldata】调用

    • 输入 1wei ,无参数直接调用;查看交易详情内的 logs,此时是 receive,余额变化多 1wei

    • 输入 2wei,参数使用0x00调用,查看交易详情内的 logs,此时是 fallback,余额变化多 2wei

  • 【destruct】调用,注意查看余额变化。

    • 注意查看当前 leader 地址的余额

    • 先使用非 leader 地址触发【destruct】,提示错误

    • 然后是 leader 地址触发。查看交易详情种的 logs

    • 查看 leader 地址/ balance/step,都已经是默认值

    • 触发所有函数,此时函数都可以使用,但是都是默认值。

案例知识点

  • 合约的基本用法

  • 合约的构造函数使用

  • 函数的基本用法

    • 函数中条件判断和错误输出

  • 事件和事件触发

  • NatSpec 用法演示

    • 自定错误使用和触发,以及结合 NatSpec 抛出错误

  • fallbackreceive 的使用和不同之处

  • immutable 不可变量的使用

  • constant 常量的使用

  • unicode 字面常量

  • modifier 使用

  • keccak256 结合 abi.encodePacked 判断字符串是否相同

生成文档

solc --userdoc --devdoc a.sol

实战作业

这个例子中 step,因为只有几个选择,尝试将 step 改为 enum 类型。

实战 2: 存钱罐合约

  • 所有人都可以存钱

    • ETH

  • 只有合约 owner 才可以取钱

  • 只要取钱,合约就销毁掉 selfdestruct

  • 扩展:支持主币以外的资产

    • ERC20

    • ERC721

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

contract Bank {
    // 状态变量
    address public immutable owner;

    // 事件
    event Deposit(address _ads, uint256 amount);
    event Withdraw(uint256 amount);

    // receive
    receive() external payable {
        emit Deposit(msg.sender, msg.value);
    }

    // 构造函数
    constructor() payable {
        owner = msg.sender;
    }

    // 方法
    function withdraw() external {
        require(msg.sender == owner, "Not Owner");
        emit Withdraw(address(this).balance);
        selfdestruct(payable(msg.sender));
    }

    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }
}

实战 3: WETH 合约

WETH 是包装 ETH 主币,作为 ERC20 的合约。

标准的 ERC20 合约包括如下几个

  • 3 个查询

    • balanceOf: 查询指定地址的 Token 数量

    • allowance: 查询指定地址对另外一个地址的剩余授权额度

    • totalSupply: 查询当前合约的 Token 总量

  • 2 个交易

    • transfer: 从当前调用者地址发送指定数量的 Token 到指定地址。

      • 这是一个写入方法,所以还会抛出一个 Transfer 事件。

    • transferFrom: 当向另外一个合约地址存款时,对方合约必须调用 transferFrom 才可以把 Token 拿到它自己的合约中。

  • 2 个事件

    • Transfer

    • Approval

  • 1 个授权

    • approve: 授权指定地址可以操作调用者的最大 Token 数量。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

contract WETH {
    string public name = "Wrapped Ether";
    string public symbol = "WETH";
    uint8 public decimals = 18;

    event Approval(address indexed src, address indexed delegateAds, uint256 amount);
    event Transfer(address indexed src, address indexed toAds, uint256 amount);
    event Deposit(address indexed toAds, uint256 amount);
    event Withdraw(address indexed src, uint256 amount);

    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;


    function deposit() public payable {
        balanceOf[msg.sender] += msg.value;
        emit Deposit(msg.sender, msg.value);
    }

    function withdraw(uint256 amount) public {
        require(balanceOf[msg.sender] >= amount);
        balanceOf[msg.sender] -= amount;
        payable(msg.sender).transfer(amount);
        emit Withdraw(msg.sender, amount);
    }

    function totalSupply() public view returns (uint256) {
        return address(this).balance;
    }

    function approve(address delegateAds, uint256 amount) public returns (bool) {
        allowance[msg.sender][delegateAds] = amount;
        emit Approval(msg.sender, delegateAds, amount);
        return true;
    }

    function transfer(address toAds, uint256 amount) public returns (bool) {
        return transferFrom(msg.sender, toAds, amount);
    }

    function transferFrom(
        address src,
        address toAds,
        uint256 amount
    ) public returns (bool) {
        require(balanceOf[src] >= amount);

        if (src != msg.sender) {
            require(allowance[src][msg.sender] >= amount);
            allowance[src][msg.sender] -= amount;
        }

        balanceOf[src] -= amount;
        balanceOf[toAds] += amount;

        emit Transfer(src, toAds, amount);

        return true;
    }

    fallback() external payable {
        deposit();
    }

    receive() external payable {
        deposit();
    }

}

ETH 上的 WETH 合约参考: https://cn.etherscan.com/address/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2#code

问答题

  • 因为区块可以被撤回,编码时候有些需要注意的?

    • 会出现你发起的交易被回滚甚至从区块链中抹除掉的可能。区块链不能保证当前的交易一定包含在下一个区块中。如果你开发的合约有顺序关系,要注意这个特性。合约内的逻辑,不能将某一个块作为依赖。

  • 标记版本号有哪些方法?

    • ^0.8.17

    • 0.8.17

    • pragma solidity >=0.7.0 <0.9.0;

  • 常用的版权声明有哪些,分别什么区别?

    • MIT/BUSL

  • 聊一聊 NatSpec 注释

    • 单行使用 /// 开始,多行使用 /** 开头以 */ 结尾。NatSpec 描述注释的作用非常重要,它是为函数、返回变量等提供丰富的文档。在编写合约的时候,强烈推荐使用 NatSpec 为所有的开放接口(只要是在 ABI 里呈现的内容)进行完整的注释。

    • 可以输出错误,而不消耗 gas

  • 聊一聊存储,内存,栈的内容

    • 存储:每一个地址都有一个持久化的内存,存储是将 256 位字映射到 256 位字的键值存储区。所以数据类型的最大值是 uint256/int256/bytes32,合约只能读写存储区内属于自己的部分。

    • 内存:合约会试图为每一次消息调用获取一块被重新擦拭干净的内存实例。所以储存在内存中的数据,在函数执行完以后就会被销毁。内存是线性的,可按字节级寻址,但读的长度被限制为 256 位,而写的长度可以是 8 位或 256 位。

    • :合约的所有计算都在一个被称为栈(stack)的区域执行,栈最大有 1024 个元素,每一个元素长度是 256 bit;所以调用深度被限制为 1024 ,对复杂的操作,推荐使用循环而不是递归。

  • interface 如何使用

    • 定义一个拥有某个方法的接口,传入地址后,调用地址。

      interface AnimalEat {
          function eat() external returns (string memory);
      }
      
      contract Animal {
          function test(address _addr) external returns (string memory) {
              AnimalEat general = AnimalEat(_addr);
              return general.eat();
          }
      }
      
  • string message = "Hello World!"; 这种没有明确标注可视范围的情况下,message 的可视范围是什么? 是 internal 还是 private?

    • private

  • 变量如何使用以太币单位?

    • 如果想用以太币单位来计算输入参数,你可以使用乘法来转换: amountEth * 1 ether

  • receive 和 fallback 共存的调用?

    • 只有 msg.data 为空,并且存在 receive 的时候,才会运行 receive。

  • receive 和 fallback 区别?

    • receive 只负责接收主币

    • 调用没有的方法时候执行,因为可以设置 payable,可以接收网络主币。尽管 fallback 可以是 payable 的,但并不建议这么做,声明为 payable 之后,其所消耗的 gas 最大量就会被限定在 2300。

  • 合约没有 receive 和 fallback 可以接受以太币么?

    • 可以接受,可以方法标记 payable 进行转账

  • 聊一聊合约自毁 selfdestruct

    • 合约代码从区块链上移除的唯一方式是合约在合约地址上的执行自毁操作 selfdestruct 。selfdestruct 作用是 销毁合约,并把余额发送到指定地址类型 Address。

    • 销毁合约:它使合约变为无效,删除该地址地字节码。

    • 它把合约的所有资金强制发送到目标地址。

      • 如果接受的地址是合约,即使里面没有 fallbackreceive 也会发送过去

    • 除非必要,不建议销毁合约。

      • 如果有人发送以太币到移除的合约,这些以太币可能将永远丢失

      • 如果要禁用合约,可以通过修改某个内部状态让所有函数无法执行,这样也可以达到目的。

    • 即便一个合约的代码中没有显式地调用 selfdestruct,它仍然有可能通过 delegatecallcallcode 执行自毁操作。

    • 即使一个合约被 selfdestruct 删除,它仍然是区块链历史的一部分,区块链的链条中不可能无缘无故消失一个块,这样他们就没办法做校验了。 因此,使用 selfdestruct 与从硬盘上删除数据是不同的。

  • 合约进行selfdestruct后,还可以调用状态变量和函数么?

    • 可以调用,但是返回默认值。如果想调用,也可以在存档节点里指定未删除的高度进行调用。