部分内容与前文互补。
文章目录
- 一个简单的智能合约
- 子货币(Subcurrency)示例
- 区块链基础
- 交易
- 区块
- 预编译合约
一个简单的智能合约
我们从一个基础示例开始,该示例用于设置变量的值,并允许其他合约访问它。
// 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 许可证进行授权。在以源代码公开为默认规则的环境中,使用机器可读的许可证标识符是非常重要的。
接下来一行 pragma solidity >=0.4.16 <0.9.0;
指定了该合约适用于 Solidity 0.4.16 及以上版本,但不包括 0.9.0。这是为了确保合约不会在未来的破坏性更新(Breaking Changes)中出现兼容性问题。Pragma 语句是编译器的指令,类似于 C/C++ 语言中的 pragma once,用于指定源代码的编译方式。
在 Solidity 语言中,合约(contract) 本质上是一个代码(函数)和数据(状态)的集合,它们驻留在以太坊区块链上的特定地址处。
contract SimpleStorage {uint storedData;function set(uint x) public {storedData = x;}function get() public view returns (uint) {return storedData;}
}
在合约 SimpleStorage 中,uint storedData;
声明了一个状态变量 storedData,其类型为 uint(无符号整数,默认为 256 位)。你可以把它看作数据库中的一个单一存储槽位,可以通过调用合约中的函数来查询和修改它。在这个示例中,合约提供了 set 和 get 两个函数,分别用于修改和获取 storedData 的值。
在 Solidity 中,访问当前合约的成员变量(如 storedData),通常无需使用 this. 前缀,直接使用变量名即可。这不仅仅是代码风格的问题,而是影响访问方式的关键区别(后续会详细讲解)。
这个合约本身功能还比较简单,但得益于以太坊的基础架构,它允许任何人存储一个数值,并让全球范围内的任何人访问。理论上,没有任何方法可以阻止你发布这个数值。但需要注意,任何人都可以再次调用 set 方法,修改存储的值,并覆盖之前的数据。不过,之前存储的数据仍然会保留在区块链的历史记录中。
后续会介绍如何实现访问权限控制,以便只有你自己才能修改这个值。
警告:使用 Unicode 文本时需要小心,因为一些看起来相似甚至完全相同的字符,可能具有不同的代码点(Code Point),因此它们的字节编码可能不同,从而引发安全或兼容性问题。
注意:所有标识符(包括合约名、函数名和变量名)都必须使用 ASCII 字符集。不过,你仍然可以在 string 类型的变量中存储 UTF-8 编码的数据。
子货币(Subcurrency)示例
以下合约实现了最简单形式的加密货币。该合约仅允许其创建者铸造新币。任何人都可以在没有用户名和密码的情况下相互转账,所需的只是一个以太坊密钥对。
// 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;}// 错误(Errors)允许提供有关操作失败原因的信息// 这些信息会返回给调用该函数的用户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 位的值,不允许执行任何算术运算。它适用于存储合约地址,或者存储外部账户(EOA)公钥哈希的一部分。
关键字 public 会自动生成一个函数,使外部可以访问当前合约的状态变量。如果没有 public,其他合约将无法访问该变量。
编译器生成的代码等效于以下函数(暂时忽略 external 和 view 关键字):
function minter() external view returns (address) { return minter;
}
下一行代码:
mapping(address => uint) public balances;
这行代码同样定义了一个 public 状态变量,但它的类型比 address 更复杂。mapping 是 Solidity 提供的一种映射类型,它将地址映射到 uint(无符号整数),即每个地址对应一个余额。
mapping 的特性:
-
mapping 类似于哈希表,所有可能的键在初始化时就已经存在,并默认映射到 0(即字节表示全为零)。
-
无法获取 mapping 的所有键或所有值,因此如果你需要跟踪存储在 mapping 中的数据,最好自己维护一个列表,或者使用更合适的数据结构。
使用 mapping 是因为它提供了一种高效且简洁的方式来关联每个地址与其余额,且适应了区块链中分布式账本的特点。
由于 balances 变量是 public,编译器会自动生成以下 getter 函数:
function balances(address account) external view returns (uint) {return balances[account];
}
这个函数可以用于查询某个账户的余额,例如:
uint myBalance = contract.balances(myAddress);
这样,你就可以直接在外部访问某个地址的 balance,而无需手动编写 getter 方法。
这一行代码:
event Sent(address from, address to, uint amount);
声明了一个 事件(event),它在 send 函数的最后一行被触发(emit)。像 Web 应用程序这样的以太坊客户端可以监听这些事件,而不会产生太多成本。
当事件被触发后,监听器会立即收到 from、to 和 amount 这三个参数,从而能够跟踪交易。
刚才提到的以太坊客户端使用以下 JavaScript 代码(web3.js)监听 Sent 事件,并调用 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() {minter = msg.sender;
}
其中,msg 是 Solidity 提供的全局变量,它包含了一些区块链相关的属性,比如msg.sender为当前调用该函数的外部账户(EOA)或合约地址。
这个合约有两个主要的用户调用函数:
-
mint —— 铸造新币
-
send —— 发送已存在的币
mint(铸造新币)
function mint(address receiver, uint amount) public {require(msg.sender == minter);balances[receiver] += amount;
}
只有合约的创建者(minter)可以调用 mint,因为:
require(msg.sender == minter);
如果 msg.sender 不是 minter,则交易会被回滚(revert)。
balances[receiver] += amount; 为接收者账户增加一定数量的新币。
注意: 虽然 minter 可以无限制铸造代币,但如果 balances[receiver] + amount 超过 uint 类型的最大值 2的256次方 - 1,就会导致溢出(overflow)。然而,Solidity 默认启用了 Checked arithmetic(溢出检查),所以如果溢出发生,交易会自动回滚。
send(发送币)
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);
}
任何人(已经拥有币的人)都可以调用 send,将币发送给其他人。
如果 msg.sender 的余额不足:
require(amount <= balances[msg.sender], InsufficientBalance(amount, balances[msg.sender]));
交易会回滚(revert),并返回 InsufficientBalance 错误,错误信息会提供给调用者,以便前端应用或区块浏览器能够显示失败的具体原因。
Solidity 允许在交易失败时提供更多的错误信息,以便前端应用可以更容易地调试或做出反应。
错误信息通过 revert 语句触发:
error InsufficientBalance(uint requested, uint available);
当 require 失败时,它会返回 InsufficientBalance,并提供请求的金额 requested 和可用余额 available。
注意,在这个例子中,所有的代币操作(如铸造、转账)都在合约内部完成,余额和交易信息是局部的,仅存储在合约的 balances 映射中。
普通区块链浏览器(如 Etherscan)只能显示以太坊全局账户余额,你不会在普通的区块浏览器中看到余额变化。
解决方案:监听 Sent 事件,并创建自己的区块链浏览器来跟踪交易记录和余额变化,但你查询合约地址(通过合约内部的查询函数),而不是代币持有人的地址。
区块链基础
区块链作为一个概念对于程序员来说并不难理解。大多数复杂性(如哈希、椭圆曲线加密、对等网络等)只是为了为平台提供一组特定的功能和承诺。一旦你接受了这些特性作为前提,你就不必担心底层技术——就像你不需要知道亚马逊的 AWS 是如何在内部工作的。
交易
区块链是一个全球共享的事务性数据库。这意味着每个人都可以通过参与网络来读取数据库中的条目。如果你想更改数据库中的内容,你必须创建一个所谓的“交易”,并且这个交易必须被所有其他参与者接受。
“交易”一词意味着你想要进行的更改(假设你同时想更改两个值)要么完全不做,要么完全应用。此外,在你的交易被应用到数据库时,其他交易不能修改它。
例如,假设有一个表格列出了所有账户的余额。如果请求从一个账户转账到另一个账户,数据库的事务性特征确保如果从一个账户扣除金额,这个金额始终会被加到另一个账户上。如果由于某种原因无法将金额添加到目标账户,源账户也不会被修改。
此外,交易总是由发送者(创建者)进行加密签名。这使得保护对数据库特定修改的访问变得简单。举个例子,只有持有账户密钥的人可以从中转移一定的货币。
区块
需要克服的一个主要问题是双重支付攻击:“如果在网络中有两个交易都想清空一个账户,该怎么办?”
解决方案是:只有其中一个交易可以是有效的,通常是先被接受的那个。
问题在于,“先”在对等网络中并不是一个客观的术语。
对此的抽象回答是:你不需要担心。一个全球公认的交易顺序会为你选定,从而解决冲突。这些交易会被打包成一个叫做“区块”的内容,然后被执行并在所有参与节点之间分发。如果两个交易互相矛盾,第二个交易会被拒绝,并不会成为区块的一部分。
这些区块形成了一个线性时间序列,这也是“区块链”这一术语的来源。区块会在定期的间隔时间内添加到链中,尽管这些间隔时间将来可能会发生变化。为了获取最新的信息,建议监控网络,例如通过 Etherscan。
可能会发生区块偶尔被回滚的情况,但仅限于“链顶”部分。这是因为越多的区块添加到某个区块上时,这个区块被回滚的可能性就越小。所以,可能会出现你的交易被回滚甚至从区块链中移除的情况,但等待的时间越长,这种情况发生的可能性就越小。
注意
交易并不能保证会包含在下一个区块或任何特定的未来区块中,因为是否将交易包含在区块中并不是由交易提交者决定的,而是由矿工决定交易被包含在哪个区块中。
如果我们想安排未来的智能合约调用,可以使用智能合约自动化工具(比如定时触发某个操作,或者基于某个事件触发合约的函数调用)或预言机服务。
预编译合约
在以太坊中,智能合约通常用 Solidity 编写,并转换为 EVM 字节码执行。但一些计算(例如椭圆曲线加密、哈希计算)如果用 Solidity 实现,会消耗大量 Gas,甚至无法在区块 Gas 限制内完成。因此,以太坊提供了一组内置的预编译合约。
地址范围 0x01 到 0x0a(包含 0x0a) 属于预编译合约(Precompiled Contracts)。这些合约可以像普通合约一样被调用,但它们的行为(包括 Gas 消耗)并不是由存储在这些地址上的 EVM 代码决定的。这些合约直接在 EVM 层面执行,比普通智能合约运行更高效,并且Gas 消耗更少。
这些合约特别适用于密码学、哈希计算、零知识证明等高计算量的任务。