8.以太坊php web3-智能合约概述

正文开始

[md]

智能合约概述

智能合约就是区块链上运行的软件,它常常被类比为「自动贩卖机」,因为大家认为这样比较容易理解: 自动贩卖机能接受并执行外部的指令。当顾客选定商品并付款后, 自动贩卖机将释放商品给顾客, 并不需要额外的人工介入:

智能合约的概念最早由电脑科学家、密码学家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了。

代币合约状态设计

智能合约的设计核心是状态的设计,然后再围绕状态设计相应的操作。代币合约也不例外。

首先我们需要有一个状态来记录代币发行总量,通常这个总量在合约部署的时候就固定下来了,不过你也可以定义额外的非标接口来操作这个状态,例如增发:

在solidity中,我们可以使用一个uint256类型的变量来记录发行总量:

uint256 totalSupply;

嗯,多多益善。不过没有比uint256更大的类型了。

接下来我们还需要有一个状态来保存所有账户的代币余额:

在solidity中,可以使用一个从账户地址对应于一个整数(表示余额)的映射表来表示这个状态:

mapping(address => uint256) balances;

最后一个重要的状态是授权关系,我们需要记录三个信息:授权账户、被授权账户和授权额度:

显然,这需要一个嵌套的映射表:

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中描述如下:

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个代币


通知机制概述

通知机制对任何应用开发都很重要,因为它提供了另外一个方向的变化 通知能力。以太坊也不例外,它的通知机制增强了智能合约与外部应用之间 的沟通能力。

以太坊的通知机制是建立在日志基础之上。例如,如果智能合约触发了一个 事件,那么该事件将写入以太坊日志;如果外部应用订阅了这个事件,那么当日志中出现该事件后此外部应用就可以拉到,如图:

需要指出的是,以太坊的通知机制不是推(Push)模式,而是需要外部应用周期性轮询的拉(Pull)模式。外部应用通过在节点中创建过滤器来订阅感兴趣的日志,之后则通过检测该过滤器的变化获得最新的日志。

在这一部分的课程中,我们将学习以下内容:

使用块过滤器监听新块生成事件和新交易事件 使用待定交易过滤器监听待定交易事件 使用主题过滤器监听合约事件 解析合约事件产生的日志


监听新块事件

使用块过滤器来监听新块生成事件。其过程如下:

首先进行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。 在两个不同的终端分别运行两个脚本,观察监听脚本的输出。

监听新交易事件

要监听新的确认交易,也是使用块过滤器。其过程如下:

实际上它的实现机制,就是在捕捉到新块事件后,进一步获取新块的详细信息。

因此和监听新块事件一样,首先执行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 在两个终端分别运行上面脚本,查看监听输出。

监听待定交易事件

待定交易指那些提交给节点但还未被网络确认的交易,因此它不会包含在区块中。 使用待定交易过滤器来监听新待定交易事件。其过程如下:

首先执行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对象,但显然,捕捉到的日志还 需要进一步解码才可以得到事件的参数:

参考教程,编写两个php脚本分别实现以下功能: 监听代币合约的transfer事件 每隔3秒从节点第1个账户向第3个账户转1个代币 在两个终端分别运行上述脚本,观察监听输出。

使用主题过滤日志

当我们创建主题过滤器时,以及查看日志数据时,都接触到了一个概念:主题。 以太坊利用主题来区别不同的事件。

主题实际上就是事件的哈希签名。例如,对于代币合约的Transfer 事件,它的签名计算如下:

使用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事件 在两个终端分别运行以上脚本,查看监听输出是否与预期一致。

下一篇:9.以太坊php web3 解码日志数据

正文结束

1.以太坊php web3 在windows10下调试——ganache工具的安装 【hi 以太坊】 0.以太坊概述 php web3 前言 【付费下载】