智能合约概述
智能合约就是区块链上运行的软件,它常常被类比为「自动贩卖机」,因为大家认为这样比较容易理解: 自动贩卖机能接受并执行外部的指令。当顾客选定商品并付款后, 自动贩卖机将释放商品给顾客, 并不需要额外的人工介入:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WOCdkEor-1650265504516)(https://www.li6.cc/assets/img/loading2.gif)]
智能合约的概念最早由电脑科学家、密码学家Nick Szabo在1994年提出, 不过当时并没有合适的环境实现。由于区块链上的交易具有可追溯、抗篡改、不可逆转的特性, 使智能合约在没有第三方中间人的情况下,也可以进行安全的交易,这才使得自动化执行的智能合约得以落地。
而以太坊由于内置了虚拟机和开发语言,这使得在以太坊区块链上开发智能合约的效率大大提高、难度 大大降低。因此,现在提到智能合约,基本上大家说的都是以太坊上的智能合约。
在这一部分的课程中,我们将学习以下内容:
使用solidity开发ERC20代币合约 使用命令行工具编译solidity智能合约 编写部署合约的php代码 在php代码中与智能合约交互
学习ERC20代币智能合约的设计并使用solidity开发语言实现,然后使用php 进行部署与交互。
在运行预置代码之前,请首先在1#终端启动节点仿真器:
~$ ganache-cli
编译合约 执行以下命令:
~/repo/chapter5$ ./build-contract.sh
部署合约 执行php脚本:
~/repo/chapter5$ php deploy-contract.php
访问合约 执行php脚本:
~/repo/chapter5$ php access-contract.php
ERC20代币规范
目前几乎所有用于ICO筹集资金的代币,都是基于同样的技术:以太坊ERC-20标准,这些 代币实际上就是实现了ERC20标准的智能合约。
一个ERC20代币合约应当实现如下标准的接口,当然你也可以根据自己的实际需要 补充额外的接口:
contract ERC20 {
function totalSupply() constant returns (uint theTotalSupply);
function balanceOf(address _owner) constant returns (uint balance);
function transfer(address _to, uint _value) returns (bool success);
function transferFrom(address _from, address _to, uint _value) returns (bool success);
function approve(address _spender, uint _value) returns (bool success);
function allowance(address _owner, address _spender) constant returns (uint remaining);
event Transfer(address indexed _from, address indexed _to, uint _value);
event Approval(address indexed _owner, address indexed _spender, uint _value);
}
totalSupply()
该函数应当返回流通中的代币供给总量。比如你准备为自己的网站发行100万个代币。
balanceOf()
该函数应当返回指定账户地址的代币余额。
approve()
使用该函数进行授权,被授权的账户可以调用账户的名义进行转账。
transfer()
该函数让调用账户进行转账操作,将指定数量的代币发送到另一个账户。
transferFrom()
该函数允许第三方进行转账操作,转出账户必须之前已经调用approve()方法授权给调用账户。
Transfer事件
每次转账成功后都必须触发该事件,参数为:转出账户、转入账户和转账代币数量。
Approval事件
每次进行授权,都必须触发该事件。参数为:授权人、被授权人和授权额度。
除了以上必须实现的接口,ERC20还约定了几个可选的状态,以便钱包或 其他应用可以更好的标识代币:
name : 代币名称。例如:HAPPY COIN。 symbol : 代币符号。例如:HAPY ,在钱包或交易所展示这个名字。 decimals :小数位数。默认值为18。钱包应用会使用这个参数。例如 假设你的代币小数位数设置为2,那么1002个代币在钱包里就显示为10.02了。
代币合约状态设计
智能合约的设计核心是状态的设计,然后再围绕状态设计相应的操作。代币合约也不例外。
首先我们需要有一个状态来记录代币发行总量,通常这个总量在合约部署的时候就固定下来了,不过你也可以定义额外的非标接口来操作这个状态,例如增发:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Z8VibJ9s-1650265504518)(https://www.li6.cc/assets/img/loading2.gif)]
在solidity中,我们可以使用一个uint256类型的变量来记录发行总量:
uint256 totalSupply;
嗯,多多益善。不过没有比uint256更大的类型了。
接下来我们还需要有一个状态来保存所有账户的代币余额: [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wBhwmGox-1650265504518)(https://www.li6.cc/assets/img/loading2.gif)]
在solidity中,可以使用一个从账户地址对应于一个整数(表示余额)的映射表来表示这个状态:
mapping(address => uint256) balances;
最后一个重要的状态是授权关系,我们需要记录三个信息:授权账户、被授权账户和授权额度: [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7DO7StHQ-1650265504518)(https://www.li6.cc/assets/img/loading2.gif)]
显然,这需要一个嵌套的映射表:
mapping (address => mapping (address => uint256)) public allowed;
至于代币名称、代币符号和小数点位数,就简单的使用string和uint8类型的变量吧:
string public name;
string public symbol;
阅读教程,回答以下问题: transfer()会修改哪些状态? transferFrom()会使用那些状态,又会修改哪些状态? approve()会修改哪些状态? uint8 public decimals;
代币合约方法实现
(demo:repo\chapter5\contract\EzToken.sol)
定义好了核心状态,ERC20规定的接口实现起来非常简单。不过在实现这些接口之前,我们 先看一下构造函数:
constructor(
uint256 _initialAmount,
string _tokenName,
uint8 _decimalUnits,
string _tokenSymbol
) public {
balances[msg.sender] = _initialAmount;
totalSupply = _initialAmount;
name = _tokenName;
decimals = _decimalUnits;
symbol = _tokenSymbol;
}
嗯,很简单,就是保存一下传入四个参数:初识发行总量、代币名称、小数点位数和代币符号。 在上面的实现中,部署合约的账户在开始时将持有所有的代币。
你可以根据自己的需要调整构造函数的参数和实现逻辑。
transfer(to,value)
容易理解,transfer()函数操作的状态就是balances。实现逻辑很直白,代码如下:
function transfer(address _to, uint256 _value) public returns (bool success) {
require(balances[msg.sender] >= _value);
balances[msg.sender] -= _value;
balances[_to] += _value;
emit Transfer(msg.sender, _to, _value);
return true;
}
由于transfer()是从调用账户转出代币,因此首先需要检查调用账户的代币余额是否足够。 接下来就可以分别调整双方的账户余额,然后触发Transfer事件即可。
approve(spender,value)
approve()函数操作的状态是allowed。实现逻辑同样直白,上代码:
function approve(address _spender, uint256 _value) public returns (bool success) {
allowed[msg.sender][_spender] = _value;
emit Approval(msg.sender, _spender, _value);
return true;
}
修改allowed映射表之后,触发Approval事件即可。
transferFrom(from,to,value)
transferFrom()方法的逻辑相对复杂一点,它需要同时操作balances状态和allowed状态:
function transferFrom(address _from, address _to, uint256 _value) public returns (bool success) {
uint256 allowance = allowed[_from][msg.sender];
require(balances[_from] >= _value && allowance >= _value);
balances[_to] += _value;
balances[_from] -= _value;
if (allowance < MAX_UINT256) {
allowed[_from][msg.sender] -= _value;
}
emit Transfer(_from, _to, _value); //solhint-disable-line indent, no-unused-vars
return true;
}
代码首先查看allowed状态来确定调用账户是否得到转出账户的授权 以及 授权额度是否足够本次转账。然后还要检查转出账户的余额是否足够本次转账。这些条件满足以后,直接调整 balances状态中转出账户和转入账户的余额,同时调整allowed状态中响应的授权额度。最后触发Transfer事件即可。
balanceOf(owner)
balanceOf()方法只是查询balances状态,因此它是一个不消耗gas的view函数:
function balanceOf(address _owner) public view returns (uint256 balance) {
return balances[_owner];
}
allowance(owner,spender)
allowance()方法查询账户对的授权额度,显然,它也是一个不修改状态的view函数:
function allowance(address _owner, address _spender) public view returns (uint256 remaining) {
return allowed[_owner][_spender];
}
参考教程,编写代币合约EzToken.sol,实现ERC20规范。
编译代币合约
为了在Php代码中与合约交互,我们需要先编译solidity编写的合约,以便得到 EVM字节码和二进制应用接口(ABI)。
字节码是最终运行在以太坊虚拟机中的代码,看起来就是这样:
608060405234801561001057600080fd5b5060405…
嗯,就是16进制码流,每两个字符表示1个字节,就像PC里的机器码,以太坊的字节码对应着以太坊虚拟机的操作码 ——— 字节码就是最终在以太坊的EVM上运行的合约代码,我们在部署合约的时候需要用到它。
而ABI则是描述合约接口的一个JSON对象,用来在其他开发语言中调用合约。ABI 描述了合约中的每个方法、状态与事件的语言特性。例如,对于代币合约中的 transfer()方法,在ABI中描述如下: [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-siJ1f96g-1650265504519)(https://www.li6.cc/assets/img/loading2.gif)]
ABI提供的丰富信息是实现其他语言绑定的关键资料。任何时候与合约进行交互, 都需要持有合约的ABI信息。
solidity的官方编译器solc是一个命令行程序,根据运行选项的不同会有 不一样的行为。例如,下面的使用–bin和–abi选项要求solc编译器同时 生成字节码文件和ABI文件,并输出到build目录:
~$ mkdir -p contract/build
~$ solc contract/EzToken.sol --bin --abi \\
--optimize --overwrite \\
-o contract/build/
编译成功后,将在build目录中得到两个文件,这是我们下一步工作的基础:
~$ ls contract/build
EzToken.abi EzToken.bin
参考教程: 使用solc编译合约EzToken.sol 查看生成的ABI文件,找到balanceOf()函数对应的描述,与其solidity声明 对照查看,理解ABI中的信息涵盖范围。
部署代币合约
(demo: \repo\chapter5\deploy-contract.php)
有了合约的字节码和ABI,就可以使用Web3\Contract类来部署合约到链上了。
首先载入ABI 和 字节码:
$abi = file_get_contents('contract/build/EzToken.abi');
$bytecode = '0x' . file_get_contents('contract/build/EzToken.bin');
然后创建Web3\Contract实例,需要传入Web3\Provider对象以及合约的 ABI信息,然后调用bytecode()方法设置字节码:
$contract = new Web3\\Contract($web3->provider,$abi);
$contract->bytecode($bytecode);
为什么不在构造函数中直接传入字节码?
这是因为只有在部署的时候,才需要使用字节码。一旦合约部署完成,只需要合约的ABI和部署地址就可以与合约交互了。
一切就绪,现在只需要调用合约对象的new()方法即可部署。例如,下面的代码完成了1000万枚幸福币的发行:
$cb = new Callback();
$opts = [
'from' => $accounts[0],
'gas' => '0x200b20' //2100000
];
$contract->new(10000000,'HAPPY TOKEN',0,'HAPY',$opts,$cb);
$txhash = $cb->result;
容易理解,部署时会依次传入合约的构造函数声明的各参数值,也就是说,只有在 部署合约的时候,才会执行合约的构造函数。此外,由于部署合约是一个交易,因此 我们需要声明部署账户和gas用量。
由于部署账户是节点旳第1个账户,该账户将持有初始发行的全部代币。
接下来要等待交易收据,因为在收据中有合约部署的具体地址:
$timeout = 60;
$interval = 1;
$t0 = time();
while(true){
$this->eth->getTransactionReceipt($txhash,$cb);
if(isset($cb->result)) break;
$t1 = time();
if(($t1 - $t0) > $timeout) break;
sleep($interval);
}
如果拿到收据,那就算部署成功了。但是如果你希望接着马上与这个新部署的合约对象交互,还需要设置其地址:
$contract->at($receipt->contractAddress);
最重要的,把地址抄下来,或者记录到一个文件中,否则你接下来没法访问合约了。
file_put_contents('./contract/build/EzToken.addr',$receipt->contractAddress);
参考教程,编写php脚本实现以下功能: 使用节点第1个账户部署代币合约,发行100万枚幸福币,代号HAPY 在控制台输出合约的部署地址 将合约的部署地址保存到文件中
访问代币合约
(demo:repo\chapter5\access-contract.php)
要访问一个已经部署在链上的合约,只需要它的ABI和部署地址。
同样,首先载入ABI和之前保存的地址:
$abi = file_get_contents('./contract/build/EzToken.abi');
$addr = file_get_contents('./contract/build/EzToken.addr');
然后构建Contract对象,设置其部署地址:
$contract = new Web3\\Contract($web3->provider,$abi);
$contract->at($addr);
接下来就可以调用合约的方法了。这分两种情况。
如果是那些修改合约状态的交易函数,比如transfer(), 使用合约对象的send()方法,例如,向第2个节点账户转一些代币:
$opts = [
'from' => $accounts[0],
'gas' => '0x200b20'
];
$contract->send('transfer',$accounts[1],$opts,$cb);
echo 'tx hash: ' . $cb->result . PHP_EOL;
交易函数返回的总是交易收据。
注意,因为我们使用第1个节点账户部署的合约,因此现在它持有全部的代币。
如果是要调用合约中的只读函数,例如balanceOf(),那么使用合约对象的call() 方法。例如,获取第1个节点账户的代币余额:
$opts = []; //不需要消耗gas
$contract->call('balanceOf',$accounts[0],$opts,$cb);
$balance = $cb->result['balance']->toString();
echo 'balance' . $balance . PHP_EOL;
注意,由于solidity支持函数返回多个值,因此call()方法的返回结果总是一个关联 数组,各键的名称与所调用合约方法在ABI中的outputs部分的名称对应。
参考教程与示例代码, 编写php实现以下功能: 向节点第2个账户转账100个代币
通知机制概述
通知机制对任何应用开发都很重要,因为它提供了另外一个方向的变化 通知能力。以太坊也不例外,它的通知机制增强了智能合约与外部应用之间 的沟通能力。
以太坊的通知机制是建立在日志基础之上。例如,如果智能合约触发了一个 事件,那么该事件将写入以太坊日志;如果外部应用订阅了这个事件,那么当日志中出现该事件后此外部应用就可以拉到,如图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hD9Um7jo-1650265504519)(https://www.li6.cc/assets/img/loading2.gif)]
需要指出的是,以太坊的通知机制不是推(Push)模式,而是需要外部应用周期性轮询的拉(Pull)模式。外部应用通过在节点中创建过滤器来订阅感兴趣的日志,之后则通过检测该过滤器的变化获得最新的日志。
在这一部分的课程中,我们将学习以下内容:
使用块过滤器监听新块生成事件和新交易事件 使用待定交易过滤器监听待定交易事件 使用主题过滤器监听合约事件 解析合约事件产生的日志
监听新块事件
使用块过滤器来监听新块生成事件。其过程如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mATGFrC9-1650265504520)(https://www.li6.cc/assets/img/loading2.gif)]
首先进行eth_newBlockFilter调用 来创建一个新块过滤器:
$cb = new Callback();
$web3->eth->newBlockFilter($cb);
$fid = $cb->result;
然后调用eth_getFilterChanges进行周期性检查:
while(true){
$web3->eth->getFilterChanges($fid,$cb);
$blocks = $cb->result;
foreach($blocks as $hash) {
echo $hash . PHP_EOL;
}
sleep(2);
}
对于块过滤器,eth_getFilterChanges调用将返回块哈希值的数组。 如果你希望获取块的详细信息,可以使用eth_getBlockByHash调用。
参考教程和示例代码,编写两个php脚本分别实现以下功能: 监听新块生成事件,并打印新块信息 从节点第1个账户向第2个账户转入1kwei。 在两个不同的终端分别运行两个脚本,观察监听脚本的输出。
监听新交易事件
要监听新的确认交易,也是使用块过滤器。其过程如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-c0zRcDZZ-1650265504520)(https://www.li6.cc/assets/img/loading2.gif)]
实际上它的实现机制,就是在捕捉到新块事件后,进一步获取新块的详细信息。
因此和监听新块事件一样,首先执行eth_newBlockFilter调用 来创建一个新块过滤器:
$cb = new Callback();
$web3->eth->newBlockFilter($cb);
$fid = $cb->result;
然后调用eth_getFilterChanges进行周期性检查。 对于该调用返回的每一个块哈希,进一步调用eth_getBlockByHash 来获取块的详细信息:
while(true){
$web3->eth->getFilterChanges($fid,$cb); $blocks = $cb->result;
foreach($blocks as $hash) {
$web3->eth->getBlockByHash($hash,true,$cb); $block = $cb->result;
foreach($block->transactions as $tx) var_dump($tx);
}
sleep(2);
}
eth_getBlockByHash调用的第二个参数用来声明是否需要返回完整的交易对象, 如果设置为false将仅返回交易的哈希。
参考教程和示例代码,编写两个php脚本分别实现以下功能: 监听新交易生成事件,显示新交易信息 从节点第1个账户向第2个账户转入1gwei 在两个终端分别运行上面脚本,查看监听输出。
监听待定交易事件
待定交易指那些提交给节点但还未被网络确认的交易,因此它不会包含在区块中。 使用待定交易过滤器来监听新待定交易事件。其过程如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-t9A3hUdN-1650265504520)(https://www.li6.cc/assets/img/loading2.gif)]
首先执行eth_newPendingTransactionFilter 调用来创建一个新待定交易过滤器:
$cb = new Callback();
$web3->eth->newPendingTransactionFilter($cb); $fid = $cb->result;
然后同样是调用eth_getFilterChanges进行周期性检查。 例如,下面的代码将打印新出现的待定交易的信息:
while(true){
$web3->eth->getFilterChanges($cb); $ptxs = $cb->result;
foreach($ptxs as $hash) echo $hash . PHP_EOL;
sleep(2);
}
对于待定交易过滤器,eth_getFilterChanges调用返回的结果是自上次 调用之后新产生的一组待定交易的哈希。可以使用eth_getTransactionByHash 调用查看指定交易的详细信息。
参考教程和示例代码,编写两个php脚本实现以下功能: 监听新待定交易事件,显示待定交易信息 从节点第1个账户向第2个账户转入1gwei 在两个终端分别运行上述脚本,查看监听输出。
监听合约事件
合约事件的监听是通过主题过滤器实现的,其过程如下:
首先执行eth_newFilter调用 创建一个主题过滤器,获得该过滤器的编号:
$cb = new Callback();
$web3->eth->newFilter([],$cb);
$fid = $cb->result;
eth_newFilter调用可以接收一个选项参数,来过滤监听的日志类型。该选项 可以指定一些监听过滤条件,例如要监听的合约地址等。 不过在上面的代码中,我们使用一个空的关联数组,没有设置这些参数, 这意味着我们将监听全部日志。
在创建主题过滤器之后,同样使用eth_getFilterChanges 调用进行周期性的检查,看是否有新的日志产生。该调用将返回自上次调用之后的所有新日志的数组:
while(true){
$web3->eth->getFilterChanges($cb);
$logs = $cb->result;
foreach($logs as $log) {
var_dump($log);
}
sleep(2);
}
每一个日志在php里被映射到一个StdClasst对象,但显然,捕捉到的日志还 需要进一步解码才可以得到事件的参数:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xtG4IewL-1650265504521)(https://www.li6.cc/assets/img/loading2.gif)]
参考教程,编写两个php脚本分别实现以下功能: 监听代币合约的transfer事件 每隔3秒从节点第1个账户向第3个账户转1个代币 在两个终端分别运行上述脚本,观察监听输出。
使用主题过滤日志
当我们创建主题过滤器时,以及查看日志数据时,都接触到了一个概念:主题。 以太坊利用主题来区别不同的事件。
主题实际上就是事件的哈希签名。例如,对于代币合约的Transfer 事件,它的签名计算如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wL0ZO795-1650265504522)(https://www.li6.cc/assets/img/loading2.gif)]
使用Web3\Contract\Ethabi类的encodeEventSignature()方法来 计算事件签名。我们可以从合约对象的ethabi属性得到一个Ethabi实例。 例如:
$contract = new Contract($web3->provider,$abi);
$ethabi = $contract->ethabi;
$topic = $ethabi->encodeEventSignature('Transfer(address,address,)');
echo 'topic: ' . $topic . PHP_EOL;
由于在abi中已经记录了事件的参数信息,因此也可以直接传入abi信息:
$topic = $ethabi->encodeEventSignature($abi->events['Transfer']);
echo 'topic: ' . $topic . PHP_EOL;
在创建主题过滤器时,就可以用这个主题来调整监听行为了:
while(true){
$contract->eth->getFilterChanges($fid,$cb);
$logs = $cb->result;
if(count($logs) >0) {
foreach($logs as $log)
{
if($log->topics[0] == $topic)
{
var_dump($log);
} else {
echo 'skip log ' . PHP_EOL;
}
}
}
sleep(2);
}
参考教程和示例代码,编写两个php脚本分别实现以下功能: 监听代币合约的Approval事件 分别触发Approval事件和Transfer事件 在两个终端分别运行以上脚本,查看监听输出是否与预期一致。