Solidity

Solidity#

参考

智能合约 与 Solidity 语言#

智能合约是运行在链上的程序,合约开发者可以通过智能合约实现与链上资产/数据进行交互,用户可以通过自己的链上账户来调用合约,访问资产与数据。因为区块链保留区块历史记录的链式结构、去中心化、不可篡改等特征,智能合约相比传统应用来说能更公正、透明。

然而,因为智能合约需要与链进行交互,部署、数据写入等操作都会消耗一定费用,数据存储与变更成本也比较高,因此在设计合约时需要着重考虑资源的消耗。此外,常规智能合约一经部署就无法进行修改,因此,合约设计时也需要多考虑其安全性、可升级性与拓展性。

Solidity 是一门面向合约的、为实现智能合约而创建的高级编程语言,在 EVM 虚拟机上运行,语法整体类似于 Javascript,是目前最流行的智能合约语言,也是入门区块链与 Web3 所必须掌握的语言。

编写合约#

编写一个以太坊合约相当于编写一个类,一个合约可以包含多个成员变量和若干函数,以及可选的构造函数;

部署一个合约相当于实例化,部署时刻将执行构造函数;

任何外部账户均可发起对合约函数的调用,但一个交易仅限一个函数调用;

所有检查都必须在合约的函数内部完成。

参考

以太坊的智能合约就是一段由EVM虚拟机执行的字节码,类似于Java虚拟机执行Java字节码。直接编写字节码非常困难,通常都是由编译器负责把高级语言编译为字节码。

编写以太坊合约最常用的高级语言是Solidity,这是一种类似JavaScript语法的高级语言。通过Solidity的官方网站可以快速上手,我们不会详细讨论Solidity的语法细节,这里给出一个简单的投票合约:

// SPDX-License-Identifier: GPL-3.0

pragma solidity =0.8.7;

contract Vote {

    event Voted(address indexed voter, uint8 proposal);

    mapping(address => bool) public voted;

    uint256 public endTime;

    uint256 public proposalA;
    uint256 public proposalB;
    uint256 public proposalC;

    constructor(uint256 _endTime) {
        endTime = _endTime;
    }

    function vote(uint8 _proposal) public {
        require(block.timestamp < endTime, "Vote expired.");
        require(_proposal >= 1 && _proposal <= 3, "Invalid proposal.");
        require(!voted[msg.sender], "Cannot vote again.");
        voted[msg.sender] = true;
        if (_proposal == 1) {
            proposalA ++;
        }
        else if (_proposal == 2) {
            proposalB ++;
        }
        else if (_proposal == 3) {
            proposalC ++;
        }
        emit Voted(msg.sender, _proposal);
    }

    function votes() public view returns (uint256) {
        return proposalA + proposalB + proposalC;
    }
}

Solidity注释和JavaScript一致,第一行通常是版权声明的注释,然后声明编译器版本:

pragma solidity =0.8.7; // 指定编译器版本为0.8.7

也可以指定版本范围,如:

pragma solidity >=0.8.0 <0.9.0; // 指定编译器版本为0.8.x

紧接着,由关键字contract声明一个合约:

contract Vote {
    ... // 合约代码
}

虽然一个Solidity文件可以包含多个合约,但最好还是遵循一个文件一个合约,且文件名保持与合约一致。这里的合约名是Vote。

熟悉面向对象编程的小伙伴对类、成员变量、成员方法一定不陌生。一个合约就相当于一个类,合约内部可以有成员变量:

contract Vote {
    // 记录已投票的地址:
    mapping(address => bool) public voted;

    // 记录投票终止时间:
    uint256 public endTime;

    // 记录得票数量:
    uint256 public proposalA;
    uint256 public proposalB;
    uint256 public proposalC;

    ...
}

Solidity支持整型(细分为uint256、uint128、uint8等)、bytes32类型、映射类型(相当于Java的Map)、布尔型(true或false)和特殊的address类型表示一个以太坊地址。

以太坊合约不支持浮点数类型,是为了保证每个节点运行合约都能得到完全相同的结果。浮点数运算在不同的ISA体系下存在表示方式、运算精度的不同,无法保证两个节点执行浮点运算会得到相同的结果。

所有的成员变量都默认初始化为0或false(针对bool)或空(针对mapping)。

如果某个成员变量要指定初始值,那么需要在构造函数中赋值:

contract Vote {
    ...

    // 构造函数:
    constructor(uint256 _endTime) {
        endTime = _endTime; // 设定成员变量endTime为指定参数值
    }

    ...
}

以太坊合约支持读、写两种类型的成员函数,以view修饰的函数是只读函数,它不会修改成员变量,即不会改变合约的状态:

contract Vote {
    ...

    function votes() public view returns (uint256) {
        return proposalA + proposalB + proposalC;
    }

    ...
}

没有view修饰的函数是写入函数,它会修改成员变量,即改变了合约的状态:

contract Vote {
    ...

    function vote(uint8 _proposal) public {
        require(block.timestamp < endTime, "Vote expired.");
        require(_proposal >= 1 && _proposal <= 3, "Invalid proposal.");
        require(!voted[msg.sender], "Cannot vote again.");
        // 给mapping增加一个key-value:
        voted[msg.sender] = true;
        if (_proposal == 1) {
            // 修改proposalA:
            proposalA ++;
        }
        else if (_proposal == 2) {
            // 修改proposalB:
            proposalB ++;
        }
        else if (_proposal == 3) {
            // 修改proposalC:
            proposalC ++;
        }
        emit Voted(msg.sender, _proposal);
    }

    ...
}

合约可以定义事件(Event),我们在Vote合约中定义了一个Voted事件:

contract Vote {
    // Voted事件,有两个相关值:
    event Voted(address indexed voter, uint8 proposal);

    ...
}

