智能合约简介

一个简单的智能合约

让我们从一个基本示例开始,该示例设置变量的值并将其公开供其他合约访问。如果您现在还不理解所有内容,不要担心,我们将在后面详细介绍。

存储示例

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.16 <0.9.0;

contract SimpleStorage {
    uint storedData;

    function set(uint x) public {
        storedData = x;
    }

    function get() public view returns (uint) {
        return storedData;
    }
}

第一行告诉您源代码是在 GPL v3.0 许可下发布的。在默认情况下发布源代码的环境中,机器可读的许可证说明符很重要。

下一行指定源代码是为 Solidity 版本 0.4.16 或更高版本的语言(直至但不包括版本 0.9.0)编写的。这样做是为了确保合约无法使用新的(破坏性)编译器版本进行编译,因为在该版本中其行为可能不同。 Pragmas 是编译器关于如何处理源代码的常用指令(例如, pragma once)。

从 Solidity 的角度来看,合约是代码(其函数)和数据(其状态)的集合,它驻留在以太坊区块链上的特定地址。代码行 uint storedData; 声明了一个名为 storedData 的状态变量,其类型为 uint无符号整数256 位)。您可以将其视为数据库中的一个插槽,您可以通过调用管理数据库的代码的函数来查询和更改该插槽。在本例中,合约定义了 setget 函数,这些函数可用于修改或检索变量的值。

要访问当前合约的成员(例如状态变量),您通常不会添加 this. 前缀,而是直接通过其名称访问它。与其他一些语言不同,省略它不仅仅是风格问题,它会导致访问成员的方式完全不同,但我们稍后会详细介绍。

除了(由于以太坊构建的基础设施)允许任何人存储一个单一数字,该数字可供世界上任何人访问,并且没有(可行)的方式阻止您发布此数字外,此合约目前还没有做太多事情。任何人可以再次使用不同的值调用 set 并覆盖您的数字,但该数字仍然存储在区块链的历史记录中。稍后,您将了解如何实施访问限制,以便只有您才能更改该数字。

警告

使用 Unicode 文本时要小心,因为看起来相似(甚至相同)的字符可能具有不同的代码点,因此被编码为不同的字节数组。

注意

所有标识符(合约名称、函数名称和变量名称)都限于 ASCII 字符集。可以在字符串变量中存储 UTF-8 编码的数据。

子币示例

以下合约实现了最简单的加密货币形式。该合约仅允许其创建者创建新币(可以实现不同的发行方案)。任何人都可以在彼此之间发送代币,而无需注册用户名和密码,您只需要一个以太坊密钥对。

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.4;

contract Coin {
    // The keyword "public" makes variables
    // accessible from other contracts
    address public minter;
    mapping(address => uint) public balances;

    // Events allow clients to react to specific
    // contract changes you declare
    event Sent(address from, address to, uint amount);

    // Constructor code is only run when the contract
    // is created
    constructor() {
        minter = msg.sender;
    }

    // Sends an amount of newly created coins to an address
    // Can only be called by the contract creator
    function mint(address receiver, uint amount) public {
        require(msg.sender == minter);
        balances[receiver] += amount;
    }

    // Errors allow you to provide information about
    // why an operation failed. They are returned
    // to the caller of the function.
    error InsufficientBalance(uint requested, uint available);

    // Sends an amount of existing coins
    // from any caller to an address
    function send(address receiver, uint amount) public {
        if (amount > balances[msg.sender])
            revert InsufficientBalance({
                requested: amount,
                available: balances[msg.sender]
            });

        balances[msg.sender] -= amount;
        balances[receiver] += amount;
        emit Sent(msg.sender, receiver, amount);
    }
}

此合约引入了一些新概念,让我们逐一介绍它们。

代码行 address public minter; 声明了一个类型为 address 的状态变量。 address 类型是一个 160 位的值,不允许任何算术运算。它适用于存储合约地址或属于 外部账户 的密钥对的公钥一半的哈希值。

关键字 public 会自动生成一个函数,该函数允许您从合约外部访问状态变量的当前值。没有此关键字,其他合约就无法访问该变量。编译器生成的函数代码等效于以下代码(现在忽略 externalview):

