智能合约简介
一个简单的智能合约
让我们从一个基本的例子开始,它设置一个变量的值并将其暴露给其他合约访问。如果现在不理解所有内容也没关系,我们稍后会详细讲解。
存储示例
// 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 版本 3.0 许可的。第一行告诉你源代码是根据 GPL 版本 3.0 授权的。在默认发布源代码的环境中,机器可读的许可证说明符非常重要。
下一行指定源代码是为 Solidity 版本 0.4.16 或更新版本编写的,但不包括版本 0.9.0。这是为了确保合约不会与新的(破坏性)编译器版本编译,从而可能导致不同的行为。 Pragmas 是关于如何处理源代码的编译器常见指令(例如 pragma once)。
在 Solidity 的意义上,合约是代码(其 函数)和数据(其 状态)的集合,位于以太坊区块链上的特定地址。
uint storedData;
这一行声明了一个名为 storedData
的状态变量,类型为 uint
(256位符号整数)。
可以将其视为数据库中的一个单一插槽,可以通过调用管理数据库的代码的函数来查询和更改它。
在这个例子中,合约定义了可以用来修改或检索变量值的 set
和 get
函数。
要访问当前合约的成员(如状态变量),通常不需要添加 this.
前缀,只需通过其名称直接访问它。
与某些其他语言不同,省略它不仅仅是风格问题,它会导致完全不同的方式来访问成员,但稍后会详细讨论这一点。
这个合约目前除了(由于以太坊构建的基础设施)允许任何人存储一个单一的数字,该数字可以被世界上任何人访问,而没有(可行的)方法来阻止你发布这个数字,实际上并没有做太多事情。
任何人都可以再次调用 set
并使用不同的值覆盖你的数字,但这个数字仍然存储在区块链的历史中。
稍后你将看到如何施加访问限制,以便只有你可以更改这个数字。
警告
使用 Unicode 文本时要小心,因为看似相似(甚至相同)的字符可能具有不同的代码点,因此被编码为不同的字节数组。
备注
所有标识符(合约名称、函数名称和变量名称)都限制在 ASCII 字符集内。可以在字符串变量中存储 UTF-8 编码的数据。
子货币示例
以下合约实现了最简单形式的加密货币。该合约仅允许其创建者创建新币(不同的发行方案是可能的)。任何人都可以相互发送币,而无需注册用户名和密码,所需的只是以太坊密钥对。
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.26;
// 仅通过 IR 编译
contract Coin {
// 关键字 "public" 使变量可从其他合约访问
address public minter;
mapping(address => uint) public balances;
// 事件允许客户端对你声明的特定合约更改做出反应
event Sent(address from, address to, uint amount);
// 构造函数代码仅在合约被创建时运行
constructor() {
minter = msg.sender;
}
// 向地址发送一定数量的新创建的币
// 只能由合约创建者调用
function mint(address receiver, uint amount) public {
require(msg.sender == minter);
balances[receiver] += amount;
}
// 错误允许你提供有关操作失败的原因的信息。
// 它们被返回给函数的调用者。
error InsufficientBalance(uint requested, uint available);
// 从任何调用者向地址发送一定数量的现有币
function send(address receiver, uint amount) public {
require(amount <= balances[msg.sender], InsufficientBalance(amount, balances[msg.sender]));
balances[msg.sender] -= amount;
balances[receiver] += amount;
emit Sent(msg.sender, receiver, amount);
}
}
这个合约引入了一些新概念,让我们逐一了解它们。
address public minter;
这一行声明了一个类型为 address 的状态变量。
address
类型是一个 160 位的值,不允许任何算术操作。它适合存储合约的地址,或属于 external accounts 的密钥对的公钥哈希。
关键字 public
自动生成一个函数,允许从合约外部访问状态变量的当前值。如果没有这个关键字,其他合约将无法访问该变量。
编译器生成的函数代码等同于以下内容(暂时忽略 external
和 view
):
function minter() external view returns (address) { return minter; }
可以自己添加一个像上面这样的函数,但将有一个函数和一个状态变量同名。你不需要这样做,编译器会为你处理。
下一行 mapping(address => uint) public balances;
也创建了一个公共状态变量,但它是一个更复杂的数据类型。
mapping 类型将地址映射到 unsigned integers。
映射可以看作是 hash tables,它们在虚拟上初始化,使得每个可能的键从一开始就存在,并映射到一个字节表示全为零的值。 然而,无法获取映射的所有键的列表,也无法获取所有值的列表。记录你添加到映射中的内容,或在不需要此信息的上下文中使用它。或者更好的是,保持一个列表,或使用更合适的数据类型。
由 public
关键字创建的 getter function 在映射的情况下更复杂。它看起来像这样:
function balances(address account) external view returns (uint) {
return balances[account];
}
可以使用这个函数查询单个账户的余额。
event Sent(address from, address to, uint amount);
这一行声明了一个 “event”,它在函数 send
的最后一行被触发。
以太坊客户端,如 Web 应用程序,可以在区块链上监听这些事件,而成本不高。只要它被触发,监听者就会收到参数 from
、to
和 amount
,这使得跟踪交易成为可能。
要监听此事件,可以使用以下 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));
}
})
constructor 是在合约创建期间执行的特殊函数,之后无法调用。
在这种情况下,它永久存储创建合约的人的地址。
msg
变量(与 tx
和 block
一起)是一个 特殊的全局变量,它包含允许访问区块链的属性。
msg.sender
始终是当前(外部)函数调用来源的地址。
构成合约的函数,以及用户和合约可以调用的函数是 mint
和 send
。
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 语句 一起使用。
revert
语句无条件地中止并撤销所有更改,类似于 require 方法。
这两种方法都允许你提供错误的名称和附加数据,这些数据将提供给调用者(最终提供给前端应用程序或区块浏览器),以便更容易调试或响应失败。
send
函数可以被任何人使用(已经拥有一些这些硬币的人)将硬币发送给其他人。
如果发送者没有足够的硬币发送,则 if
条件评估为 true。因此,revert
将导致操作失败,同时使用 InsufficientBalance
错误向发送者提供错误详细信息。
备注
如果你使用此合约将硬币发送到一个地址,当你在区块链浏览器上查看该地址时,你将不会看到任何内容,因为你发送硬币的记录和更改的余额仅存储在此特定硬币合约的数据存储中。 通过使用事件,你可以创建一个跟踪你新硬币的交易和余额的“区块链浏览器”,但你必须检查硬币合约地址,而不是硬币所有者的地址。
区块链基础
区块链作为一个概念,对于程序员来说并不难理解。 原因在于大多数复杂性(挖矿、hashing、elliptic-curve cryptography、peer-to-peer networks 等)只是为了为平台提供一组特定的功能和承诺。 一旦你接受这些功能为既定事实,你就不必担心底层技术——或者你是否必须知道亚马逊的 AWS 如何在内部工作才能使用它?
交易
区块链是一个全球共享的事务性数据库。这意味着每个人都可以通过参与网络来读取数据库中的条目。如果你想更改数据库中的某些内容,你必须创建一个所谓的交易,该交易必须被所有其他人接受。交易一词意味着你想要进行的更改(假设你想同时更改两个值)要么根本不执行,要么完全应用。此外,当你的交易正在应用于数据库时,其他任何交易都无法更改它。
例如,想象一个列出所有账户在电子货币中余额的表。如果请求从一个账户转账到另一个账户,数据库的事务性质确保如果从一个账户中减去金额,它总是会添加到另一个账户。如果由于某种原因,无法将金额添加到目标账户,则源账户也不会被修改。
此外,交易始终由发送者(创建者)进行加密签名。这使得保护对数据库特定修改的访问变得简单。在电子货币的例子中,一个简单的检查确保只有持有账户密钥的人才能从中转移一些补偿,例如以太币。
区块
一个主要的障碍是克服在比特币术语中称为“双重支付攻击”的问题:如果网络中存在两个都想清空一个账户的交易,会发生什么? 只有一个交易可以是有效的,通常是首先被接受的那个。问题在于“首先”在点对点网络中不是一个客观的术语。
对此的抽象回答是,你不必关心。网络会为为你选择一个全球接受的交易顺序,从而解决冲突。这些交易将被打包成所谓的“区块”,然后它们将在所有参与节点之间执行和分发。 如果两个交易相互矛盾,最终成为第二个的交易将被拒绝,并且不会成为区块的一部分。
这些区块形成一个线性时间序列,这就是“区块链”一词的来源。区块以固定的间隔添加到链中,尽管这些间隔在未来可能会有所变化。 为了获得最新的信息,建议监控网络,例如在 Etherscan 上。
作为“顺序选择机制”的一部分,称为 attestation,可能会发生区块不时被撤销,但仅在链的“尖端”。 在特定区块上添加的区块越多,该区块被撤销的可能性就越小。 因此,你的交易可能会被撤销,甚至从区块链中删除,但你等待的时间越长,这种可能性就越小。
备注
交易不保证会被包含在下一个区块或任何特定的未来区块中, 因为这取决于矿工决定将交易包含在哪个区块中,而不是交易提交者。
如果你想安排未来对你的合约的调用,可以使用 智能合约自动化工具或预言机服务。
以太坊虚拟机
概述
以太坊虚拟机(EVM)是以太坊中智能合约的运行环境。 它不仅是沙箱环境,而且实际上是完全隔离的,这意味着在 EVM 内部运行的代码无法访问网络、文件系统或其他进程。 智能合约甚至对其他智能合约的访问也有限。
账户
以太坊中有两种类型的账户,它们共享相同的地址空间:外部账户 由公私钥对(即人类)控制,合约账户 由与账户一起存储的代码控制。
外部账户的地址由公钥确定,而合约的地址在合约创建时确定(它是从创建者地址和从该地址发送的交易数量,即所谓的“nonce”派生而来)。
无论账户是否存储代码,这两种类型在 EVM 中都是平等对待的。
每个账户都有一个持久的键值存储,将 256 位字映射到 256 位字,称为 存储。
此外,每个账户在以太币中都有一个 余额 (确切地说是“Wei”,1 ether
是 10**18 wei
),可以通过发送包含以太币的交易进行修改。
交易
交易是从一个账户发送到另一个账户(可能是相同的或为空,见下文)的消息。 它可以包含二进制数据(称为“有效载荷”)和以太币。
如果目标账户包含代码,则执行该代码,并将有效载荷作为输入数据提供。
如果目标账户未设置(交易没有接收者或接收者设置为 null
),则交易创建一个 新合约。
如前所述,该合约的地址不是零地址,而是从发送者及其发送的交易数量(“nonce”)派生的地址。
此类合约创建交易的有效载荷被视为 EVM 字节码并执行。此执行的输出数据将永久存储为合约的代码。
这意味着为了创建合约,你并不发送合约的实际代码,而是实际上发送在执行时返回该代码的代码。
备注
在合约创建期间,其代码仍然是空的。 因此,在构造函数执行完成之前,你不应回调正在构建的合约。
gas
在创建时,每个交易都会被收取一定数量的 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 位)扩展。 在扩展时,必须支付 gas 费用。内存的成本随着其增长而增加(其增长是平方级的)。
EVM 不是寄存器机器,而是栈机器,因此所有计算都在称为 栈 的数据区域上执行。 它的最大大小为1024 个元素,包含 256 位的字。对栈的访问仅限于顶部,以以下方式进行: 可以将顶部 16 个元素中的一个复制到栈顶,或将栈顶元素与下面的 16 个元素中的一个交换。 所有其他操作从栈中取出顶部两个(或一个或多个,具体取决于操作)元素,并将结果推送到栈上。 当然,可以将栈元素移动到存储或内存中,以便更深入地访问栈,但在未先移除栈顶元素的情况下,无法直接访问栈中更深处的任意元素。
调用数据、返回数据和代码
还有其他数据区域,它们并不像之前讨论的那些那样明显。 然而,它们在智能合约交易的执行过程中被常规使用。
调用数据区域是作为智能合约交易的一部分发送到交易的数据。
例如,在创建合约时,调用数据将是新合约的构造函数代码。
外部函数的参数总是最初以 ABI 编码的形式存储在调用数据中,然后再解码到其声明中指定的位置。
如果声明为 memory
,编译器将在函数开始时急切地将其解码到内存中,
而标记为 calldata
则意味着这将在访问时懒惰地进行。
值类型和 storage
指针直接解码到栈上。
返回数据是智能合约在调用后返回值的方式。
一般来说,外部 Solidity 函数使用 return
关键字将值 ABI 编码到返回数据区域。
代码是存储智能合约的 EVM 指令的区域。 代码是 EVM 在智能合约执行期间读取、解释和执行的字节。 存储在代码中的指令数据作为合约账户状态字段的一部分是持久的。 不可变和常量变量存储在代码区域。 所有对不可变变量的引用都被替换为分配给它们的值。 常量的处理方式类似,其表达式在智能合约代码中引用的地方被内联。
指令集
EVM 的指令集保持最小化,以避免可能导致共识问题的不正确或不一致的实现。 所有指令都在基本数据类型 256 位字或内存切片(或其他字节数组)上操作。 常见的算术、位、逻辑和比较操作都具备。 条件和无条件跳转都是可能的。此外,合约可以访问当前区块的相关属性,如其编号和时间戳。
有关完整列表,请参见 opcodes 列表,作为内联汇编文档的一部分。
消息调用
合约可以通过消息调用调用其他合约或向非合约账户发送以太币。 消息调用类似于交易,因为它们具有源、目标、数据负载、以太币、 gas 和返回数据。 实际上,每个交易都由一个顶层消息调用组成,而该调用又可以创建进一步的消息调用。
合约可以决定其剩余的 gas 中有多少应与内部消息调用一起发送,以及它希望保留多少。 如果内部调用发生了耗尽 gas 异常(或任何其他异常),这将通过放置在堆栈上的错误值进行信号传递。 在这种情况下,仅与调用一起发送的 gas 被消耗。 在 Solidity 中,调用合约在这种情况下默认会导致手动异常,因此异常会“冒泡”到调用堆栈。
如前所述,被调用的合约(可以与调用者相同)将接收到一个新清除的内存实例,并可以访问调用负载 - 这将在一个称为 calldata 的单独区域中提供。 在执行完成后,它可以返回数据,这些数据将存储在调用者预分配的内存位置中。 所有此类调用都是完全同步的。
调用的深度 限制 为 1024,这意味着对于更复杂的操作,应优先使用循环而不是递归调用。 此外,在消息调用中只能转发 63/64 的 gas ,这在实践中导致深度限制略低于 1000。
委托调用和库
存在一种特殊的消息调用变体,称为 delegatecall,它与消息调用相同,唯一的区别在于目标地址的代码在调用合约的上下文(即地址)中执行,并且 msg.sender
和 msg.value
的值不会改变。
这意味着合约可以在运行时动态加载来自不同地址的代码。存储、当前地址和余额仍然指向调用合约,只有代码是从被调用地址获取的。
这使得在 Solidity 中实现“库”功能成为可能:可重用的库代码可以应用于合约的存储,例如为了实现复杂的数据结构。
日志
可以将数据存储在一个特殊索引的数据结构中,该结构映射到区块级别。 这个称为 日志 的特性被 Solidity 用于实现 事件。 合约在创建日志数据后无法访问该数据,但可以从区块链外部高效访问。 由于日志数据的一部分存储在 bloom filters 中,因此可以以高效且加密安全的方式搜索这些数据,因此不下载整个区块链的网络节点(所谓的“轻客户端”)仍然可以找到这些日志。
创建
合约甚至可以使用特殊的操作码创建其他合约(即它们并不只是像交易那样调用零地址)。 这些 创建调用 与普通消息调用之间的唯一区别在于有效负载数据被执行,并且结果作为代码存储,调用者/创建者在堆栈上接收新合约的地址。
停用和自毁
从区块链中移除代码的唯一方法是当该地址上的合约执行 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
的调用,
它仍然可以通过 delegatecall
或 callcode
执行该操作。
如果你想停用你的合约,你应该通过更改某些内部状态来 禁用 它们,这会导致所有函数回退。 这使得合约无法使用,因为它会立即返回以太币。 .. index:: ! 预编译合约, ! 预编译, ! 合约;预编译
预编译合约
有一小部分合约地址是特殊的:
地址范围在 1
和(包括)``0x0a`` 之间包含了“预编译合约”,可以像其他合约一样调用,
但它们的行为(及其 gas 消耗)并不是由存储在该地址的 EVM 代码定义的(它们不包含代码),
而是由 EVM 执行环境本身实现的。
不同的 EVM 兼容链可能使用不同的预编译合约。
未来也可能会在以太坊主链上添加新的预编译合约,
但你可以合理地预期它们始终在 1
和 0xffff
(包括)之间。