以太坊(Ethereum)是一个支持智能合约的区块链平台,它与比特币最大的不同是,以太坊通过一个虚拟机(EVM)可以运行智能合约。
以太坊是Vitalik Buterin(维塔利克·布特林,人称V神)在2013年提出的概念,Vitalik最早参与了比特币社区的开发,并希望比特币把功能受限的脚本扩展成图灵完全的编程环境,但没有得到比特币开发社区的认同,于是他决定另起炉灶,打造一个新的区块链平台,目标是运行去中心化的程序。
以太坊从2015年正式启动并运行,期间经历过DAO攻击造成的硬分叉。和比特币类似,以太坊也通过PoW进行挖矿,其挖出的平台币叫以太币(Ether),目前每个区块奖励是2 Ether,约13~15秒左右出一个块。
和比特币相比,以太坊在以下几点上有所不同:
账户模型
比特币使用的UTXO模型是一种对开发友好、易于实现清结算的模型,但对用户不友好,因为普通用户所认知的账户是一个账号、对应余额变动的模型。以太坊的账户模型和比特币不同,它就是余额模型,即交易引发账户余额的变动,这与传统金融账户一致。
智能合约
从比特币的支付原理可知,任何支付实际上都是在执行比特币脚本,只有脚本成功执行,支付才能成功。
以太坊的交易与之类似,并且更进一步,它实现了一个图灵完备的脚本语言,运行在EVM(Ethereum Virtual Machine,以太坊虚拟机)中,任何人都可以编写合法的脚本来执行任意逻辑(有很多限制),例如,定义一种新的代币,抵押贷款等。
以太坊账户负责存储用户的以太坊余额。对大多数普通用户来说,以太坊账户和银行账户非常类似,通常只需要一个账户即可。
确切地说,以太坊账户分为外部账户和合约账户两类:
外部账户:即普通用户用私钥控制的账户;
合约账户:一种拥有合约代码的账户,它不属于任何人,也没有私钥与之对应。
本节我们仅讨论普通用户使用的外部账户。
和比特币类似,一个以太坊账户就是一个公钥哈希后得到的地址,它是由一个私钥推导出对应的公钥,然后再计算出地址。其中,私钥与公钥算法与比特币完全相同,均为secp256k1椭圆曲线,但和比特币不同的是,以太坊采用非压缩公钥,然后直接对公钥做keccak256哈希,得到32字节的哈希值,取后20字节加上0x前缀即为地址:

