原标题:《以太坊智能合约ABI、事件和日志》
作者:北京大学胡悦阳,本文仅代表作者观点
原文链接:https://mp.weixin.qq.com/s/QKz8r1MpntGuw_xM9gh9_w
智能合约是什么
智能合约是在区块链上运行的应用或程序。通常情况下,它们为一组具有特定规则的数字化协议,且该协议能够被强制执行。这些规则由计算机源代码预先定义,网络节点会复制和执行这些计算机源码。
简单的说,智能合约就是区块链上一个包含合约代码和存储空间的虚拟账户。
智能合约的行为由合约代码控制,而智能合约的账户存储则保存了合约的状态。
在以太坊平台上,智能合约的代码运行在以太坊虚拟机中,EVM是一个图灵完备的虚拟机,是以太坊的核心。
在以太坊的点对点网络中,每个全节点上都包含一个以太坊虚拟机,当节点需要打包或验证区块时,便将交易相关的可执行代码送入EVM中执行,执行的结果更新了以太坊账户的状态并被记录在区块链上。
智能合约的操作
创建智能合约的流程:
编写智能合约的代码编译智能合约的代码变成可以在EVM上执行的bytecode,同时可以通过编译获得智能合约的ABI部署到区块链,通过一个交易将bytecode存储在链上,并获得合约地址调用智能合约的流程:
发起一笔指向智能合约地址的交易,智能合约代码分布式地运行在网络中每个节点的以太坊虚拟机中,然后会获得交易的回执。回执保存交易的输入参数、输出、执行状态等。
举一个例子,首先在Remix平台编写一个智能合约Hello.sol
pragma?solidity?>=0.4.21;contract?Hello{????string?message;????event?SetMessage(string?_message);????function?set(string?memory?_message)?public?{????????message?=?_message;??????????emit?SetMessage(_message);????}????function?get()?public?view?returns(string?memory){????????return?message;????}}
编译成字节码,发起一笔交易部署到区块链中,得到交易回执。
这样就成功的将合约部署到区块链网络中了。
ABI是什么
上文提到调用智能合约,需要发起一笔指向合约地址的交易,以太坊节点会根据输入的信息,选择要执行合约中的哪一个函数和函数的参数。如何知道智能合约提供哪些函数以及参数要求呢,就需要用到ABI了。
合约ABI是在以太坊生态系统中与合约进行交互的标准方法,既可以从区块链外部进行,也可以用于合约间的交互。
ABI类似程序中的接口文档,描述了字段名称、字段类型、方法名称、参数名称、参数类型、方法返回值类型等。
通俗的解释:
ABI是合约接口的说明ABI定义与合约进行交互的数据编码解码规则以之前的Hello.sol为例,在编译合约的时候可以生成合约的ABI
,????????"name":?"SetMessage",????????"type":?"event"????},????{????????"inputs":?,????????"name":?"get",????????"outputs":?,????????"stateMutability":?"view",????????"type":?"function"????},????{????????"inputs":?,????????"name":?"set",????????"outputs":?,????????"stateMutability":?"nonpayable",????????"type":?"function"????}]
ABI各参数的意义
??name:函数名称
??type:方法类型,包括function、event等
??payable:布尔值,表明方法是否可以接受ether
??stateMutability:状态类型,包括pure(不读取区块链状态),view(和constant类型,只能查看,不会修改合约字段),nonpayable,payable。
??inputs:数组,描述参数的名称和类型
–name:参数名称
–type:参数类型
??outputs:和inputs一样,如果没有返回值,缺省是一个空数组
当用户调用一个合约时,要对调用的函数名和传入的函数参数进行编码,这样EVM才能执行,知道用户调用的是哪个接口,以及正确读取用户的参数,下面介绍以太坊是如何生成可供EVM调用的字节码的。
生成的字节码主要分两部分:函数选择器和参数编码。
函数选择器?FunctionSelector
函数调用的调用数据的前四个字节指定要调用的函数。它是通过将函数签名进行Keccak-256哈希运算后,取前四个字节得到的。
以Hello.sol为例,set函数的接口定义为:
functionset(stringmemory_message)public;
在python3环境下安装ethereum库
>?from?ethereum.utils?import?sha3>?sha3("set(string)").hex()'4ed3885e778f096a5fd9407b264b5478208ea71532d13d454b0307e5f1542101'>?sha3("set(string)").hex()'4ed3885e'
取前四个字节即:4ed3885e
参数编码ArgumentEncoding
从第五个字节开始,后面是编码参数。参数的编码根据类型的不同,编码方式也有所区别。主要分为固定类型和动态类型。
1、固定类型
?uint:M位的无符号整数类型,0<M<=256,M%8==0,如uint32,uint8,uint256.。
?int:M位的两个补码有符号整数类型.0<M<=256,M%8==0.
?uint和int:整型,分别是uint256和int256的别名。注意:函数参数类型是uint,转sha3码时要变成uint256。
?address:地址,20个字节,160bits,等同于uint160。
?bool:布尔类型,1个字节,true:1,false:0
?bytes:固定大小的字节数组,0<M<=32,byte都是bytes1的别名。
2、动态类型
?bytes:动态分配大小的字节数组
?string:动态大小UTF8编码的字符串
?:给定类型的元素的可变长度数组。
?:给定类型的元素的定长数组。
编码规则
固定类型的编码就很简单,直接将参数值转成32字节长度的16进制即可。需要注意的是:数字类型,不足32bytes时,如果是正数高位补0,如果是负数高位补1。布尔类型高位补0。字节类型、字符串类型在低位补全。?动态类型的编码稍微复杂点,如果是固定长度就不需要计算偏移量,如果是不定长度就需要先计算偏移量,并在最后加上长度和具体值的编码。下面举例说明。
USDC Treasury在以太坊网络增发1695万枚USDC:Whale Alert数据显示,北京时间12月6日03:25,USDC Treasury在以太坊网络上增发1695万枚USDC。增发哈希为:763b664b91c2b36ee5b058ab48b0a836b09a900bd2e8d3862ed7f97e816746d6。[2020/12/6 14:08:33]
Example
给出如下合约,参考官方文档:
https://solidity.readthedocs.io/en/develop/abi-spec.html#function-selector-and-argument-encoding
pragma?solidity?>=0.4.16?<0.8.0;contract?Foo?{????function?bar(bytes3?memory)?public?pure?{}????function?baz(uint32?x,?bool?y)?public?pure?returns?(bool?r)?{?r?=?x?>?32?||?y;?}????function?sam(bytes?memory,?bool,?uint?memory)?public?pure?{}}
案例1
函数:baz(uint32,bool)?
调用:baz(69,true)
?0xcdcd77c0:函数选择器,在python中通过sha3("set(string)").hex()得到0xcdcd77c0
?0x0000000000000000000000000000000000000000000000000000000000000045,十进制69,转成16进制为45,因为是正数,高位补0至32bytes
?0x0000000000000000000000000000000000000000000000000000000000000001,bool类型,true=1,false=0,高位补0
最终的字节码为
0xcdcd77c000000000000000000000000000000000000000000000000000000000000000450000000000000000000000000000000000000000000000000000000000000001
会返回bool类型.在这个调用中,返回值是false,它的输出将是单字节数组
0x0000000000000000000000000000000000000000000000000000000000000000
案例2
函数:bar(bytes3memory)?
调用:bar()
?0xfce353f6:函数选择器,在python中通过sha3("bar(bytes3)").hex()得到0xfce353f6
?固定长度不需要计算偏移量
?0x6162630000000000000000000000000000000000000000000000000000000000,字符串abc转成16进制后为616263,低位补0
?0x6465660000000000000000000000000000000000000000000000000000000000,字符串def转成16进制后为646566,低位补0
字符串转16进制的python参考代码
以太坊未确认交易为90,658笔:金色财经消息,据OKLink数据显示,以太坊未确认交易90,658笔,当前全网算力为253.46TH/s,全网难度为3.36P,当前持币地址为49,743,531个,同比增加160,856个,24h链上交易量为3,204,649.38ETH,当前平均出块时间为13s。[2020/11/7 11:57:49]
import?binasciis?=?'abc'str_16?=?binascii.b2a_hex(s.encode('utf-8'))??#?字符串转16进制print(str_16)
案例3
函数:sam(bytes,bool,uint)
调用:sam("dave",true,")
?a5643bf2:函数选择器,在python中通过sha3("sam(bytes,bool,uint256)").hex()得到a5643bf2.请注意,将uint替换为其规范表示形式uint256。
?0x0000000000000000000000000000000000000000000000000000000000000060:动态类型,计算偏移量。这个的偏移量是指实际存储值的位置,由于这个函数有3个变量,那么实际存储值的位置就是第四个32bytes位置,也就是说偏移量等于3*32bytes=96,转成16进制后就是对应的值
?0x0000000000000000000000000000000000000000000000000000000000000001:第二个参数,布尔值true
?0x00000000000000000000000000000000000000000000000000000000000000a0:动态类型,计算偏移量,这个偏移量就等于参数长度3*32bytes+前面的动态参数参数占有的长度,那么具体的值就是3*32bytes+(1*32bytes+1*32bytes)=5*32bytes=160,转成16进制就是a0,高位补全就是对应的值
?0x0000000000000000000000000000000000000000000000000000000000000004:第一个参数的数据部分,代表元素中字节数组的长度,在这种情况下为4。
?0x6461766500000000000000000000000000000000000000000000000000000000:“dave”的utf-8编码,填充为32字节。
?0x0000000000000000000000000000000000000000000000000000000000000003:第三个参数的数据部分,代表数组中元素的个数,在这种情况下为3.
?0x0000000000000000000000000000000000000000000000000000000000000001:第三个参数的第一项。
?0x0000000000000000000000000000000000000000000000000000000000000002:第三个参数的第二项。
?0x0000000000000000000000000000000000000000000000000000000000000003:第三个参数的第三项。
最终的字节码为
0xa5643bf20000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000464617665000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000003
分析 | 以太坊对比特币汇率有一定下滑?主流交易所相关交易量有一定增长:据 TokenGazer 数据分析显示,截止至 10 月 16?日 11 时,以太坊价格为$179.58,总市值为$19,437.40M,主流交易所24H交易量约为$100.82M,环比昨日增量33.89%;近期以太坊对比特币汇率有一定下滑趋势;基本面方面,以太坊链上交易量、链上DApp交易量持续增长、算力有一定反弹、新增地址增长稳定;以太坊 30 天开发者指数约为 2.25;以太坊与 BTC 180 天关联度保持稳定,30 天 ROI 持续下滑;ERC20 代币总市值约为以太坊总市值的 59.09%。[2019/10/16]
综上所述,ABI是合约接口的说明,并定义了与合约进行交互的数据编码解码规则。
事件和日志
区块链是一个块列表,从根本上讲就是交易列表。每一个交易都有一个收据,其中包含0个或多个日志记录。日志记录表示从智能合约触发的事件的结果。在以太坊中,事件是一个基本功能,可以将数据记录成日志,保存在区块链上。事件也可以与外部交互,比如与前端进行交互。事件强调功能,是指触发操作的行为,日志强调存储,是指触发事件后,将数据保存在区块链上,形成日志。
事件如何定义和触发
在solidity中,使用关键字event来定义事件,使用关键字emit来触发事件,其参数列表就是需要保存在区块链上的数据,最多可有三个具有indexed属性的参数,表示其可以被索引,便于查找。
contract?MyContract{????event?Transfer(address?indexed?from,?address?indexed?to,?uint256?value);????function?transfer(address?_to,?uint256?_value)?public?returns?(bool)?{????emit?Transfer(msg.sender,?_to,?_value);????return?true;??}}
事件的作用
事件可以在不同的场景下使用,主要有如下三种作用。
获取合约执行结果过滤日志存储合约数据1、获取合约执行结果
在开发Dapp时,我们会通过发送一笔交易来调用智能合约的某个函数,但是我们不能立即得到返回值。因为交易不是立刻打包进区块链的,在这种场景下,可以使用事件来解决这个问题。
以和前端交互为例,我们可以通过编写代码来监听某一特定事件来做到更新前端。例如通过如下代码来监听上文提到的合约中的Transfer事件。
var?event?=?myContract.Transfer();event.watch(function(error,?result){????if?(err)?{????console.log(err)????return;??}??console.log(result.args._value。);
当调用transfer函数的交易被打包进区块链中时,将会触发回调中的watch函数,前端可以得到有效的transfer函数的返回值。
事件通常可以被看作带有数据的异步触发器。当一个合约想要触发前端时,合约会发出一个事件。因为前端正在监听这个事件,一旦监听到相关事件,前端可以采取相应的操作,比如显示消息,更新前端展示内容等。
2、过滤日志
日志不能被合约访问,Solidity没有提供查询日志的接口,在监听日志的时候,Solidity提供了filter功能,借此我们可以实现对日志的查找过滤。在Transfer事件中,from和to参数被设置成indexed,说明其是可以被索引的。所以我们可以监听特定的事件,例如转账地址为0xab213的事件,也可以监听从0xab213地址转账到0x417ac的事件,但由于value参数没有indexed属性,所以我们不能监听例如value为100的事件。
行情 | 比特币未确认交易量3472笔 以太坊未确认交易量31617笔:监测显示,当前比特币未确认交易量为3472笔,比特币全网算力为42.20EH/s,当前挖矿难度为5.81T,预计下次挖矿难度将上调0.71%至5.86T。当前以太坊未确认交易量为31617笔,网络拥堵状况稍有缓解,以太坊全网算力为133.29TH/s,24h跌幅为0.97%。[2019/2/4]
在此场景下,我们如果想过滤指定地址发出的交易,我们可以通过web3.js编写如下代码。
Mycontract.deployed().then(function?(instance)?{var?event?=?instance.Transfer({}function?(error,?result)?{????var?obj1?=?{????????'_to':?'0xab213',????}????var?obj2?=?{????????'fromBlock':?0,????????'toBlock':?'latest'????}????var?event?=?instance.Transfer(obj1,?obj2)????event.watch(function?(error,?result)?{????????console.log(JSON.stringify(result))????}。).then(function?(value)?{????console.log(value。).catch(function?(e)?{????console.log(e。)
参数说明:
?obj1:添加indexed属性的参数,在这里我们可以过滤特定地址0xab213发起的交易
?obj2:Solidity提供的额外的过滤参数,可选的主要参数有:
–fromBlock:指定过滤的起始位置,值为块的编号,默认为latest
–toBlock:指定过滤的结束位置,值为块的编号,默认为latest
·callfunction:回调函数function(error,result
3、存储合约数据
与上面讲述的不同,事件可以作为便宜的多的一种存储形式。通过触发事件,存储在日志上的数据,基本上每字节花费8gas,但是智能合约每存储32字节花费20000gas。尽管日志可以节省大量gas,但是无法从任何智能合约中读取日志信息。需要根据使用场景来选择适合的存储办法,日志作为一种廉价存储的方式,适合存储可由前端展示的历史数据。
日志记录的组成
EVM具有5个用于发出日志的操作码:LOG0,LOG1,LOG2,LOG3和LOG4。通过这些操作码来创建日志记录。
每个日志记录都包含topics和data。Topics是bytes32类型的参数,不同的操作码描述包含在日志记录中的Topics数量。LOG1包含1个topic,LOG2包含2个topics,最多支持4个topics。
Topics用于描述事件,日志中存储的不同的具有indexed属性的事件就叫不同的主题。比如对于事件Transfer来说,其定义为eventTransfer(addressindexedfrom,addressindexedto,uint256value);,有三个主题。第一个主题为事件签名的哈希值,即通过keccak256("Transfer(address,address,uint256)")来得到,如果该事件是匿名事件,那么就没有这个主题。后面两个具有indexed属性的参数,可以用来进行过滤进行精确查找。
由于只能容纳32个字节的数据,所以无法将数据或字符串之类的参数用作Topic,应该将其作为data包含在日志记录中。如果想要包含超过32字节的topic,应该将其哈希。Topic可视作事件的索引,其使用场景在于可以有效缩小搜索查询范围内的数据。
日志记录的另一个部分是数据。Topic是可以搜索的,但是数据却不可以,数据可以摆脱Topic的32字节大小的限制,包含例如数组或字符串的复杂数据。
下面举一个例子来说明。还是以上文中的Transfer事件为例,由于Transfer不是一个匿名事件,所以第一个Topic包含事件签名。
contract?MyContract{????event?Transfer(address?indexed?from,?address?indexed?to,?uint256?value);????function?transfer(address?_to,?uint256?_value)?public?returns?(bool)?{????emit?Transfer(msg.sender,?_to,?_value);????return?true;??}}
在事件的参数部分,Transfer事件有三个参数from、to、value,其中from和to被声明为indexed,标识其被视为topic,value参数不会被索引,将会作为日志的数据部分。此事件包含3个topics,将使用LOG3操作码来创建日志记录。
举个例子,将上述示例合约部署到区块链网络中,我们使用:0x246B0ED379bdDbe1aDaC56277Ce5cB3018c24E04地址调用transfer函数,to参数指定为0x3F0b9B0D373C26328A879430383e87F4780AD410,value指定为1,发起一笔交易获得交易的回执信息。其中在logs属性中,记录如下信息
,????"type":"mined",????"id":"log_cf046b97"??}]
其中,topics数组中有三个元素,第一个为keccak256("Transfer(address,address,uint256)")的结果,第二个为from参数的值,这里为0x246B0ED379bdDbe1aDaC56277Ce5cB3018c24E04,第三个为to参数的值,这里为0x3F0b9B0D373C26328A879430383e87F4780AD410。data字段是通过对value进行编码得到的,这里将1转成32字节长度的16进制的值,得到0x0000000000000000000000000000000000000000000000000000000000000001。
布隆过滤器LogBloomandFilter
布隆过滤器在以太坊中用于检索交易日志log,方便交易结果的查询以及交易事件通知。在以太坊的区块头中,有一个区域叫做logsBloom。这个区域存储了当前区块中所有收据的日志的布隆过滤器,有2048个bit,相当于256个字节。在一个交易的收据中,可能存在0个或多个日志记录,每个日志记录中包含了相应的Topics和data。在一个交易的收据中同样也存在布隆过滤器,记录了所有的日志记录数据。
下面介绍布隆过滤器的原理。我们知道,查找一个元素是否存在于一个集合中,可以使用数组这种数据结构。但是当数据量非常庞大的情况下,数组的空间开销和查询开销也会变得非常大。
布隆过滤器的原理是当一个元素被加入到一个集合中时,使用K个哈希函数,对该元素求哈希值,得到K个不同的哈希值,分别记作X1,X2,X3,…,XK。将这K个数字作为位图的下标,将对应的
BitMap,BitMap,BitMap,…,BitMap都设置为1。即用K个bit来表示一个元素是否存在。
当想要查询某个元素是否存在于这个集合中时,用相同的K个哈希函数,得到Y,Y,Y...Y,如果这K个哈希值对应的位图下标均为1,则表示这个元素可能存在,如果有任意一个下标不为1,则说明这个元素肯定不存在。
举例说明,假设集合中现在有3个元素{X,Y,Z},使用3个哈希函数。首先将数组进行初始化,每一位置为0。然后对于集合中的每一个元素,都通过3个哈希函数进行哈希计算,每次计算都会产生一个下标,将数组中该下标对应的值设置为1。这时候想要查询一个元素W是否在该集合中,依次用这3个哈希函数将W映射到数组的3个位点上,如果3个位点对应的值都为1,则可能存在于集合中,若有一个位点的值不为1,则该元素一定不在这个集合中。
布隆过滤器存在一定的误判性,因为存在一定的哈希碰撞可能,所以当位点上的值都为1时,只能反映该元素可能存在于该集合中。但这不影响其广泛的应用性,布隆过滤器是一种存储效率很高的数据结构,其空间利用率和时间利用率非常高,插入数据和查询数据的时间复杂度为O。
布隆过滤器可以用于事件查询,在以太坊中,系统会先创建各个主题的布隆过滤器,然后通过合并获得事件的布隆过滤器,再次合并得到交易的布隆过滤器,最后合并得到区块的布隆过滤器。在查询过程中,查询满足指定特征的事件的过程是,先根据查询条件得到布隆过滤器,如果其位向量是区块布隆过滤器的子向量,则可能存在此区块中,如果不是其子向量,那么就不存在此区块中。如果可能存在,继续比对区块中各个交易的布隆过滤器,比对交易中每个事件的布隆过滤器。如果与事件的布隆过滤器匹配,再进行严格的数据验证,相同的话即说明存在。
参考资料
https://academy.binance.com/zh/articles/what-are-smart-contracts
https://segmentfault.com/a/1190000016634359
https://cloud.tencent.com/developer/article/1328286
https://fisco-bcos-documentation.readthedocs.io/zhCN/latest/docs/articles/3features/35contract/abiof_contract.html
http://www.jouypub.com/2018/437e42a5629ea0ccd567909c94abb4a4/
https://media.consensys.net/technical-introduction-to-events-and-logs-in-ethereum-a074d65dd61e
郑重声明: 本文版权归原作者所有, 转载文章仅为传播更多信息之目的, 如作者信息标记有误, 请第一时间联系我们修改或删除, 多谢。