function minter() external view returns (address) { return minter; }

您可以自己添加类似上面的函数,但您将拥有具有相同名称的函数和状态变量。您无需执行此操作,编译器会为您解决。

下一行, mapping(address => uint) public balances; 也会创建一个公共状态变量,但它是一个更复杂的数据类型。 mapping 类型将地址映射到 无符号整数

映射可以被视为 哈希表,它们被虚拟初始化,因此从一开始就存在每个可能的键,并且映射到一个字节表示为全零的值。但是,既无法获取映射的所有键列表,也无法获取所有值列表。记录您添加到映射的内容,或在不需要此内容的情况下使用它。或者更进一步,保持一个列表,或使用更适合的数据类型。

public 关键字创建的 getter 函数 在映射的情况下更为复杂。它看起来像这样:

function balances(address account) external view returns (uint) {
    return balances[account];
}

您可以使用此函数查询单个账户的余额。

代码行 event Sent(address from, address to, uint amount); 声明了一个 “事件”,该事件在函数 send 的最后一行发出。以太坊客户端(如 Web 应用程序)可以监听在区块链上发出的这些事件,而无需花费太多成本。一旦它被发出,监听器就会收到参数 fromtoamount,这使得跟踪交易成为可能。

要监听此事件,您可以使用以下 JavaScript 代码,该代码使用 web3.js 创建 Coin 合约对象,任何用户界面都会调用上面自动生成的 balances 函数:

Coin.Sent().watch({}, '', function(error, result) {
    if (!error) {
        console.log("Coin transfer: " + result.args.amount +
            " coins were sent from " + result.args.from +
            " to " + result.args.to + ".");
        console.log("Balances now:\n" +
            "Sender: " + Coin.balances.call(result.args.from) +
            "Receiver: " + Coin.balances.call(result.args.to));
    }
})

构造函数 是一个特殊函数,在创建合约期间执行,之后无法调用。在本例中,它会永久存储创建合约的人的地址。 msg 变量(以及 txblock)是一个 特殊的全局变量,它包含允许访问区块链的属性。 msg.sender 始终是当前(外部)函数调用的地址。

构成合约并可由用户和合约调用的函数是 mintsend

函数 mint 用于将一定数量的新创建的代币发送到另一个地址。 require 函数调用定义了一些条件,如果这些条件不满足,则会回滚所有更改。在这个示例中,require(msg.sender == minter); 确保只有合约的创建者才能调用 mint。一般来说,创建者可以根据自己的意愿铸造任意数量的代币,但最终会导致“溢出”现象。需要注意的是,由于默认的 Checked arithmetic,如果表达式 balances[receiver] += amount; 溢出,则事务将会回滚,即当 balances[receiver] + amount 在任意精度算术运算中大于 uint 的最大值 (2**256 - 1) 时。这也适用于函数 send 中的语句 balances[receiver] += amount;

Errors 允许你向调用者提供更多关于条件或操作失败原因的信息。错误与 revert statement 一起使用。revert 语句会无条件地中止并回滚所有更改,类似于 require 函数,但它还允许你提供错误名称和额外数据,这些数据将提供给调用者(并最终提供给前端应用程序或区块浏览器),以便更容易地调试或处理失败。

函数 send 可以被任何人(已经拥有一些代币)用于向其他人发送代币。如果发送者没有足够的代币来发送,则 if 条件将评估为真。因此,revert 将导致操作失败,同时使用 InsufficientBalance 错误向发送者提供错误详细信息。

注意

如果你使用此合约将代币发送到某个地址,你不会在区块链浏览器中看到任何内容,因为你发送代币的记录以及更改的余额只存储在这个特定代币合约的数据存储中。通过使用事件,你可以创建一个“区块链浏览器”来跟踪你新代币的事务和余额,但你必须检查代币合约地址,而不是代币所有者的地址。

区块链基础

对于程序员来说,区块链的概念并不难理解。原因是大多数复杂情况(挖矿、哈希椭圆曲线密码学点对点网络等)只是为了为平台提供一组特定的功能和承诺。一旦你接受了这些功能,你就不必担心底层技术 - 或者你是否需要知道亚马逊 AWS 的内部工作原理才能使用它?

事务

区块链是一个全局共享的事务数据库。这意味着每个人都可以通过参与网络来读取数据库中的条目。如果你想更改数据库中的某些内容,你必须创建一个所谓的交易,该交易必须得到所有其他参与者的认可。事务一词意味着你要进行的更改(假设你想要同时更改两个值)要么完全不做,要么完全应用。此外,在你的事务被应用到数据库的过程中,其他事务不能更改它。

例如,想象一个表格,其中列出了电子货币中所有账户的余额。如果请求从一个账户到另一个账户的转账,则数据库的事务性质确保如果从一个账户中减去金额,则该金额始终会被添加到另一个账户中。如果由于某种原因,无法将金额添加到目标账户,则也不会修改源账户。

此外,事务总是由发送者(创建者)进行加密签名。这使得保护对数据库特定修改的访问变得直截了当。以电子货币为例,一个简单的检查可以确保只有拥有账户密钥的人才能从中转账一些补偿,例如以太坊。

区块

要克服的一个主要障碍是在比特币术语中被称为“双重支付攻击”:如果网络中存在两个都想要清空一个账户的事务,会发生什么?只有一笔事务是有效的,通常是第一个被接受的事务。问题是,“第一个”在点对点网络中不是一个客观术语。

对这个问题的抽象答案是你不必担心。一个全局接受的事务顺序将为你选择,从而解决冲突。这些事务将被捆绑到一个被称为“区块”的集合中,然后它们将被执行并分发到所有参与节点中。如果两个事务相互矛盾,那么第二个被执行的事务将被拒绝,并且不会成为区块的一部分。

这些区块在时间上形成了一个线性序列,这就是“区块链”这个词的由来。区块以规律的时间间隔添加到链中,尽管这些间隔将来可能会发生变化。为了获得最新信息,建议监控网络,例如在 Etherscan 上。

作为“顺序选择机制”的一部分,它被称为 证明,可能会发生区块不时被回滚,但只发生在链的“顶端”。在某个特定区块之上添加的区块越多,该区块被回滚的可能性就越小。因此,你的事务可能被回滚甚至从区块链中删除,但你等待的时间越长,这种可能性就越小。

注意

事务并不能保证被包含在下一个区块或任何特定的未来区块中,因为这取决于矿工而不是事务提交者来决定将事务包含在哪个区块中。

如果你想安排将来调用你的合约,你可以使用智能合约自动化工具或预言机服务。

以太坊虚拟机

概述

以太坊虚拟机或 EVM 是以太坊中智能合约的运行时环境。它不仅被沙盒化,而且实际上是完全隔离的,这意味着在 EVM 中运行的代码无法访问网络、文件系统或其他进程。智能合约甚至对其他智能合约的访问权限也有限。

账户

以太坊中有两种类型的账户,它们共享相同的地址空间:由公钥-私钥对控制的**外部账户**(即人类)和由与账户一起存储的代码控制的**合约账户**。