用代码实现如下:
const
randomBytes = require('randombytes'),
ethUtil = require('ethereumjs-util');
// 生成256bit的随机数作为私钥:
let priKey = randomBytes(32).toString('hex');
// 计算公钥(非压缩格式):
let pubKey = ethUtil.privateToPublic(new Buffer(priKey, 'hex')).toString('hex');
// 计算地址:
let addr = ethUtil.pubToAddress(new Buffer(pubKey, 'hex')).toString('hex');
console.log('Private key: 0x' + priKey);
console.log('Public key: 0x' + pubKey);
console.log('Address: 0x' + addr);
和比特币采用Base58或Bech32编码不同,以太坊对私钥和地址均采用十六进制编码,因此它没有任何校验,如果某一位写错了,仍然是一个有效的私钥或地址。
keccak256哈希算法在以太坊中也被称为SHA3算法,但是要注意,keccak算法原本是SHA3的候选算法,然而在SHA3最后的标准化时,对keccak做了改进,因此,标准的SHA3算法和keccak是不同的,只是以太坊在开发时就选择了尚未成为SHA3标准的keccak算法。后续我们在讨论以太坊的哈希算法时,一律使用keccak256而不是SHA3-256。
因为以太坊的地址就是原始哈希的后20字节,并且以十六进制表示,这种方法简单粗暴,但没有校验。地址中任何数字出错都仍是一个有效地址。为了防止抄错,以太坊通过EIP-55实现了一个带校验的地址格式,它的实现非常简单,即对地址做一个keccak256哈希,然后按位对齐,将哈希值>=8的字母变成大写:
original addr = 0x29717bf51d8afca452459936d395668a576bce66
keccak hash = e72ecce2eb2ed0ffab5e05f043ee68fab3df759d...
checksum addr = 0x29717BF51D8AFcA452459936d395668A576Bce66
因此,以太坊地址就是依靠部分变成大写的字母进行校验,它的好处是带校验的地址和不带校验的地址对钱包软件都是一样的格式,缺点是有很小的概率无法校验全部小写的地址。
const ethUtil = require('ethereumjs-util');
console.log('is valid address: ' + ethUtil.isValidAddress('0x29717bf51d8afca452459936d395668a576bce66')); // true
console.log('is valid checksum address: ' + ethUtil.isValidChecksumAddress('0x29717BF51D8AFcA452459936d395668A576Bce66')); // true
console.log('is valid checksum address: ' + ethUtil.isValidChecksumAddress('0x29717BF51D8AFcA452459936d395668A576BcE66')); // false
下面这个程序可以自动搜索指定前缀地址的私钥:
const randomBytes = require('randombytes');
const ethUtil = require('ethereumjs-util');
// 搜索指定前缀为'0xAA...'的地址:
let prefix = '0xAA';
if (/^0x[a-fA-F0-9]{1,2}$/.test(prefix)) {
let
max = parseInt(Math.pow(32, prefix.length-2)),
qPrefix = prefix.toLowerCase().substring(2),
prettyPriKey = null,
prettyAddress = null,
priKey, pubKey, addr, cAddr, i;
for (i=0; i<max; i++) {
priKey = randomBytes(32).toString('hex');
pubKey = ethUtil.privateToPublic(new Buffer(priKey, 'hex')).toString('hex');
addr = ethUtil.pubToAddress(new Buffer(pubKey, 'hex')).toString('hex');
if (addr.startsWith(qPrefix)) {
cAddr = ethUtil.toChecksumAddress('0x' + addr);
if(cAddr.startsWith(prefix)) {
prettyPriKey = priKey;
prettyAddress = cAddr;
break;
}
}
}
if (prettyPriKey === null) {
console.error('Not found.');
} else {
console.log('Private key: 0x' + prettyPriKey);
console.log('Address: ' + prettyAddress);
}
} else {
console.error('Invalid prefix.');
}
原理是不断生成私钥和对应的地址,直到生成的地址前缀满足指定字符串。一个可能的输出如下:
Private key: 0x556ba88aea1249a1035bdd3ec2d97f8c60404e26ecfcd7757e0906885d40322e
Address: 0xAA6f2ea881B96F87152e029f69Bd443834D99f97
注意:如果你想用这种方式生成地址,请确保电脑无恶意软件,并在断网环境下用Node执行而不是在浏览器中执行。
因为以太坊和比特币的非对称加密算法是完全相同的,不同的仅仅是公钥和地址的表示格式,因此,比特币的HD钱包体系也完全适用于以太坊。用户通过一套助记词,既可以管理比特币钱包,也可以管理以太坊钱包。
以太坊钱包的派生路径是m/44'/60'/0'/0/0,用代码实现如下:
const
bitcoin = require('bitcoinjs-lib'),
bip39 = require('bip39'),
ethUtil = require('ethereumjs-util');
// 助记词和口令:
let
words = 'bleak version runway tell hour unfold donkey defy digital abuse glide please omit much cement sea sweet tenant demise taste emerge inject cause link',
password = 'bitcoin';
// 计算seed:
let seedHex = bip39.mnemonicToSeedHex(words, password);
console.log('seed: ' + seedHex); // b59a8078...c9ebfaaa
// 生成root:
let root = bitcoin.HDNode.fromSeedHex(seedHex);
console.log('xprv: ' + root.toBase58()); // xprv9s21ZrQH...uLgyr9kF
console.log('xpub: ' + root.neutered().toBase58()); // xpub661MyMwA...oy32fcRG
// 生成派生key:
let child0 = root.derivePath("m/44'/60'/0'/0/0");
let prvKey = child0.keyPair.d.toString(16);
let pubKey = ethUtil.privateToPublic(new Buffer(prvKey, 'hex')).toString('hex');
let address = '0x' + ethUtil.pubToAddress(new Buffer(pubKey, 'hex')).toString('hex');
let checksumAddr = ethUtil.toChecksumAddress(address);
console.log(" prv m/44'/60'/0'/0/0: 0x" + prvKey); // 0x6c03e50ae20af44b9608109fc978bdc8f081e7b0aa3b9d0295297eb20d72c1c2
console.log(" pub m/44'/60'/0'/0/0: 0x" + pubKey); // 0xff10c2376a9ff0974b28d97bc70daa42cf85826ba83e985c91269e8c975f75f7d56b9f5071911fb106e48b2dbb2b30e0558faa2fc687a813113632c87c3b051c
console.log(" addr m/44'/60'/0'/0/0: " + address); // 0x9759be9e1f8994432820739d7217d889918f2f07
console.log("check-addr m/44'/60'/0'/0/0: " + checksumAddr); // 0x9759bE9e1f8994432820739D7217D889918f2f07
因为以太坊采用账户余额模型,通常情况下一个以太坊地址已够用。如果要生成多个地址,可继续派生m/44'/60'/0'/0/1、m/44'/60'/0'/0/2等。
以太坊的私钥和公钥采用和比特币一样的ECDSA算法和secp256k1曲线,并且可以复用比特币的HD钱包助记词;
以太坊的地址采用对非压缩公钥的keccak256哈希后20字节,并使用十六进制编码,可以通过大小写字母实现地址的校验。
比特币的区块链是由PoW保证每个区块都指向前一个区块,而在每一个区块内部,由一个独立的Merkle Tree来保证所有交易的不可篡改。用户的比特币是以UTXO的方式存储的,因此,比特币的交易就是不断地消耗现有的UTXO,并产生新的UTXO。
而以太坊采用的是账户模型,如果小明的账户在某个区块的资产是1 ETH,当小明给小红转账0.2 ETH后,刨除手续费,他的账户还剩下约0.8 ETH。由于小明的账户地址不变,所以,以太坊的区块结构必须能在每个区块持续地跟踪并记录小明的账户余额变动。因此,和比特币相比,以太坊的区块数据结构更加复杂。
以太坊存储账户数据的数据结构是MPT:Merkle Patricia Tree,它是一种改进的Merkle Tree。当MPT的每个叶子结点的值确定后,计算出的Root Hash就是完全确定的。例如,在第一个区块中,4个账户的余额确定后,即可确定Root1:
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
Root1
│ ┌───┐ │
│ │
│ └───┘ │
│
│ ┌─────┴─────┐ │
│ │
│ ┌───┐ ┌───┐ │
│ │ │ │
│ └───┘ └───┘ │
│ │
│ ┌──┴──┐ ┌──┴──┐ │
│ │ │ │
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │
│5.5│ │0.2│ │1.7│ │9.0│
│ └───┘ └───┘ └───┘ └───┘ │
─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
在第二个区块中,如果发生了转账,将计算出一个新的Root2:
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
Root2
│ ┌───┐ │
│ │
│ └───┘ │
│
│ ┌─────┴─────┐ │
│ │
│ ┌───┐ ┌───┐ │
│ │ │ │
│ └───┘ └───┘ │
│ │
│ ┌──┴──┐ ┌──┴──┐ │
│ │ │ │
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │
│5.5│ │0.1│ │1.8│ │9.0│
│ └───┘ └───┘ └───┘ └───┘ │
─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
每一个区块通过Root Hash将完全确定所有账户的状态,所以,从全局看,以太坊就是一个状态机,每个区块通过记录一个stateRoot来表示一个新状态。如果给定某个区块的stateRoot,我们肯定能完全确定所有账户的所有余额等信息。因此,stateRoot就被称为当前的世界状态。
有的同学可能会思考,如果第一个区块只有几个账户,随着账户的增加,如果有数百万个账户,到后面岂不是区块存储的数据量会越来越大?
实际上,每个区块的stateRoot表示的是一个完全状态的逻辑树,但每个区块记录的数据只包括修改的部分,如果我们观察第二个区块的树,它实际上只记录修改的两个账户,以及两个账户因修改后导致的上层路径的Hash发生的变化:

想要将一个有几百万节点的树完整地放入内存需要消耗大量的内存,而以太坊全节点也并不会将整颗逻辑树放入内存。实际上,每个节点的数据被存放到LevelDB中,节点仅在内存中存储当前活动的一些账户信息。如果需要操作某个不在内存的账户,则会将其从LevelDB加载到内存。如果内存不够,也会将长期不活动的节点从内存中移除,因为将来可以通过节点的路径再次从LevelDB加载。
一个以太坊账户由4部分数据构成:
nonce
balance
storageRoot
codeHash
其中,nonce是一个递增的整数,每发送一次交易,nonce递增1,因此,nonce记录的就是交易次数。
balance记录的就是账户余额,以wei为单位,1 Ether等于1018wei。
如果一个账户是合约账户,则storageRoot存储合约相关的状态数据,codeHash存储合约代码的Hash。对于外部账户,这两部分数据都是空。
一个以太坊区块由区块头和一系列交易构成。区块头除了记录parentHash(上一个区块的Hash)、stateRoot(世界状态)外,还包括:
sha3Uncles:记录引用的叔块;
transactionRoot:记录当前区块所有交易的Root Hash;
receiptsRoot:记录当前区块所有交易回执的Root Hash;
logsBloom:一个Bloom Filter,用于快速查找Log;
difficulty:挖矿难度值;
number:区块高度,严格递增的整数;
timestamp:区块的时间戳(以秒为单位);
……
transactionRoot和receiptsRoot也是两个MPT树,但他两和stateRoot不同,他们仅表示当前区块的两棵树,与前面的区块状态无关。
以太坊采用和比特币类似的PoW挖矿,只是算法为改进的Ethash算法。PoW挖矿肯定会产生分叉,但由于最长链共识,最终某个分叉将胜出:

但是和比特币不同的是,虽然#4的竞争导致一个胜出另一个失败,但以太坊鼓励后续的#5区块引用另一个废弃的#4块,这种引用的废弃块被称为叔块(Uncle Block):

区块头记录的sha3Uncles就是叔块,一个区块可引用0~2个叔块,且叔块高度必须在前7层之内。
叔块的目的是给予竞争失败的矿工部分奖励,避免出现较长的分叉。
以太坊的核心数据结构是以Merkle Patricia Tree记录的世界状态,每个区块通过打包新的交易,从而导致世界状态的变化。
在比特币中,交易就是消耗已有UTXO,并通过执行脚本产生新的UTXO,其中隐含的新旧差额即为矿工手续费。
在以太坊中,交易也需要手续费,手续费被称为Gas(汽油),它的计算比比特币要复杂得多。
以太坊除了最基本的转账:即从一个账户支付Ether到另一个账户,还支持执行合约代码。合约代码是图灵完备的编程语言,通过EVM(以太坊虚拟机)执行。如果某个合约编写了一个无限循环,那么所有节点执行该合约代码,岂不是永远无法结束?
为了保证合约代码的可靠执行,以太坊给每一个虚拟机指令都标记了一个Gas基本费用,称为gasUsed。例如,加减计算的费用是3,计算SHA3的费用是30,输出日志的费用是375,写入存储的费用高达20000。总的来说,消耗CPU比消耗存储便宜,简单计算比复杂计算便宜,读取比写入便宜。
除了gasUsed外,用户还需要提供一个gasPrice,以Gwei(1Gwei=109Wei)为单位。通过竞价得到一个矿工愿意接受的gasPrice。如果一个交易消耗了120000的gasUsed,而gasPrice是50 Gwei,则交易费用是:
120000 x 50 Gwei = 6000000 Gwei = 0.006 Ether
但是在执行代码的时候,存在条件判断、循环等语句,同一段代码,执行的结果也可能不同,因此,事前预计一个合约执行要花费多少Gas,不现实。
所以以太坊规定,一笔交易,先给出gasPrice和gasLimit,如果执行完成后有剩余,剩余的退还,如果执行过程中消耗殆尽,那么交易执行失败,但已执行的Gas不会退。
太复杂了是不是?我们还是举个例子。
假定某个账户想执行一笔交易,他给出gasPrice为50 Gwei,预估gasUsed约120000,设定gasLimit为150000,则预支付的Ether为:
150000 x 50 Gwei = 7500000 Gwei = 0.0075 Ether
如果账户的Ether余额不足0.0075,则该交易根本无法发送。如果账户余额等于或超过0.0075,例如余额为0.008,则矿工可以将该交易打包。假设实际执行消耗的gasUsed为120000,则交易费用0.006,账户剩余0.002。
很少有交易能准确预估gasUsed,只有标准转账交易是21000,因此,标准的转账交易gasLimit可以设置为21000(即恰好消耗完毕无剩余)。
Gas Price是全网用户竞价产生的,它时刻在波动。如果交易少,Gas Price将下降,如果交易多,网络拥堵,则Gas Price将上升。以太坊的Gas价格可以在Etherscan跟踪。
以太坊区块为每一笔交易都会产生一笔回执(Recipt),表示交易的最终状态。一个回执信息主要包括:
status:执行结果,1表示成功,0表示失败;
gasUsed:已消耗的Gas数量;
txHash:交易Hash;
logs:交易产生的日志;
……
转账交易是指两个外部账号转移Ether,我们以测试网的交易0xf04d...b7ba为例,可以看到:
Transaction Hash: 0xf04d...b7ba,这是交易Hash,即交易的唯一标识;
Status: Success,表示交易成功;
From: 0x0c46...2978,交易的发送方;
To: 0x77e4...e34c,交易的接收方;
Value: 0.02759796846726396 Ether,交易发送的Ether;
Gas Price: 1.500000788 Gwei,Gas的价格;
Gas Limit: 21,000,转账交易恰好消耗21000Gas,因此总是21000;
Gas Used by Transaction: 21,000 (100%),消耗的Gas占比,这里恰好全部消耗完;
Nonce:1,发送方的nonce,1表示第2笔交易;
Input Data: 0x,因为是转账交易,没有输入数据,因此为空。
合约交易就是指一个外部账号调用某个合约的某个public函数。我们以测试网的交易0x2235...132e为例,可以看到:
From: 0x0c46...2978,交易的发起方,该地址一定是外部账户;
To: 0x5b2a...5a46,交易的接收方,这里地址是一个合约地址;
Value: 0 Ether,这里发送的Ether是0,即没有向合约发送Ether;
Gas Limit: 105,406,这是交易发起前设定的最大Gas;
Gas Used by Transaction: 70,271 (66.67%),这是交易实际消耗的Gas;
Input Data: 0xb3f98adc...,这是交易的输入数据,其中包含了调用哪个函数,以及传递的参数。
可见,转账交易的Gas费用是固定的,而合约交易只能预估,具体费用以实际执行后消耗的为准。
以太坊的交易需要消耗Gas,而Gas价格和实际消耗的数量决定了一个交易实际消耗的Ether,即交易成本。
合约交易无法精确地确定Gas数量,只能预估并给出Gas Limit。
以太坊相比比特币的一个重大创新就是它支持智能合约(Smart Contract)。
所谓智能合约,就是一种运行在区块链上的程序。和普通程序不同的是,智能合约要保证在区块链网络的每一个节点中运行的结果完全相同,这样才能使任何一个节点都可以验证挖矿产出节点生成的区块里,智能合约执行的结果对不对。
因此,以太坊提供了一个EVM(Ethereum Virtual Machine)虚拟机来执行智能合约的字节码,并且,和普通程序相比,为了消除程序运行的不确定性,智能合约有很多限制,例如,不支持浮点运算(因为浮点数有不同的表示方法,不同架构的CPU运行的浮点计算精度都不同),不支持随机数,不支持从外部读取输入等等。
类似于Java源码被编译为JVM可执行的字节码,我们也需要一种高级语言来编写智能合约,然后编译成EVM的字节码。最常用的开发智能合约的语言是以太坊专门为其定制的Solidity语言,后续我们会详细介绍Solidity的用法。
一个智能合约被编译后就是一段EVM字节码,将它部署在以太坊的区块链时,会根据部署者的地址和该地址的nonce分配一个合约地址,合约地址和账户地址的格式是没有区别的,但合约地址没有私钥,也就没有人能直接操作该地址的合约数据。要调用合约,唯一的方法是调用合约的公共函数。
这也是合约的一个限制:合约不能主动执行,它只能被外部账户发起调用。如果一个合约要定期执行,那只能由线下服务器定期发起合约调用。
此外,合约作为地址,可以接收Ether,也可以发送Ether。合约内部也可以存储数据。合约的数据存储在合约地址关联的存储上,这就使得合约具有了状态,可以实现比较复杂的逻辑,包括存款、取款等。
合约在执行的过程中,可以调用其他已部署的合约,前提是知道其他合约的地址和函数签名,这就大大扩展了合约的功能。例如,一个合约可以调用另一个借贷合约的借款方法,再调用交易合约,最后再调用还款方法,实现所谓的“闪电贷”(即在一个合约调用中实现借款-交易-还款)功能。多个合约的嵌套调用也使得因为代码编写的漏洞导致黑客攻击的可能性大大增加。为了避免漏洞,编写合约时需要更加小心。
技术→关于编写、部署、调用合约,参见
https://www.liaoxuefeng.com/wiki/1207298049439968/1207298408350656
金融→关于在交易所中玩转合约,参见