只定义事件还不够,触发事件必须在合约的写函数中通过emit关键字实现。当调用vote()写方法时,会触发Voted事件:

contract Vote {
    ...

    function vote(uint8 _proposal) public {
        ...
        emit Voted(msg.sender, _proposal);
    }

    ...
}

事件可用来通知外部感兴趣的第三方,他们可以在区块链上监听产生的事件,从而确认合约某些状态发生了改变。

以上就是用Solidity编写一个完整的合约所涉及的几个要素:

  • 声明版权(可选);
  • 声明编译器版本;
  • 以contract关键字编写一个合约;
  • 可以包含若干成员变量;
  • 可以在构造函数中对成员变量初始化(可选);
  • 可以编写只读方法;
  • 可以编写写入方法;
  • 可以声明Event并在写入方法中触发。

函数又可以用public或private修饰。顾名思义,public函数可以被外部调用,而private函数不能被外部调用,他们只能被public函数在内部调用。

合约执行流程#

当一个合约编写完成并成功编译后,我们就可以把它部署到以太坊上。合约部署后将自动获得一个地址,通过该地址即可访问合约。

把contract Vote {...}看作一个类,部署就相当于一个实例化。如果部署两次,将得到两个不同的地址,相当于实例化两次,两个部署后的合约对应的成员变量是完全独立的,互不影响。

构造函数在部署合约时就会立刻执行,且仅执行一次。合约部署后就无法调用构造函数。

任何外部账户都可以发起对合约的函数调用。如果调用只读方法,因为不改变合约状态,所以任何时刻都可以调用,且不需要签名,也不需要消耗Gas。但如果调用写入方法,就需要签名提交一个交易,并消耗一定的Gas。

在一个交易中,只能调用一个合约的一个写入方法。无需考虑并发和同步的问题,因为以太坊交易的写入是严格串行的。

验证#

由于任何外部账户都可以发起对合约的函数调用,所以任何验证工作都必须在函数内部自行完成。最常用的require()可以断言一个条件,如果断言失败,将抛出错误并中断执行。

常用的检查包括几类:

参数检查:

// 参数必须为1,2,3:
require(_proposal >= 1 && _proposal <= 3, "Invalid proposal.");

条件检查:

// 当前区块时间必须小于设定的结束时间:
require(block.timestamp < endTime, "Vote expired.");

调用方检查:

// msg.sender表示调用方地址:
require(!voted[msg.sender], "Cannot vote again.");

以太坊合约具备类似数据库事务的特点,如果中途执行失败,则整个合约的状态保持不变,不存在修改某个成员变量后,后续断言失败导致部分修改生效的问题:

function increment() {
    // 假设a,b均为成员变量:
    a++;
    emit AChanged(a);
    // 如果下面的验证失败,a不会被更新,也没有AChanged事件发生:
    require(b < 10, 'b >= 10');
    b++;
}

即合约如果执行失败,其状态不会发生任何变化,也不会有任何事件发生,仅仅是调用方白白消耗了一定的Gas。

开发/调试工具#

与常规编程语言不同,Solidity 智能合约的开发往往无法直接通过一个 IDE 或本地环境进行方便的调试,而是需要与一个链上节点进行交互。开发调试往往也不会直接与主网(即真实资产、数据与业务所在的链)进行交互,否则需要承担高额手续费。目前开发调试主要有以下几种方式与框架:

  1. Remix IDE。通过 Ethereum 官方提供的基于浏览器的 Remix 开发工具进行调试,Remix 会提供完整的 IDE、编译工具、部署调试的测试节点环境、账户等,可以很方便地进行测试,这是我学习使用时用的最多的工具。Remix 还可以通过 MetaMask 插件与测试网、主网进行直接交互,部分生产环境也会使用它进行编译部署。
  2. Truffle。Truffle 是一个非常流行的 Javascript 的 Solidity 合约开发框架,提供了完整的开发、测试、调试工具链,可以与本地或远程网络进行交互。
  3. Brownie。Brownie 是一个基于 Python 的 Solidity 合约开发框架,以简洁的 Python 语法为调试和测试提供了便捷的工具链。
  4. Hardhat。Hardhat 是另一个基于 Javascript 的开发框架,提供了非常丰富的插件系统,适合开发复杂的合约项目。

Fundry 是什么? 也是个开发框架?从 B 站上看到的,待定。

除了开发框架外,更好地进行 Solidity 还需要熟悉一些工具:

  1. Remix IDE 对于语法提示等并不完善,因此,可以使用 Visual Studio Code 配合 Solidity 进行编写,有更好的体验。
  2. MetaMask。一个常用的钱包应用,开发过程中可以通过浏览器插件与测试网、主网进行交互,方便开发者进行调试。
  3. Ganache。Ganache 是一个开源的虚拟本地节点,提供了一个虚拟链网络,可以通过各类 Web3.js、Remix 或一些框架工具与之交互,适合有一定规模的项目进行本地调试与测试。
  4. Infura。Infura 是一个 IaaS(Infrastructure as a Service)产品,我们可以申请自己的 Ethereum 节点,通过 Infura 提供的 API 进行交互,可以很方便地进行调试,也更接近生产环境。
  5. OpenZeppelin。OpenZeppelin 提供了非常多的合约开发库与应用,能兼顾安全、稳定的同时给予开发者更好的开发体验,降低合约开发成本。

合约编译/部署#

Solidity 合约是以 .sol 为后缀的文件,无法直接执行,需要编译为 EVM(Ethereum Virtual Machine)可识别的字节码才能在链上运行。

compile_solidity

编译完成后,由合约账户进行部署到链上,其他账户可通过钱包与合约进行交互,实现链上业务逻辑。