外部账户的地址由公钥决定,而合约的地址在合约创建时决定(它由创建者地址和从该地址发送的交易数量推导出来,即所谓的“nonce”。

无论账户是否存储代码,这两种类型都被 EVM 同等对待。

每个账户都有一个持久性的键值存储映射,将 256 位字映射到 256 位字,称为**存储**。

此外,每个账户都有一个以太坊(准确地说是“Wei”)的**余额**,它可以通过发送包含以太坊的事务来修改。 (1 ether10**18 wei)。

事务

事务是从一个账户到另一个账户(可能是相同的或空的,见下文)发送的消息。它可以包含二进制数据(称为“有效负载”)和以太坊。

如果目标账户包含代码,则会执行该代码,并将有效负载作为输入数据提供。

如果目标账户未设置(事务没有接收者,或者接收者被设置为 null),则事务将创建一个**新合约**。如前所述,该合约的地址不是零地址,而是一个从发送者及其发送的交易数量(“nonce”)推导出来的地址。此类合约创建事务的有效负载被视为 EVM 字节码并执行。此执行的输出数据被永久存储为合约的代码。这意味着要创建合约,你不会发送合约的实际代码,而是实际上发送执行时返回该代码的代码。

注意

在创建合约的过程中,它的代码仍然为空。因此,在构造函数执行完成之前,你不应该回调正在构建的合约。

汽油

创建后,每个事务都会收取一定数量的**gas**,该费用必须由事务的发起者(tx.origin)支付。当 EVM 执行事务时,gas 会根据特定规则逐渐减少。如果 gas 在任何时候用完(即变为负数),则会触发 gas 耗尽异常,这将结束执行并回滚对当前调用帧中状态进行的所有修改。

这种机制鼓励节约地使用 EVM 执行时间,并补偿 EVM 执行者(即矿工/质押者)的工作。由于每个区块都有最大 gas 量,因此它也限制了验证区块所需的工作量。

**gas 价格**是由事务发起者设置的值,他必须预先向 EVM 执行者支付 gas_price * gas。如果执行后剩余一些 gas,则会退还给事务发起者。如果出现回滚更改的异常,则已用完的 gas 不会退还。

由于 EVM 执行者可以选择包含或不包含事务,因此事务发送者无法通过设置较低的 gas 价格来滥用系统。

存储、内存和堆栈

以太坊虚拟机有三个区域可以存储数据:存储、内存和堆栈。

每个账户都有一个名为 **存储** 的数据区域,它在函数调用和事务之间是持久的。存储是一个键值存储,它将 256 位字映射到 256 位字。无法从合约内部枚举存储,读取存储比较昂贵,初始化和修改存储则更加昂贵。由于这种成本,您应该将持久存储中存储的内容最小化,仅限于合约运行所需的内容。将派生计算、缓存和聚合等数据存储在合约外部。合约既不能读取也不能写入任何与其自身存储不同的存储。

第二个数据区域称为 **内存**,合约为每个消息调用获取一个新的清除实例。内存是线性的,可以在字节级别寻址,但读取仅限于 256 位的宽度,而写入可以是 8 位或 256 位的宽度。当访问(读取或写入)之前未触碰的内存字(即字内的任何偏移量)时,内存会按一个字(256 位)扩展。在扩展时,必须支付汽油成本。内存越大,成本越高(呈二次方增长)。

EVM 不是寄存器机器,而是一个堆栈机器,因此所有计算都在名为 **堆栈** 的数据区域上执行。它最大尺寸为 1024 个元素,包含 256 位的字。对堆栈的访问仅限于顶端,方式如下:可以将最上面的 16 个元素中的一个复制到堆栈顶部,或者将最上面的元素与下面的 16 个元素中的一个交换。所有其他操作都从堆栈中取出最上面的两个(或一个,或多个,取决于操作)元素,并将结果压入堆栈。当然,可以将堆栈元素移动到存储或内存以获得更深的堆栈访问权限,但无法在不先移除堆栈顶部的元素的情况下直接访问堆栈中更深的任意元素。

指令集

EVM 的指令集保持最小,以避免可能导致共识问题的错误或不一致的实现。所有指令都作用于基本数据类型,即 256 位字或内存片(或其他字节数组)。提供了通常的算术、位、逻辑和比较运算。条件和无条件跳转是可能的。此外,合约可以访问当前块的相关属性,例如其编号和时间戳。

有关完整列表,请参阅 操作码列表,这是内联汇编文档的一部分。

消息调用

合约可以通过消息调用来调用其他合约或向非合约账户发送以太坊。消息调用类似于事务,因为它们具有源、目标、数据有效负载、以太坊、汽油和返回数据。实际上,每个事务都包含一个顶级消息调用,它反过来可以创建进一步的消息调用。

合约可以决定其剩余 **汽油** 的多少应该与内部消息调用一起发送,以及想要保留多少。如果在内部调用中发生汽油不足异常(或任何其他异常),则会通过将错误值置于堆栈上来发出信号。在这种情况下,只有与调用一起发送的汽油才会被使用。在 Solidity 中,调用合约默认情况下在这种情况下降会引起手动异常,以便异常“冒泡”到调用堆栈。

如前所述,被调用合约(可以与调用者相同)将收到内存的新清除实例,并可以访问调用有效负载 - 这将在一个名为 **calldata** 的单独区域中提供。它完成执行后,可以返回数据,这些数据将存储在调用者内存中的由调用者预分配的位置。所有此类调用都是完全同步的。

调用 **受限于** 1024 的深度,这意味着对于更复杂的操作,应优先选择循环而不是递归调用。此外,只有 63/64 的汽油可以转发到消息调用中,这在实践中导致深度限制略小于 1000。

Delegatecall 和库

存在一种名为 **delegatecall** 的特殊消息调用变体,它与消息调用相同,不同之处在于目标地址的代码在调用合约的上下文中(即地址)执行,并且 msg.sendermsg.value 不会改变其值。

这意味着合约可以在运行时从不同的地址动态加载代码。存储、当前地址和余额仍然引用调用合约,只有代码是从被调用地址获取的。

这使得在 Solidity 中实现“库”功能成为可能:可重用库代码,可以应用于合约的存储,例如,为了实现复杂的数据结构。

日志

可以将数据存储在专门的索引数据结构中,该结构映射到块级别。此功能称为 **日志**,由 Solidity 用于实现 事件。合约无法在创建日志数据后访问它,但可以从区块链外部有效地访问它。由于日志数据的一部分存储在 布隆过滤器 中,因此可以以高效且加密安全的方式搜索此数据,因此不下载整个区块链的网络对等节点(所谓的“轻客户端”)仍然可以找到这些日志。

创建

合约甚至可以使用特殊操作码创建其他合约(即它们不只是像事务那样调用零地址)。这些 **创建调用** 与普通消息调用的唯一区别在于有效负载数据被执行,结果被存储为代码,并且调用者/创建者在堆栈上接收到新合约的地址。

停用和自我销毁

从区块链中删除代码的唯一方法是当该地址的合约执行 selfdestruct 操作时。存储在该地址的剩余以太坊将被发送到指定的目标,然后存储和代码将从状态中删除。从理论上讲,删除合约听起来像是一个好主意,但它可能很危险,因为如果有人向已删除的合约发送以太坊,那么以太坊将永远丢失。

警告

EVM >= Cancun 开始,selfdestruct 将 **仅** 将账户中的所有以太坊发送给给定的接收者,而不是销毁合约。但是,当在创建调用它的合约的同一事务中调用 selfdestruct 时,selfdestruct 在 Cancun 硬分叉之前的行为(即 EVM <= Shanghai)将被保留,并将销毁当前合约,删除任何数据,包括存储键、代码和账户本身。有关更多详细信息,请参阅 EIP-6780

新行为是网络范围内的更改的结果,它影响了以太坊主网和测试网上存在的所有合约。请务必注意,此更改取决于部署合约的链的 EVM 版本。编译合约时使用的 --evm-version 设置与它无关。

此外,请注意 selfdestruct 操作码在 Solidity 版本 0.8.18 中已弃用,如 EIP-6049 所建议。弃用仍然有效,编译器仍然会在使用它时发出警告。强烈建议不要在新部署的合约中使用它,即使考虑到新的行为。将来对 EVM 的更改可能会进一步减少操作码的功能。

警告

即使合约被 selfdestruct 删除,它仍然是区块链历史的一部分,并且可能被大多数以太坊节点保留。因此,使用 selfdestruct 与从硬盘中删除数据并不相同。

注意

即使合约的代码不包含对 selfdestruct 的调用,它仍然可以使用 delegatecallcallcode 执行该操作。

如果您想停用您的合约,您应该改为通过更改某些内部状态来 **禁用** 它们,这会导致所有函数恢复。这使得无法使用合约,因为它会立即返回以太坊。

预编译合约

有一小部分合约地址是特殊的:1 和(包括)8 之间的地址范围包含“预编译合约”,可以像任何其他合约一样调用它们,但它们的行为(及其汽油消耗)不是由存储在该地址的 EVM 代码定义的(它们不包含代码),而是由 EVM 执行环境本身实现。

不同的 EVM 兼容链可能使用不同的预编译合约集。将来可能还会向以太坊主链添加新的预编译合约,但您可以合理地预期它们始终位于 10xffff(含)之间的范围内。