-
智能合约概述:
- 运⾏在区块链系统上的⼀段代码,代码逻辑定义了合约内容
- 只有合约账户才有代码
- 智能合约的账户保存了合约当前的运⾏状态:
- balance:当前余额
- nonce:交易次数
- code:合约代码
- storage:存储,数据结构为⼀棵MPT
-
改编⾃Solidity⽂档的智能合约例子:简单的公开拍卖
// 声明使⽤Solidity的版本 pragma solidity ^0.4.21; contract SimpleAuction { // 状态变量 address public beneficiary; // 拍卖受益⼈ unit public auctionEnd; // 结束时间 address public highestBidder; // 当前的最⾼出价⼈ mapping(address => uint) bids; // 所有竞拍者的出价 address[] bidders; // 所有竞拍者,前述mapping⽆法遍历,因此需要单独存储,动态数组 // 需要记录的事件 // log记录 event HighestBidIncreased(address bidder, uint amount); event Pay2Beneficiary(address winner, uint amount); // 构造函数,仅在合约创建时调⽤⼀次 // 以受益者地址`_beneficiary`的名义 // 创建⼀个简单的拍卖,拍卖时间为`_biddingTime`秒 constructor(uint _biddingTime, address _beneficiary) public { beneficiary = _beneficiary; auctionEnd = now + _biddingTime; } // 成员函数,可以被⼀个外部账户或合约账户调⽤ // 对拍卖进⾏出价,随交易⼀起发送的ether与之前已经发送的ether的和为本次出价 function bid() public payable { ... } // 使⽤withdraw模式 // 由投标者⾃⼰取回出价,返回是否成功 function withdraw() public returns (bools) { ... } // 结束拍卖,把最⾼的出价发送给受益⼈ function pay2Beneficiary() public returns (bool) { ... } } -
账户调⽤
- 外部账户调⽤合约账户
- 对于A→B这个交易,如果B是⼀个外部账户,则这笔tx是⼀次普通转账,⽽如果B是⼀个智能合约账户,则这笔tx就是⼀次A对B合约的调⽤
- 创建⼀个交易,接收地址为要调⽤的那个智能合约的地址,data域填写要调⽤的函数及其参数的编码值
- 对于A→B这个交易,如果B是⼀个外部账户,则这笔tx是⼀次普通转账,⽽如果B是⼀个智能合约账户,则这笔tx就是⼀次A对B合约的调⽤
- 合约账户调⽤合约账户:3种
- 直接调⽤:
contract A { event LogCallFoo(string str); function foo(string str) returns (uint) { emit LogCallFoo(str); return 123; } } contract B { uint ua; function callAFooDirectly(address addr) public { A a = A(addr); ua = a.foo("call foo directly"); } }- 如果在执⾏a.foo()过程中抛出错误,则callAFooDirectly也抛出错误,本次调⽤全部回滚
- ua为执⾏a.foo("call too directly")的返回值
- 可以通过.gas()和.value()调整提供的gas数量或提供⼀些ETH
- 错误处理:直接调⽤的⽅式,⼀⽅产⽣异常会导致另⼀⽅也进⾏回滚操作
- address调⽤:
contract C { function callAFooByCall(address addr) public returns (bool) { bytes4 funcsig = bytes4(keccak256("foo(string)")); if (addr.call(funcsig, "call foo by func call")) return true; return false; } }- 使⽤address类型的call()函数
- 第⼀个参数被编码成4个字节,表示要调⽤的函数的签名
- 其它参数会被扩展到32字节,表示要调⽤函数的参数
- 上⾯的这个例⼦相当于A(addr).foo("call foo by func call")
- 返回⼀个布尔值表明了被调⽤的函数已经执⾏完毕(true)或者引发⼀个EVM异常(false),⽆法获取函数返回值
- 可以通过.gas()和.value()调整提供的gas数量或提供⼀些ETH
- 错误处理:address.call()的⽅法,如果调⽤过程中被调⽤合约产⽣异常,会导致call()返回false,但发起调⽤的函数不会抛出异常,⽽是继续执⾏
- 代理调⽤:
- 使⽤⽅法与call()相同,只是不能使⽤.value()
- 区别在于是否切换上下⽂:
- call()切换到被调⽤的智能合约上下⽂中
- delegatecall()只使⽤给定地址的代码,其它属性(存储、余额等)都取⾃当前合约
- delegatecall()的⽬的是使⽤存储在另外⼀个合约中的库代码
// 对拍卖进⾏出价,随交易⼀起发送的ether与之前已经发送的ether的和为本次出价 // payable function bid() public payable { ... }- 如果⼀个函数可以接收外部转账,则必须标记为payable
- 该例背景为拍卖,bid()为出价,因此需要payable进⾏标记
- withdraw()为其它未拍卖到的⼈将锁定在智能合约中的钱取出的函数,其不涉及转账,因此不需要payable进⾏标记
// fallback()函数: function() public [payable] { ... }- 匿名函数,没有参数也没有返回值
- 在两种情况下会被调⽤:
- 直接向⼀个合约地址转账⽽不加任何data
- 被调⽤的函数不存在
- 如果转账⾦额不是0,同样需要声明payable,否则会抛出异常
- 该函数主要是防⽌A向B转账,但没有在data域中说明要调⽤哪个函数或要调⽤的函数不存在,此时调⽤fallback()函数
- 如果没有fallback(),在发⽣之前的情况后会直接抛出异常
- 直接调⽤:
- 外部账户调⽤合约账户
-
智能合约创建与运⾏
- 智能合约的代码写完后,要编译成bytecode
- 创建合约:外部账户发起⼀个转账交易到0x0的地址
- 转账的⾦额是0,但是要⽀付汽油费
- 合约的代码放在data域⾥
- 矿⼯把这个智能合约发布到区块链后,会返回此合约的地址(智能合约本身有⼀个合约账户,包含着状态信息)
- ⾃此该合约⼀直就在区块链上了, 所有⼈都可以调⽤
- 智能合约运⾏在EVM (
Ethereum Virtual Machine) 上:- EVM设计思想类似于JAVA中的JVM,便于跨平台增强可移植性
- EVM中寻址空间为256位,⽽⽬前个⼈机主流为32位和64位,与之存在较⼤差距
- 以太坊是⼀个交易驱动的状态机:
- 调⽤智能合约的交易发布到区块链上后,每个矿⼯都会执⾏这个交易,从当前状态确定性地转移到下⼀个状态
-
汽油费
- 以太坊中功能很充⾜,提供图灵完备的平台,从⽽使得以太坊相对于⽐特币可以实现很多功能,但这也导致⼀些问题,例如当⼀个全节点收到⼀个对智能合约的调⽤后怎么知晓其是否会导致死循环
- 事实上,⽆法预知其是否会导致死循环,该问题是⼀个停机问题
Halting Problem,⽽停机问题不可解 - 以太坊引⼊汽油费机制将该问题交给了发起交易的账户
- 事实上,⽆法预知其是否会导致死循环,该问题是⼀个停机问题
- 以太坊规定,执⾏合约中的指令需要收取汽油费,并且由发起交易的⼈进⾏⽀付
type txdata struct { AccountNonce uint64 // 交易序号 Price *big.Int // 单位汽油价格 GasLimit uint64 // 愿意⽀付最⼤汽油量 Recipient *common.Address // 收款⼈地址 Amount big.Int // 转账⾦额 Payload []byte // data域 ... }- EVM中不同指令消耗的汽油费不同:
- 简单的指令很便宜(如四则运算),复杂的或者需要存储状态的指令很贵(如取哈希)
- 当⼀个全节点收到⼀个对智能合约的调⽤:
- 先按照最⼤汽油费
GasLimit收取,从其账户⼀次性扣除 - 再根据实际执⾏情况,多退少回滚
- 汽油费不够会引发回滚,⽽⾮简单的补⻬
- 先按照最⼤汽油费
- 以太坊中存在Gaslimit,通过收取汽油费保障系统中不会存在对资源消耗特别⼤的调⽤:
- 与⽐特币不同,⽐特币直接通过限制区块⼤⼩1MB保障对⽹络资源压⼒不会过⼤,这1MB⼤⼩是固定的,⽆法修改
- 以太坊中,每个矿⼯都可以以前⼀个区块中的gaslimt为基数,进⾏上调或下调1/1024,从⽽保证通过绝⼤多数区块不断上下调整,得到⼀个较为理想化的gaslimt值
- 最终整个系统的gaslimt就是所有矿⼯希望的平均值
- 在block header中包含了gaslimit,其并⾮将所有交易的消耗汽油费相加,⽽是该区块中所有交易能够消耗的资源的上限
- 引⼊汽油费的理由:
- 在⽐特币系统中,交易是⽐较简单的,仅仅是转账操作(即可以通过交易的字节数衡量出交易所需要消耗的资源数量)
- 以太坊中引⼊了智能合约,智能合约逻辑很复杂,其字节数与消耗资源数并⽆关联
- 因此要根据交易的具体操作收费,所以引⼊了汽油费这⼀概念
- 转账⾦额和汽油费是不同的:
- 汽油费是为了让矿⼯打包该交易
- 转账⾦额是单纯为了转账,其可以为0,但汽油费必须给
- 以太坊中功能很充⾜,提供图灵完备的平台,从⽽使得以太坊相对于⽐特币可以实现很多功能,但这也导致⼀些问题,例如当⼀个全节点收到⼀个对智能合约的调⽤后怎么知晓其是否会导致死循环
-
错误处理
- 以太坊中交易具有原⼦性,要么全执⾏,要么全不执⾏,不会只执⾏⼀部分(包含智能合约) 需要注意的是,在执⾏过程中产⽣错误导致回滚,已经消耗掉的汽油费是不会退回的
- 这有效防⽌了恶意节点对全节点的恶意调⽤
- 智能合约中不存在⾃定义的try-catch结构,⼀旦遇到异常,除特殊情况外,本次执⾏操作全部回滚
- 可以抛出错误的语句:
assert(bool condition):如果条件不满⾜就抛出——⽤于内部错误require(bool condition):如果条件不满⾜就抛掉——⽤于输⼊或者外部组件引起的错误function bid() public payable { // 对于能接收以太币的函数,关键字payable是必须的 // 拍卖尚未结束 require(now <= auctionEnd); ... }- revert():终⽌运⾏并回滚状态变动(⽆条件抛错)
- 以太坊中交易具有原⼦性,要么全执⾏,要么全不执⾏,不会只执⾏⼀部分(包含智能合约) 需要注意的是,在执⾏过程中产⽣错误导致回滚,已经消耗掉的汽油费是不会退回的
-
嵌套调⽤
- 智能合约的执⾏具有原⼦性:执⾏过程中出现错误,会导致回滚
- 嵌套调⽤是指⼀个合约调⽤另⼀个合约中的函数
- 嵌套调⽤是否会触发连锁式的回滚:
- 问:如果被调⽤的合约执⾏过程中发⽣异常,会不会导致发起调⽤的这个合约也跟着⼀起回滚?
- 答:有些调⽤⽅法会引起连锁式的回滚,有些则不会
- 直接调⽤会
- call()调⽤不会
- ⼀个合约直接向⼀个合约账户⾥转账,没有指明调⽤哪个函数,仍然会引起嵌套调⽤
- 因为由于fallback函数的存在,仍有可能会引发嵌套调⽤
-
挖矿与智能合约执⾏
- 问:假设全节点要打包⼀些交易到区块中,其中存在某些交易是对智能合约的调⽤。全节点应该先执⾏智能合约再挖矿,还是先挖矿获得记账权后执⾏智能合约?
- 答:先执⾏智能合约后挖矿
- 汽油费扣除机制:
- ⾸先,状态树、交易树、收据树 这三棵树都位于全节点中,是全节点在本地维护的数据结构,记录了每个账户的状态等数据,所以该节点收到调⽤时,是在本地对该账户的余额减掉即可
- 也就是说,智能合约在执⾏过程中,修改的都是本地的数据结构,只有在该智能合约被发布到区块链上,所有节点才需要同步状态
- 最后获得记账权的节点获得汽油费
- 问:智能合约⽀持多线程吗?
- 答:不支持,Solidity里也没有⽀持多线程的语句
- 因为以太坊本质为⼀个交易驱动的状态机,⾯对同⼀组输⼊,必须转移到⼀个确定的状态
- 对于多线程来说,同⼀组输⼊的输⼊顺序不同,最终的结果可能不⼀致
- 此外,其它可能导致执⾏结果不确定的操作也不⽀持,例如产⽣随机数
- 正是因为其不⽀持多线程,所以⽆法通过系统调⽤获得系统信息,因为每个全节点环境并⾮完全⼀样
- 因此只能通过固定的结构获取,以下为可以获得的区块链信息和调⽤信息
block.blockhash(uint blockNumber) returns (bytes32): 给定区块的哈希——仅对最近的256个区块 有效⽽不包括当前区块 block.coinbase: (address),挖出当前区块的矿⼯地址 block.difficulty: (uint),当前区块难度 block.gaslimit: (uint),当前区块gas限额 block.number: (uint),当前区块号 block.timestamp: (uint),⾃unix epoch起始当前区块以秒计的时间戳 msg.data: (bytes),完整的calldata msg.gas: (uint),当前调⽤剩余gas msg.sender: (address),消息发送者(当前调⽤) msg.sig: (bytes64),calldata的前4字节(也就是函数标识符) msg.value: (uint),随消息发送的wei的数量 now: (uint),⽬前区块时间戳(block.timestamp) tx.gasprice: (uint),交易的gas价格 tx.origin: (address),交易发起者(完全的调⽤链)
- 问:假设全节点要打包⼀些交易到区块中,其中存在某些交易是对智能合约的调⽤。全节点应该先执⾏智能合约再挖矿,还是先挖矿获得记账权后执⾏智能合约?
-
Receipt数据结构
- 每个交易执⾏完成后会形成⼀个收据,以下为收据的数据结构:
type Receipt struct { // Consensus fields PostState []byte Status uint64 // 交易执⾏的情况 CumulativeGasUsed uint64 Bloom Bloom Logs []*Log // Implementation fields TxHash common.Hash ContractAddress common.Address GasUsed uint64 } - 地址类型,所有智能合约均可显式地转换成地址类型
<address>.balance: (uint256),以Wei为单位的地址类型的余额 <address>.transfer(uint256 amount): 向地址类型发送数量为amount的Wei,失败时抛出异常,发送 2300 gas的矿⼯费,不可调节 <address>.send(uint256 amount) returns (bool): 向地址类型发送数量为amount的Wei,失败时返回 false,发送2300 gas的矿⼯费,不可调节 <address>.call(...) returns (bool): 发出底层CALL,失败时返回false,发送所有可⽤gas,不可调节 <address>.callcode(...) returns (bool): 发出底层CALLCODE,失败时返回false,发送所有可⽤ gas,不可调节 <address>.delegatecall(...) returns (bool): 发出底层DELEGATECALL,失败时返回false,发送所 有可⽤gas,不可调节
- 每个交易执⾏完成后会形成⼀个收据,以下为收据的数据结构:
-
转账
- ETH中转账有以下三种⽅法:
<address>.transfer(uint256 amount) <address>.send(uint256 amount) returns (bool) <address>.call.value(uint256 amount)()- transfer在转账失败后会导致连锁性回滚,抛出异常,send转账失败会返回false,不会导致连锁性回滚,call的⽅式本意是⽤于发起函数调⽤,但是也可以进⾏转账
- 前两者在调⽤时,只发送2300wei的汽油费,这点汽油费很少,差不多只能写⼀个log,⽽call的⽅式则是将⾃⼰还剩下的所有汽油费全部发送过去(合约调⽤合约时常⽤call,没⽤完的汽油费会退回)
- 例如A合约调⽤B合约,⽽A不知道B要调⽤哪些合约,为了防⽌汽油费不⾜导致交易失败,A将⾃⼰所有汽油费发给B来减少失败可能性
- ETH中转账有以下三种⽅法: