资料:
BTC 密码学原理
比特币,又称加密货币(crypto-currency),它主要利用了密码学中的哈希函数(cryptographic hash function)的抗碰撞 特性(collision resistance)和单向散列特性(hiding)
值得注意的是,抗碰撞特性并未在数学上得到证明,这只是一个经验特性,例如 md5,就已经存在了人为制作哈希碰撞的方法
除了抗碰撞性和单向散列特性,应用于区块链的 hash function 还需要具有 puzzle friendly 的特性,以确保输出是完全不可预测(没有规律的),这样才能进行工作量证明: difficult to solve but easy to verify
比特币的开户过程很简单:只需要在本地创建一对公私钥即可,用于签名
BTC 数据结构
hash pointers:对于一个结构体,普通的指针只保存了该结构体的起始地址,哈希指针不仅保存了该结构体的起始地址,还保存了这个结构体内容的哈希值,以方便校验结构体的内容
区块链就是一个由哈希指针串联起来的链表,实现了 tamper-evident log(防窜改日志)
区块链中应用的第二个数据结构是 Merkle Tree,就是采用哈希指针代替普通指针的 Binary Tree
内部节点都只保存哈希指针,叶子节点存储了交易数据(区块)
只要记住了根哈希值,就能检测出对树中任何节点的修改
每个区块分为区块头和区块体 2 部分,比特币当中每个区块通过哈希指针连接在一起,每个区块包含的交易存储在 Merkle Tree 当中
区块头 记录了该区块记录的所有交易构成的 Merkle Tree 的根哈希值
BTC 系统中的节点分为 2 类:全节点和轻节点
全节点保存了整个节点的信息;轻节点只保存了 Block Header
轻节点为了验证交易 tx 已经写入区块链,需要沿着 Merkle Tree 逐层向上验证,看最终 Block Header 是否正确:首先轻节点向某个全节点发送请求,请求上图标为红色的三个哈希值(全节点持有了整个 Merkle Tree),然后逐层计算出三个绿色的哈希值,直到最终得到根哈希值
上面这个过程被称为 Merkle Proof (Proof of membership/inclusion),时间复杂度为 O(logn)
BTC 协议
比特币系统中所有的交易都记录在区块链上,每一笔交易分为输入和输出2部分:
- 输入部分要说明币的来源(哈希指针指向币的来源的交易),还要给出自己的公钥(供其他节点验证签名)
- 输出部分要说明收款人公钥的哈希以指明某个收款人
这里涉及2种哈希指针,一种是连接区块的 hash pointer,另一种是指向币的来源的 hash pointer
输出部分给出的收款人公钥的哈希也能防止攻击者利用自己的密钥对伪造交易,因为攻击者的公钥的哈希跟收款人公钥的哈希对不上
BTC 系统检验交易合法性是通过将前一笔交易的输出脚本 拼接上当前交易的输入脚本,然后执行,如果没有错误,则认为交易合法
区块链中的每个区块分为 Block Header 和 Block Body 两部分:
Block Header 包含如下几个字段:
- version
- hash of previous block header
- Merkle root hash
- target(挖矿的难度)
- nonce
Block Body 中包含了交易列表
系统中的节点分为 Full Node 和 Light Node 两类,只有 Full Node 参与了区块链的构造和维护
作为一个分布式系统,BTC 采用的共识机制是最长合法链(按照算力投票):
Block Reward: BTC 系统最初的出块奖励是 50 BTC/Block,它每隔 21w 个区块后就减半,BTC 系统每 10 分钟出一个 Block,所以换算下来每隔 4 年 Block Reward 减半,现在的 Block Reward 是 3.125
BTC 实现
BTC 采用的是 transaction-based ledger,也就是它只记录交易,不记录账户余额。所以 BTC 需要在每一笔交易都要声明账户的来源,可溯源性极强
ETH 采用的是 account-based ledger,它会显示地记录每个账户余额。所以 ETH 就不需要显示地说明币的来源
UTXO: Unspent Transaction Object,记录了未被消费的 BTC(每个人的账户余额)
例如下图中 A -> B(5 BTC), A->C(3 BTC),B 的 5 个 BTC 都花掉了,那么就不记录在 UTXO 中;只有 C 的 3 个 BTC 记录在 UTXO 中
miner 不仅能够从出块奖励(Block Reward) 中获取 mining 的报酬,它还能够从每一笔交易中抽成(transaction fee),例如一笔交易的 inputs 是 100 BTC,但是 outputs 是 99 BTC,其中的一个 BTC 就作为了 miner 为这笔交易记账的抽成
区块实例:
随着 mining 难度的逐渐上升,传统的 uint32_t 的 nonce 可能不再能满足 difficulty 的要求了(遍历所有 nonce 也不能满足 target),这就需要 extra nonce
每个区块中都会包含一个铸币交易(CoinBase Transaction),其中的 CoinBase 可以拿出一些 bits 作为 extra nonce 去满足 target
挖矿过程概率分析
每次尝试 nonce 可以看作是一次伯努利实验(a random experiment with binary outcome),它只有成功与否2种结果,而且成功的概率微乎其微
所有的尝试过程构成一个伯努利过程 (a seqence of independent Bernoulli trials)
假设结果发生的概率为 p,则 n 次伯努利实验的数学期望为 np
当 np 较小(小于 10)时,可以用 Poisson 分布近似伯努利分布
P ( X = k ) = λ k k ! e − λ P(X=k)=\frac {\lambda^k}{k!} e^{-\lambda} P(X=k)=k!λke−λ
BTC 总量分析
由于 BTC 协议规定每隔 21w 个区块,出块奖励减半
初始的出块奖励是50,则求解这个收敛的几何级数的结果就是 2100w,也就是 BTC 系统中 BTC 的总量就是 2100w
Bitcoin is secured by mining
Forking Attack
假设 M 是一个恶意节点,他有一笔转账给了 A,现在他想回滚这笔交易,构造一个转给他自己的交易,与转给 A 的交易构成分叉,然后继续向下扩展恶意链,这样就能让其他诚实节点错误地将恶意链当作最长合法链
为了防范这种攻击,BTC 系统采用"延迟确认"的方式解决:在第一个区块提交后,并不急着确认,而是要等待6个区块都提交后才会统一确认
Selfish Mining
假设在第三个区块之后,一个恶意 miner 已经挖出了第四个区块,但是它不发布,而是选择继续往下挖,假设它抢在其他 miner 挖到第四个区块之前已经挖到了第五个区块(它的算力足够强),这时它在其他 miner 发布第四个区块时也抢着发布自己的第四个和第五个区块,它就获得了2倍的区块奖励
这样做的好处在于,它第五个区块是自己偷偷挖的,不会与系统中的其他 miner 产生竞争
BTC 网络
BCT 协议工作在应用层,它依靠下层的 P2P Layer Netword 进行工作(基于 TCP)
BCT 网络的设计原则是 simple, robust, but not efficient,BCT 网络的交付模式为 best effort
消息在网络中的传播采取 flooding 模式,节点的选取是随机的,不会考虑底层网络的拓扑结构。这样增强了鲁棒性,但是牺牲了效率
BCT 系统的每个节点都会维护一个尚未上链的交易的集合,每收到一笔交易信息,就将其加入集合,并广播给邻居节点
如果节点听到了这笔交易已经被加入到区块链集合中,这笔交易就要从尚未上链的交易的集合中删掉
区块在网络中的传输与交易类似,都是采用广播的方式。BCT 协议限制了区块大小为 1MB
BTC 挖矿难度
为什么要调整挖矿难度
挖矿的过程就是不断尝试 nonce 使区块头哈希小于等于目标阈值的过程:
H ( B l o c k H e a d e r ) ≤ t a r g e t H(Block\ Header) \le target H(Block Header)≤target
BCT 采用的是 SHA256 算法,所以总的状态空间有 2 256 2^{256} 2256
挖矿难度与 target 是成反比关系的,所以随着 BCT 系统中的总算力愈发强大,需要调小 target,增大挖矿难度,以保证每 10 min 出一个区块
保持平均出块时间稳定是有一定的好处的,这样避免了多个 miner 很快同时挖到一个区块(难度太小,平均出块时间很小),从而造成很多分叉的情况。因为分叉数目一旦很多,就会分散了"好矿工"的算力,从而使得 Forking Attack 成为可能
如何调整挖矿难度
BCT 系统规定每隔 2016 个区块就调整一次挖矿难度,换算下来就是每 14 天调整一次挖矿难度
target 按照如下策略进行更新:
t a r g e t ′ = t a r g e t ∗ a c t u a l t i m e e x p e c t e d t i m e target' = target * \frac {actual\ time}{expected\ time} target′=target∗expected timeactual time
其中 actual time 是挖到最近的 2016 个区块所耗费的时间
可见 target 是按照挖到最近的 2016 个区块所耗费的时间与2个星期的比值来更新的,但是这个比例有上下限:不会超过4,也不会小于1/4
BTC 挖矿
全节点
- 一直在线
- 在本地硬盘上维护完整的区块链信息
- 在内存里维护 UTXO 集合,以便快速检验交易的正确性
- 监听 BCT 网络上的交易信息,验证每个交易的合法性
- 决定哪些交易会被打包到区块里
- 监听别的矿工挖出来的区块,验证其合法性
轻节点
- 不是一直在线
- 不用保存整个区块链,只要保存每个区块的块头
- 不用保存全部交易,只保存与自己相关的交易
- 无法检验大多数交易的合法性,只能检验与自己相关的那些交易的合法性
- 无法检测网上发布的区块的正确性
- 可以验证挖矿难度
- 只能检测哪个是最长链,不知道哪个是最长合法链
全节点一旦监听到自己当前正在挖的区块已经被别的矿工挖到,则他应该立即停止自己当前正在挖的区块,重新从本地组织一系列交易构成一个区块并重新挖
因为 mining 的过程是 memoryless(progress free),前面尝试的次数与后面能否挖到无关(伯努利实验),所以即使沿着之前的链继续挖,它能挖到的概率也和重新挖挖到的概率相等
矿池
挖矿设备经历了 CPU-GPU-ASIC(Application Specific IC) 这3代的转变,每次转变都是为了更充分的利用算力资源,让设备更适合挖矿
矿池通常是有一个 manager 管理多个 miner 构成
miner 一般由 ASIC 承担,它只负责计算哈希
manager 负责 BTC 的其他操作,例如检验每个交易的合法性,打包区块,监听 BTC 网络等
为了根据不同 miner 的算力进行分红,需要对每个 miner 的算力进行评估,评估的方法就是设置一个比较大的 target(比较小的难度),交给 miner 去挖,miner 挖到后就提交这个小难度任务(称为 share)给 manager,根据每个 miner 提交 share 的情况评估每个 miner 的算力,从而进行分红
注意 manager 拿到这个 share 后并没有任何用途,这只是一种对 miner 算力的评估手段
矿池的出现可能让他能够占据了 51% 的算力,从而使 Forking Attack 称为可能
Boycott
矿池(占据了超过 51% 的算力)还可能导致 Boycott(联合抵制)攻击,比如某个矿主不想让与 A 有关的交易上链,则它不会打包与 A 相关的交易,并产生分叉
当然矿池的出现也并非一无是处的,它为 miner 带来了好处:原本属于中彩票式的收入(不稳定,但是一旦挖到就能获取一大笔钱),现在属于上班式的收入(每天都有小额但稳定的收入)
manager 在需要时能够召集算力,类似于云计算中的 on demand computing
BTC 脚本
每一笔交易分为输入和输出2部分:
- 输入部分要说明币的来源(哈希指针),还要给出自己的公钥
- 输出部分通过收款人公钥的哈希指明某个收款人
交易结构:
javascript
result: {
txid: "921a...dd24",
hash: "921a...dd24",
version: 1,
size: 226,
locktikme: 0, // 生效时间
vin: [...], // 见下面
vout: [...], // 见下面
blockhash: "0000000000002c510d..5c0b",
confirmations: 23,
time: 1530846727,
blocktime: 1530846727,
}
交易的输入(前一笔交易的输出&签名):
javascript
vin: [{
txid: "c0cb...c57b",
vout: 0, // 来自 c0cb...c57b 的第0个输出
scriptSig: {
asm: "3045...0018",
hex: "4830...0018"
},
]
前2行就说明了币的来源(前一笔交易的 hash value,以及它是第几笔交易/输出)
scriptSig 给出签名,证明你有权利花这笔钱
交易的输出:
javascript
vout: [{
value: 0.22684000,
n: 0,
scriptPubKey: {
asm: "DUP HASH160 628e...d743 EQUALVERIFY CHECKSIG", // 输出脚本的内容
hex: "76a9...88ac",
reqSigs: 1,
type: "pubkeyhash",
addresses: ["19z8LJkNXLrTv2QK5jgTncJCGUEFfpQvSr"] // 输出的地址
}
},
{
value: 0.53756644,
n: 1,
scriptPubKey: {
asm: "DUP HASH160 da7d...2cd2 EQUALVERIFY CHECKSIG",
hex: "76a9...88ac",
reqSigs: 1,
type: "pubkeyhash",
addresses: ["1LvGTpdyeVLcLCDK2m9f7Pbh7zwhs7NYhX"]
}
}
]
为了与交易的合法性,只需将后一笔交易的输入脚本(币的来源)和前一笔交易的输出脚本拼接起来执行一遍,若能执行成功(栈顶为TEUE),则证明交易合法
P2PK(Pay to Public Key)
输出脚本直接给出收款人的公钥 ,输入脚本给出收款人签名,CHECKSIG 会根据输出脚本给出的公钥 和输入脚本的签名进行校验
javascript
// input script
PUSHDATA(Sig)
// output script
PUSHDATA(PubKey)
CHECKSIG // 从栈顶弹出2个元素做校验
P2PKH(Pay to Public Key Hash)
输出脚本给出收款人公钥的 hash,收款人真正的公钥是在输入脚本中给出。这种形式是最常用的
javascript
// input script
PUSHDATA(Sig)
PUSHDATA(PubKey)
// output script
DUP // 复制栈顶的 PubKey
HASH160 // 弹出 PubKey,计算 hash,压入栈
PUSHDATA(PubKeyHash)
EQUALVERRIFY // 栈顶2元素相等性判定,如果判定通过,弹出栈顶2元素(2个 PubKeyHash)
CHECKSIG
P2SH(Pay to Script Hash)
这里输出脚本提供的不是收款人公钥的 hash,而是收款人提供的一个脚本的 hash
javascript
// input script
...
PUSHDATA(Sig)
...
PUSHDATA(serialized redeemScript) // 赎回脚本
// output script
HASH160
PUSHDATA(redeemScriptHash)
EQUAL
input script 要给出一些签名(数目不定)及一段序列化的 redeemScript(赎回脚本)。验证分为如下2步:
验证序列化的 redeemScript 是否与 output 中的 redeemScriptHash 中的哈希值匹配
反序列化并执行 redeemScript,验证 input script 中给出的签名是否正确
利用 redeemScript,可以实现多种形式的验证:
- P2PK
- P2PKH
- 多重签名
使用 P2SH 实现 P2PK:
javascript
// redeemScript
PUSHDATA(PubKey)
CHECKSIG
// input script
PUSHDATA(Sig)
PUSHDATA(serialized redeemScript)
// output script
HASH160
PUSHDATA(redeemScriptHash)
EQUAL
第一阶段的验证:
javascript
PUSHDATA(Sig)
PUSHDATA(seriRS)
HASH160
PUSHDATA(RSH) // redeem script hash
EQUAL
第二阶段:反序列化赎回脚本,并执行:
javascript
PUSHDATA(PubKey)
CHECKSIG
多重签名
用 P2SH 实现 P2PK 看起来有点复杂,它主要是用来实现诸如"多重签名"的复杂验证场景
多重签名需要在输出脚本中给出全部的 N 个公钥,同时指定一个阈值 M ( M ≤ N M \le N M≤N),说明必须要有任意 M 个私钥才能验证通过
多重签名的好处在于即使其中一把私钥泄漏或丢失,也不会直接形成漏洞,因为验证过程还需要其他私钥
javascript
// input script
x
PUSHDATA(Sig_1)
PUSHDATA(Sig_2)
...
PUSHDATA(Sig_M)
// output script
M
PUSHDATA(pubKey_1)
PUSHDATA(pubKey_2)
...
PUSHDATA(pubKey_N)
N
CHECKMULTISIG
多重签名的一个实例:
javascript
FALSE
PUSHDATA(Sig_1)
PUSHDATA(Sig_2)
2
PUSHDATA(pubkey_1)
PUSHDATA(pubkey_2)
PUSHDATA(pubkey_3)
3
CHECKMULTISIG
多重签名的问题在于,支付方(输出脚本)需要知道 N 和 M 以及所有的 N 个公钥,这样做明显增加了输出脚本的复杂度,以网上购物为例,这样做增加了用户的支付复杂度
可以使用 P2SH 改进这个过程:将复杂度从输出脚本转移到输入脚本
javascript
// redeemScript
M
PUSHDATA(pubKey_1)
PUSHDATA(pubKey_2)
...
PUSHDATA(pubKey_N)
N
CHECKMULTISIG
// input script
x
PUSHDATA(Sig_1)
PUSHDATA(Sig_2)
...
PUSHDATA(Sig_M)
PUSHDATA(serialized redeemScript)
EQUAL
// output script
HASH160
PUSHDATA(RedeemScriptHash)
EQUAL
使用 P2SH 实现多重签名的一个实例:
第一阶段:
javascript
FALSE
PUSHDATA(Sig_1)
PUSHDATA(Sig_2)
PUSHDATA(seriRS)
HASH160
PUSHDATA(RSH)
EQUAL
第二阶段(执行 redeem script):
javascript
2
PUSHDATA(pubKey_1)
PUSHDATA(pubKey_2)
PUSHDATA(pubKey_3)
3
CHECKMULTISIG
Proof of Burn
javascript
// output script
RETURN
// ... zero or more ops or text
这种形式的 output 被称为 Provably Unspendable/Prunable Outputs
假设有一个交易的 input 指向这个 output,不论 input 里的 input script 如何设计,执行到 RETURN 命令后都会直接返回 false,不会执行 RETURN 后面的其他指令,所以这个 output 无法再被花出去,其对应的 UTXO 也就可以被删除,相当于这个币就消失了
Proof of Burn 的应用场景在于兑换一些小的币种,需要销毁一定数目的 BTC 才能换取这些小币种
另一个应用场景在于需要向区块链中写入内容,需要支付一定数目的 BTC。相当于任何一个用户都能向全节点支付一定数目的 BTC,换取向区块链中写入内容的机会
BTC 分叉
分叉出现的原因有很多,例如有2个 miner 几乎同时挖到了区块,都 append 到了区块链上,我们管这种分叉叫做 state fork(对区块链当前的状态产生了分歧,分叉攻击也属于这种,分叉攻击是恶意节点故意造成的,故又被称为 deliberate fork)
还有一种出现分叉的情况是 BTC 升级了版本,这时需要在一条新的分叉上运行新版本的 BTC 协议,故这种分叉又被称为 protocol fork,protocol fork 又可被分为 hard fork 和 soft fork
hard fork
硬分叉一般出现在 BTC 的协议发生了升级,例如增加了一些新的 feat,但是其他节点尚不认可这些升级(对协议内容出现了分歧)
一个 BTC 协议升级的例子就是区块大小的增加(block size limit)
假设系统中大多数节点都更新了协议,则大多数节点都会沿着新的链向下挖,从而形成最长合法链
注意 hard fork 可能导致社区分裂,即某些节点始终不认可更新的协议,则它们一直沿着以前的链往下挖,这样就形成了2条工作的链,这2条链通过 chain id 来区分
soft fork
软分叉的出现场景在于:对 BTC 增加了一些更为严苛的限制。举一个不太恰当但便于说明问题的例子,假设我们现在将 block size limit 缩减一半,改为 0.5 M
假设系统中的大多数节点都更新了协议(缩减 block size limit),则新节点挖出的区块大小都是 0.5M,这个 block size 旧节点是认的,但是旧节点挖出的区块新节点不会认可
而且即使旧节点将挖出的区块 append 到 block size limit = 0.5M 到链上,新节点也会再次分叉(因为新节点不认可旧节点挖出的区块),也就是上图的第二个分叉
这样就会造成旧节点挖出的区块一直得不到区块奖励,从而强迫它升级协议
也就是说软分叉从始至终只有一条链在工作(新协议的链),soft fork 形成的分叉都是临时性的
例如一个 BTC 改进提案就是说:为了方便验证账户余额,将 UTXO 也组织成一棵 Merkle Tree,并将根哈希值存储 coinbase 域。这样,新节点的 coinbase 域必须按照一定的格式填写,而旧节点则没有这个限制,这就是一个软分叉
BTC 历史上一个比较重要的软分叉就是前文介绍过的 P2SH
BTC 问答
转账交易时如果接收者不在线会出现什么
不需要接收者在线。因为只需要在区块链上记录这笔交易即可
假设一个全节点收到了一笔转账交易,有没有可能接收者的收款地址是这个节点以前从未听说过的
有可能。BTC 账户的产生只需要在本地产生一对公私钥对即可,无需通知其他节点
如果你的账户的私钥丢失了怎么办
没有办法挽回,这个账户上的钱无法取回了
如果你发现自己的私钥泄漏了怎么办
只能第一时间创建一个新的账户,将自己原来账户上的钱取走
如果发现转账对方账户填错了怎么办
没有任何办法。交易一旦发布,就不能再取消了
实际上,还存在转账对方根本就不是一个地址(接收方公钥的 hash)的情况,因为全节点也无法验证接收方地址是不是一个"真正的地址"
这种情况出现在,A 转给 B,但是 B 提供的地址无效,他只是一段内容的 hash,这样,这笔转给 B 的交易就永远无法取出,永远的保存在了 UTXO 中,这种行为是不被提倡的。这种行为的目的在于,牺牲一些 BTC,换取向区块链中写入一些内容(的哈希)的机会
既然 RETURN 会无条件返回错误,那么他是如何通过验证从而写入区块链的
这是一个细节问题,RETURN 是出现在一笔交易的输出,也就是验证这笔交易时,只是将当前交易的输入 和上一笔交易的输出拼接在一起执行,上一笔交易的输出并不包含这个 RETURN
而当前交易的输出并没有执行的机会,因为只有需要转出当前交易的 BTC 时才会执行到当前交易的输出脚本,而当前交易的 BTC 本身就是没有办法转出的
有没有可能存在 miner 偷答案的情况:它偷取已经发布到链上的 nonce 当作自己的
这是不可能的
因为每个区块的 Merkle Tree 中都有一笔铸币交易(coinbase tx),这笔交易中包含收款人地址
也就是说每个 miner 挖到的 nonce 是和他自己的收款地址绑定在一起的
BTC 匿名性
BTC 通过公钥(的哈希)标明身份,所以这种匿名并不是真正的匿名,而是一种类似于网名的"伪匿名"机制(pseudonymity),所以从这一点上来说,BTC 的匿名性弱于现金
虽然公私钥是匿名的,但是在一些交易中,可以将它们做关联。例如在一笔交易中有2个输入地址:addr1, addr2;有2个输出地址:addr3, addr4,那么 addr1 和 addr2 一般是属于同一个人的;而 addr3 和 addr4 中很可能有一个是找零地址
找零地址也是本人的地址,所以输入和输出也能在一定程度上关联(如果我们能推测出找零交易)
BTC 还可能会泄漏用户在现实世界的真实身份:在任何 BTC 与现实世界发生交互的时候,都有可能泄漏用户的真实身份,例如 BTC 和法币之间的相互兑换,还有用 BTC 做支付的时候
零知识证明
一方(证明者)向另一方(验证者)证明一个陈述是正确的,而无需透露除该陈述时正确的之外的任何信息
一个不太完善的例子:如果我向你证明我持有一个账户的私钥,那么我只需要拿这个私钥生成一个签名,然后你拿公钥验证这个签名即可
同态隐藏
如果 x、y 不同,那么它们的加密函数值 E(x)、E(y) 也不相同
给定 E(x) 的值,很难反推出 x 的值
给定 E(x) 和 E(y) 的值,我们可以很容易地计算出来某些关于 x、y 的加密函数值:
- 同态加法:通过 E(x) 和 E(y) 计算出 E(x + y) 的值
- 同态乘法:通过 E(x) 和 E(y) 计算出 E(xy) 的值
- 扩展到多项式
实例:Alice 想向 Bob 证明她知道一组数 x 和 y 使得 x + y = 7,同时不想让 Bob 知道 x 和 y 到具体数值
解决方案:利用同态隐藏:Alice 将 E(x) 和 E(y) 发送给 Bob,Bob 收到后计算 E(x + y),验证其是否等于 E(7)
盲签
用户 A 提供 SerialNum,银行在不知道 SerialNum 的情况下返回签名 Token,减少 A 的存款
用户 A 把 SerialNum 和 Token 交给 B 完成交易
用户 B 拿 SerialNum 和 Token 给银行验证,银行验证通过,增加 B 的存款
银行无法把 A 和 B 联系起来
BTC 思考
前面一直提"哈希指针",但是实际上区块链中是没有指针的(毕竟不同计算机的内存地址空间相互隔离),这种"指针"只是一种逻辑上的指针,实际上是通过一个哈希表构造的逻辑指针
BTC 的稀缺性:BTC 的总量是 2100w,总量数额是一定的,这看起来能够抵抗通胀,但是一个设计良好的货币是需要天然地通胀的。因为随着社会财富总量的增加,货币总量也需要增加
量子计算对 BTC 的威胁:与其担心量子计算对 BTC 对威胁,还不如担心量子计算对传统金融行业的威胁
RSA 非对称加密体系可以通过私钥推导出公钥,但是反过来是不可能的,而量子计算主要是致力于从公钥推导出私钥这一破解过程
但是 BTC 在收款时并不需要提供公钥,而只需要提供公钥的哈希,而从公钥的哈希推导出公钥是一项不可能的任务(状态空间被压缩了)这就抵抗了量子计算破解;即使 BTC 在付款时需要提供公钥,但是完全可以付款后立即另外生成一对密钥对作为新账户,将余额转入新账户,不给攻击者留出攻击时间(即使他采用了量子计算机)
ETH 概述
区块链2.0
BTC 的平均出块时间为 10 min,在 ETH 中将为十几秒
ETH 的 mining puzzle 对内存的要求更高(ASIC resistance)
ETH 增加了对智能合约的支持(smart contract)
智能合约:去中心化的合约,本质是一段程序,它在特定的情况下被触发执行
智能合约解决了在不同国家的法律框架下签订合约、履行合约的困难性(就好像 BTC 统一了不同国家的法币一样)
ETH 账户
BTC 并没有账户的概念,他是一个基于交易的账本,只能根据 UTXO 去推测目前还剩余多少余额
BTC 在每一笔交易中都要说明币的来源,这与我们生活中的交易很不相同
BTC 另一个让人觉得"别扭"的地方在于,当你需要花一笔钱时,你必须将这些钱全部都花出去,所以需要在每一笔交易都将剩下的钱转给自己
ETH 采用基于账户的模型(account-based ledger),无需说明每一笔交易币的来源,也无需必须保持输入=输出
ETH 的这种基于账户的设计天然地抵抗了 double spending attack
为了避免重放攻击(replay attack),ETH 在每个账户的状态中维护了一个计数器(nonce)
BTC 的"需要说明币的来源"的特性保证了它不会遭受 replay attack
ETH 中有2类账户:
- externally owned account:由一对公私钥控制,普通账户,它的状态包含 balance, nonce
- smart contract account:不是通过公私钥对控制,它的状态除了包含 balance, nonce 之外,还包含 code, storage
ETH 规定,所有的交易只能由外部账户 发起,合约账户不能主动发起一笔交易。外部账户能够调用合约账户,合约账户之间也可以相互调用
ETH 状态树
ETH 需要维护一个从账户地址到账户状态的一个映射,账户地址160bits,20Bytes,40 nibbles;账户状态包含 balance、nonce、code、storage 等
Patricia trie
经过了路径压缩的前缀树(降低树的高度)
路径压缩在 key 的分布比较稀疏的时候效果最好,这正适合于存储 ETH 账户地址的 40 nibbles
Merkle Patricia Tree
将普通指针换为哈希指针的 Patricia Tree
上图展示了4个ETH账户
MPT 中有3类节点:
- Extension Node:出现了路径压缩
- Branch Node
- Leaf Node
每当发布一个新的区块的时候,状态树中某些节点的值会发生变化,这些改变不是在原地改的,而是新建一些分支,其他未受影响状态是保留下来直接复用的
为什么要保留历史状态:假设区块链中出现了分叉,后来上一条链胜出,则需要回滚下面一条链的状态,这就需要历史状态;另一个原因在于,ETH 的智能合约表达能力很强,可以对区块链上的状态做比较大的修改,所以为了保证智能合约执行完的结果可溯源,就需要保留历史状态
ETH 数据结构
go
// Header represents a block header in the Ethereum blockchain.
type Header struct {
ParentHash common.Hash `json:"parentHash" gencodec:"required"`
UncleHash common.Hash `json:"sha3Uncles" gencodec:"required"`
Coinbase common.Address `json:"miner"`
Root common.Hash `json:"stateRoot" gencodec:"required"`
TxHash common.Hash `json:"transactionsRoot" gencodec:"required"`
ReceiptHash common.Hash `json:"receiptsRoot" gencodec:"required"`
Bloom Bloom `json:"logsBloom" gencodec:"required"`
Difficulty *big.Int `json:"difficulty" gencodec:"required"`
Number *big.Int `json:"number" gencodec:"required"`
GasLimit uint64 `json:"gasLimit" gencodec:"required"`
GasUsed uint64 `json:"gasUsed" gencodec:"required"`
Time uint64 `json:"timestamp" gencodec:"required"`
Extra []byte `json:"extraData" gencodec:"required"`
MixDigest common.Hash `json:"mixHash"`
Nonce BlockNonce `json:"nonce"`
}
go
type Block struct {
header *Header
uncles []*Header
transactions Transactions
withdrawals Withdrawals
// witness is not an encoded part of the block body.
// It is held in Block in order for easy relaying to the places
// that process it.
witness *ExecutionWitness
// caches
hash atomic.Pointer[common.Hash]
size atomic.Uint64
// These fields are used by package eth to track
// inter-peer block relay.
ReceivedAt time.Time
ReceivedFrom interface{}
}
区块链上发布的信息
go
// "external" block encoding. used for eth protocol, etc.
type extblock struct {
Header *Header
Txs []*Transaction
Uncles []*Header
Withdrawals []*Withdrawal `rlp:"optional"`
}
ETH 交易树和收据树
每一笔交易对应一笔收据,收据的作用在于快速查询智能合约的执行结果
交易树和收据树也都是 MPT
交易树和收据树的作用在于提供 Merkle Proof
Bloom Filter 不支持删除操作,如果要将其改进为能够删除,则就不能用 bitmap 的 binary 状态了,需要用一个计数器,通过引用计数记录这个位置有多少个元素映射了过来(警惕计数器 overflow)
每个交易完成后会有一笔收据,收据里面包含一个 Bloom Filter,记录这个交易的类型,地址等其他信息
发布的区块在块头中也有一个总的 Bloom Filter,它是这个区块中所有交易的 Bloom Filter 的并集。例如需要查找过去10天跟某一智能合约相关的所有交易,则先查找哪个区块的块头的 Bloom Filter 包含这种交易的类型,然后再去块头包含的收据树的 Bloom Filter 里查找
ETH 的运行过程可以看作是交易驱动的状态机(transaction-driven state machine),其中的状态是状态树维护的所有账户的状态。相比之下,BTC 维护的状态是 UTXO
如果监听到一笔转账交易,有没有可能收款人的地址之前没有听说过?
可能的。ETH 和 BTC 在这一点是相同的,创建账户时不需要通知其他人,只有当这个账户真正发生交易时,才需要在这个状态树中新插入一个节点
能不能缩减状态树,使其只包含跟这个区块中交易相关的账户状态?
不能。这样难以查找某个账户的状态,只能从后往前遍历查找。更严重的问题在于,加入 A 转给 B 10 ETH,如果 B 是新创建的用户,则之前的所有状态树都不能查找到 B 的状态,这就发生了错误
源码分析:
go
// NewBlock creates a new block. The input data is copied,
// changes to header and to the field values will not affect the
// block.
//
// The values of TxHash, UncleHash, ReceiptHash and Bloom in header
// are ignored and set to values derived from the given txs, uncles
// and receipts.
func NewBlock(header *Header, txs []*Transaction, uncles []*Header, receipts []*Receipt) *Block {
b := &Block{header: CopyHeader(header), td: new(big.Int)}
// TODO: panic if len(txs) != len(receipts)
if len(txs) == 0 {
b.header.TxHash = EmptyRootHash
} else {
b.header.TxHash = DeriveSha(Transactions(txs))
b.transactions = make(Transactions, len(txs))
copy(b.transactions, txs)
}
if len(receipts) == 0 {
b.header.ReceiptHash = EmptyRootHash
} else {
b.header.ReceiptHash = DeriveSha(Receipts(receipts))
b.header.Bloom = CreateBloom(receipts)
}
if len(uncles) == 0 {
b.header.UncleHash = EmptyUncleHash
} else {
b.header.UncleHash = CalcUncleHash(uncles)
b.uncles = make([]*Header, len(uncles))
for i := range uncles {
b.uncles[i] = CopyHeader(uncles[i])
}
}
return b
}
go
func DeriveSha(list DerivableList) common.Hash {
keybuf := new(bytes.Buffer)
trie := new(trie.Trie)
for i := 0; i < list.Len(); i++ {
keybuf.Reset()
rlp.Encode(keybuf, uint(i))
trie.Update(keybuf.Bytes(), list.GetRlp(i))
}
return trie.Hash()
}
Trie is a Merkle Patricia Trie
ETH 中的三棵树(状态树、交易树和收据树)都是 MPT
go
// Receipt represents the results of a transaction.
type Receipt struct {
// Consensus fields
PostState []byte
CumulativeGasUsed *big.Int
Bloom Bloom
Logs vm.Logs
// Implementation fields
TxHash common.Hash
ContractAddress common.Address
GasUsed *big.Int
}
ETH Ghost 协议
ETH 的出块时间只有十几秒,这大大提高了吞吐率
但是由于底层 P2P 网络的延迟很高,即使一个矿工挖出了一个区块,其他节点也可能还在沿着原来的链继续挖,也就是出现分叉的概率更高
易分叉特性会导致像矿池这种 mining centralization 占据不成比例的优势(占据的算力越多,越容易成为最长合法链)。这种情况被称为 centralization bias
ETH 采用 GHOST 协议解决这个问题(Greedy Heaviest-Observed Sub-Tree)
下面的图说明了一个 naive 的 GHOST 协议的原理
对于未能成为最长合法链的叔父区块 ,最长合法链上的区块可以选择包含它,这样,该叔父区块就能得到 7 8 ∗ 3 \frac {7}{8} * 3 87∗3 的出块奖励,而包含了它的叔父区块的位于最长合法链上的区块,能够获得 1 32 ∗ 3 \frac {1}{32} * 3 321∗3 的额外奖励。每个区块最多可以包含2个叔父区块
GHOST 协议解决了孤块奖励问题,鼓励矿工参与出块
但是上面 naive 的 GHOST 协议存在2个问题:
- 恶意 miner 始终不包含其叔父节点怎么办
- 如果叔父区块后于自己被挖出,从而无法被包含,怎么办
ETH 通过"放宽叔叔的资质"来解决这个问题,如下图所示,后面的三个节点都可以认定上面的节点为叔父
对于上面的第一个问题,如果当前恶意 miner 故意不包含叔父区块,则后面的区块可以选择包含叔父区块,而后面的区块不一定是恶意 miner 挖出的,这样它就错过了 1 32 ∗ 3 \frac {1}{32} * 3 321∗3 的额外奖励
对于上面的第二个问题,即使当前节点错过了叔父区块,它也能被后面的区块包含
当然叔父的资质也不是无限放宽的,只能从当前节点开始的7代以内有公共祖先的节点才能被认定为叔父,而且这些叔父的奖励逐代递减
出块奖励逐代递减鼓励了一旦出现分叉就尽早进行合并(奖励更多)
BTC miner 的收益主要来自于2部分:
- block reward(static reward)
- tx fee(dynamic reward)
在 ETH 中,tx fee 变为 gas fee;ETH 没有定期将 block reward 减半的规定(包含叔父区块的奖励固定)
将叔父区块包含进来的时候,叔父区块中的交易要不要执行?
不用执行。因为2个区块中的交易可能是有冲突的(余额不足),而且如果执行分支链上的交易,也会造成状态转移混乱,ETH 只沿着最长合法链上的交易进行状态转移
当前区块只需要检查叔父区块的 nonce 是否合法(挖矿难度),如果合法就可以将其包含进来
对于叔父区块有后代的情况,只能给叔父区块奖励,不能给后代奖励
因为如果给后代奖励,就会造成 Forking Attack 变得廉价:即使分叉攻击失败了也能得到很多奖励
ETH 挖矿算法
Block chain is secured by mining
ETH 的设计需要达到 ASIC resistance,采取的方法是增加 mining puzzle 对内存访问的需求,即所谓的 memory hard mining puzzle,因为 ASIC 只能增加逻辑运算单元提升算力,但是无法解决内存带宽带来的算力限制
LiteCoin
莱特币的 mining puzzle 是基于 scrypt 的,scrypt 是一个对内存要求很高的哈希算法,它的基本原理可以阐述如下:
scrypt 基于一个很大的数组,首先基于一个 seed 计算出一个哈希值填入 0 号位置,后面的每一个数值都是前一个数值的哈希,这样整个数组看起来就是一个伪随机数
生成这个数组之后,选定一个初始位置读取,然后将该位置的值模上数组长度得到下一个随机位置读取,然后再将该位置的值模上数组的长度得到下一个随机位置读取,以此类推,在数组上做一定次数的随机跳转
按照这种算法,对于 miner 来说,这就是 memory hard 的,因为它必须要保存整个数组才能得到最终的结果。它必须要用空间换时间,否则计算成本是不可接受的
但是上面的 scrypt 哈希算法虽然对全节点构成了 memory hard,但是它对轻节点也构成了 memory hard,违背了 difficult to solve, but easy to verify 的区块链设计原则
这种缺陷就造成 LiteCoin 在使用的时候内存区域不敢设置的太大,否则轻节点承受不住,实际上 LiteCoin 的数组大小只有 128KB,但是这并不足以 ASIC resistance,后来又出现了针对莱特币的 ASIC
除了 mining puzzle 不同,LiteCoin 的出块速度也是 BTC 的4倍
ethash
ETH 也采用 memory hard mining puzzle,它采用2个数据集,一个是 16MB 的 Cache,另一个是 1GB 的 dataset(DAG)
DAG 是从 cache 生成出来的
轻节点只需保存 16MB 的 cache,只有 miner 才需要保存 dataset
cache 的生成方式跟 LiteCoin 的数组类似,而 dataset 是从 cache 生成出来的:迭代 256次,每次在 cache 中跳转若干次得到结果填入 dataset
cache 和 dataset 都是定期增长的,因为计算机硬件的内存在定期增长
求解 puzzle 时,先根据 previous block header hash 和 nonce 计算出一个在 dataset 中的初始位置,然后跳转到指定位置,下一次的跳转以此类推
dataset 中的跳转会进行 64 次,每次读取当前位置和下一个位置这2个元素,这样就得到了128个伪随机数,然后将他们合并在一起求哈希,得到最终的哈希结果
如果最终的结果满足难度目标阈值,则挖到矿了;否则调整 nonce 重试
下面是通过 seed 计算出 cache 的伪代码描述(只展示大概原理)
python
def mkcache(cache_size, seed):
o = [hash(seed)]
for i in range(1, cache_size):
o.append(hash(o[-1]))
return o
cache 中每个元素都是 64B 的哈希值
每隔 30000 个区块会重新生成 seed,并利用新的 seed 生成新的 cache
cache 的初始大小为 16MB,每隔 30000 个块重新生成时增大初始大小的 1/128,也就是 128K
下面是通过 cache 来生成 dataset 中第 i 个元素的伪代码描述:
python
def calc_dataset_item(cache, i):
cache_size = cache.size
mix = hash(cache[i % cache_size] ^ i) # 初始 mix
for j in range(256):
cache_index = get_int_from_item(mix) # 下一个要访问的 cache 元素下标
mix = make_item(mix, cache[cache_index % cache_size])
return hash(mix) # dataset 中的第 i 个元素
def calc_dataset(full_size, cache):
return [calc_dataset_item(cache, i) for i in range(full_size)]
dataset 也是每隔 30000 个块更新,增大初始大小的 1/128,也就是 8M
python
def hashimoto_full(header, nonce, full_size, dataset):
mix = hash(header, nonce)
for i in range(64):
dataset_index = get_int_from_item(mix) % full_size
mix = make_item(mix, dataset[dataset_index])
mix = make_item(mix, dataset[dataset_index + 1])
return hash(mix)
def hashimoto_full(header, nonce, full_size, dataset):
mix = hash(header, nonce)
for i in range(64):
dataset_index = get_int_from_item(mix) % full_size
mix = make_item(mix, calc_dataset_item(cache, dataset_index))
mix = make_item(mix, calc_dataset_item(cache, dataset_index + 1))
return hash(mix)
轻节点是临时计算出 dataset 中的元素,而矿工是直接访存,也就是内存里必须存着这个 1G 的 dataset
矿工挖矿过程的伪代码:
python
def mine(full_size, dataset, header, target):
nonce = random.randint(0, 2**64)
while hashimoto_full(header, nonce, full_size, dataset) > target:
nonce = (nonce + 1) % 2**64
return nonce
ethash 的挖矿算法比 LiteCoin 更 ASIC resistance,因为它需要矿工有 1GB 的内存,而 LiteCoin 只需要 128KB
LiteCoin 和 ETH 的创立都是为了 ASIC resistance,尽可能让通用计算设备参与挖矿,认为这样更民主(one cpu, one vote)
但是对于 ASIC resistance,也有人持相反观点,认为让通用计算设备参与挖矿反而是不安全的,因为他们很容易被黑客劫持,对区块链发起攻击
如果只使用 ASIC 挖矿,则想要对区块链发起攻击,则需要买入大量矿机,挖矿(攻击)成本高得多
ETH 难度调整
D ( H ) = { D 0 if Hi=0 m a x ( D 0 , P ( H ) H d + x ∗ σ ) + ϵ otherwise w h e r e : D 0 = 131072 \begin{equation} D(H)= \begin{cases} D_0& \text{if\ \ {Hi}=0}\\ max(D_0, P(H)_{H_d} + x * \sigma) + \epsilon & \text{otherwise} \end{cases} \end{equation} \\ where: D_0=131072 D(H)={D0max(D0,P(H)Hd+x∗σ)+ϵif Hi=0otherwisewhere:D0=131072
D(H) 是本区块难度,由基础部分 P ( H ) H d + x ∗ σ P(H)_{H_d} + x * \sigma P(H)Hd+x∗σ 和难度炸弹 ϵ \epsilon ϵ 相加得到
P ( H ) H d P(H)_{H_d} P(H)Hd 是父区块难度,每个区块的难度都是在父区块难度的基础上进行调整
x ∗ σ x * \sigma x∗σ 用于自适应调节出块难度,维持稳定的出块速度
基础部分有下界 131072
自适应难度调整
x = ⌊ P ( H ) H d 2048 ⌋ x = \lfloor \frac{P(H)_{H_d}}{2048} \rfloor x=⌊2048P(H)Hd⌋
σ = m a x ( y − ⌊ H s − P ( H ) H s 9 ⌋ , − 99 ) \sigma = max(y - \lfloor \frac{H_s - P(H)_{H_s}}{9} \rfloor, -99) σ=max(y−⌊9Hs−P(H)Hs⌋,−99)
python
y = 2 if parent.uncle else 1
父区块包含 uncle 时难度会增大一个单位,因为包含 uncle 时新发行的货币量大,需要适当提高难度以保持货币发行量稳定
难度降低的上界设置为-99,主要是应对被黑客攻击或其他以前想不到的黑天鹅事件
H s H_s Hs 是本区块的时间戳, P ( H ) H s P(H)_{H_s} P(H)Hs 是父区块的时间戳,all in seconds
所以上面第二个公式的分子部分就是出块时间:出块时间过短则调大难度,出块时间过长则调小难度
以父块不带 uncle(y = 1) 为例:
- 出块时间在 [1, 8] 之间,出块时间过短,计算结果为1,则难度调大一个单位
- 出块时间在 [9, 17] 之间,出块时间可以接受,计算结果为0,难度保持不变
- 出块时间在 [18, 26] 之间,出块时间过长,计算结果为 -1,难度调小一个单位
难度炸弹
go
// calcDifficultyByzantium is the difficulty adjustment algorithm. It returns
// the difficulty that a new block should have when created at time given the
// parent block's time and difficulty. The calculation uses the Byzantium rules.
func calcDifficultyByzantium(time uint64, parent *types.Header) *big.Int {
// https://github.com/MatrixAINetwork/EIPs/issues/100.
// algorithm:
// diff = (parent_diff +
// (parent_diff / 2048 * max((2 if len(parent.uncles) else 1) - ((timestamp - parent.timestamp) // 9), -99))
// ) + 2^(periodCount - 2)
bigTime := new(big.Int).SetUint64(time)
bigParentTime := new(big.Int).Set(parent.Time)
// holds intermediate values to make the algo easier to read & audit
x := new(big.Int)
y := new(big.Int)
logger := log.New("CalcDifficulty diff", parent.Difficulty)
// (2 if len(parent_uncles) else 1) - (block_timestamp - parent_timestamp) // 9
x.Sub(bigTime, bigParentTime)
x.Div(x, params.DurationLimit)
if parent.UncleHash == types.EmptyUncleHash {
x.Sub(big1, x)
} else {
x.Sub(big2, x)
}
// max((2 if len(parent_uncles) else 1) - (block_timestamp - parent_timestamp) // 9, -99)
if x.Cmp(bigMinus99) < 0 {
x.Set(bigMinus99)
}
// parent_diff + (parent_diff / 2048 * max((2 if len(parent.uncles) else 1) - ((timestamp - parent.timestamp) // 9), -99))
y.Div(parent.Difficulty, params.DifficultyBoundDivisor)
if y.Sign() == 0 {
y = big1
}
x.Mul(y, x)
x.Add(parent.Difficulty, x)
logger.Info("cal Diff", "x", x, "y", y, "minDiff", params.MinimumDifficulty)
// minimum difficulty can ever be (before exponential factor)
if x.Cmp(params.MinimumDifficulty) < 0 {
x.Set(params.MinimumDifficulty)
}
// calculate a fake block number for the ice-age delay:
// https://github.com/MatrixAINetwork/EIPs/pull/669
// fake_block_number = min(0, block.number - 3_000_000
fakeBlockNumber := new(big.Int)
if parent.Number.Cmp(big2999999) >= 0 {
fakeBlockNumber = fakeBlockNumber.Sub(parent.Number, big2999999) // Note, parent is 1 less than the actual block number
}
// for the exponential factor
periodCount := fakeBlockNumber
periodCount.Div(periodCount, expDiffPeriod)
// the exponential factor, commonly referred to as "the bomb"
// diff = diff + 2^(periodCount - 2)
if periodCount.Cmp(big1) > 0 {
y.Sub(periodCount, big2)
y.Exp(big2, y, nil)
x.Add(x, y)
}
return x
}
ETH 权益证明
ETH 和 BTC 的能耗都高的吓人,ETH 每一笔交易几乎要花费 67 kWh 的电力
miner 的收入是由挖矿速度决定,挖矿速度是由算力决定,算力是由矿机决定,矿机是由钱决定
反过来推:钱越多能买的矿机越多,矿机越多算力越强,算力越强挖矿速度越快,挖矿速度越快收益越多
所以干脆一步到位,直接比拼钱的多少决定收益的分配就 OK 了,这就叫 Proof of Stake(virtual mining)
权益证明的共识机制 是按照每个人持有加密货币的数量进行投票的,这样节省了挖矿的过程,从而减少了能源的浪费
基于工作量证明的共识系统,它维护区块链安全的资源,不是一个闭环 ,因为它必须要借助外部的设备 才能进行挖矿,换一个角度想,发动攻击所需要的资源也是从外部得到的。也就是说,如果你在区块链之外的世界很有钱,你就能购入足够多的设备,从而发起 51% 攻击
而对于权益证明,即使你在区块链之外的世界很有钱,想发起 51% 攻击,你也只能先用这些钱换取加密货币,才能持有大的投票权。攻击这个加密货币需要的资源,只能从系统内部得到,所以基于权益证明的加密货币是一个闭环
权益证明和工作量证明这二者并不互斥,有的系统将二者结合起来使用:例如你占有的权益越多(持有的币越多),则你的挖矿难度将会越低。同时为了避免马太效应(币越多-挖矿越容易-币越多),一般在挖到区块之后,会将你投入的币冻结一段时间
Casper
两边下注问题(nothing at stake):miner 可能对2条链都下注(投票),对于未能成为最长合法链的分支,miner 的资金也只是被冻结了一段时间而已
Casper the Friendly Finality Gadget(FFG)
- finality: 定局
- gadget: 小工具
Validator:要想成为 validator,需要先缴纳一部分 ETH 作为保证金
validator 的作用是推动系统达成共识,投票决定最长合法链,投票的权重取决于保证金的数目大小
validator 投票的过程类似于数据库中的 two phase commit:先挖矿挖出 100 个区块作为一个 epoch,然后决定它能否成为最长合法链
分为2个阶段:
第一轮投票是 prepare message;第二轮投票是 commit message
每轮投票都需要得到超过 2/3 的 validator 同意
实际的 ETH Casper 协议进行了改进:epoch 减少为 50,每个 epoch 结束之后的投票对于后一个 epoch 是 prepare message,对于前一个 epoch 是 commit message
validator 参与这个过程能够得到响应的奖励
相反,如果 validator 迟迟不作为,导致系统达不到共识,则会受到响应的处罚,会被扣除掉一部分保证金
如果 validator 乱作为(两边下注),则会没收它的全部保证金
Casper 协议可以给区块链挖出来的一部分区块,在某种状态插入一个检查点做检查,那么这个检查点是绝对安全的吗?
不是绝对安全的:可能存在大量 validator 两边下注
为 Proof of Work 正名:它虽然耗电,但是它可以将电网用电低谷时的电力充分利用起来,转化为 BTC,从而创造价值
ETH 智能合约
智能合约是运行在区块链上的一段代码,代码的逻辑定义了合约的内容
智能合约的账户保存了合约当前的运行状态:
- balance
- nonce(交易次数)
- code
- storage(一颗 MPT)
Solidity 是智能合约常用语言,类似于 JS
payable
修饰的函数代表当前账户可以接受转账
一个合约如何调用另一个合约中的函数?
- 直接调用
javascript
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
也抛出错误,本次调用全部回滚
可以通过 .gas()
和 .value()
调整提供的 gas 数量或提供一些 ETH
- 使用 address 类型的 call 函数
javascript
contract C {
function callAFooByCall(address addr) public returns (bool) {
bytes4 funcsig = bytes4(keccak256("foo(string)"));
if (addr.call(funcsig, "call foo by func call")) { // A(addr).foo("call foo by func call")
return true;
}
return false;
}
}
delegatecall()
的使用方法与 call()
相同,只是不能使用 .value()
区别在于是否切换上下文:
- call() 切换到被调用的智能合约上下文中
- delegatecall() 只使用给定地址的代码,其他属性(存储,余额等)都取自当前合约。delegatecall() 的目的是使用存储在另一个合约中的库代码
fallback 函数
匿名函数,无参数无返回值
javascript
function() public [payable] {
// ...
}
在2种情况下会被调用:
- 直接向一个合约地址转账而不加任何 data(被调函数是需要在 data 种声明的,缺省了 data 就直接调用 fallback)
- 被调用的函数不存在
如果转账金额不是0,同样需要声明 payable,否则会抛出异常
智能合约需要编译到 bytecode 运行于 EVM 上
创建合约:外部账户发起一个转账交易到 0x0 的地址
- 转账金额是0,但需要支付汽油费
- 合约的代码放在 data 域里
ETH 是一个交易驱动的 FSM:调用智能合约的交易发布到区块链上后,每个矿工会执行这个交易,从当前状态确定性地转移到下一个状态
gas fee
智能合约是一个 Turing-complete Programming Model,所以它对于死循环的情况是无能为力的
执行合约中的指令要收取 gas fee,由发起交易的人来支付
go
type txdata struct {
AccountNonce uint64 `json:"nonce" gencodec:"required"`
Price *big.Int `json:"gasPrice" gencodec:"required"`
GasLimit uint64 `json:"gas" gencodec:"required"`
Recipient *common.Address `json:"to" rlp:"nil"` // nil means contract creation
Amount *big.Int `json:"value" gencodec:"required"`
Payload []byte `json:"input" gencodec:"required"`
//...
}
EVM 中不同指令消耗的汽油费是不一样的:简单的指令很便宜,复杂的或者需要存储状态的指令很贵
错误处理
智能合约中不存在 try-catch 结构
一旦遇到异常,除特殊情况外,本次执行操作全部回滚(原子性)
可以抛出错误的语句:
- assert(bool condition):如果条件不满足就抛出,用于内部错误
- require(bool condition):如果条件不满足就抛掉,用于输入或者外部组件引起的错误
- revert():终止运行并回滚状态变动
在合约执行之前需要给充足的 gas fee 并预先扣除(在本地记录的状态树中),如果有剩余则退回,但是如果执行到中间发现 gas fee 不足,已经花掉的 gas fee 是不退的(防止 dos 攻击)
嵌套调用
一个合约调用另一个合约中的函数
连锁式回滚:如果被调用的合约执行过程中发生异常,会不会导致发起调用的合约也跟着一起回滚?
直接调用的会导致回滚,通过 call 调用的不会
go
// Header represents a block header in the Ethereum blockchain.
type Header struct {
ParentHash common.Hash `json:"parentHash" gencodec:"required"`
UncleHash common.Hash `json:"sha3Uncles" gencodec:"required"`
Coinbase common.Address `json:"miner"`
Root common.Hash `json:"stateRoot" gencodec:"required"`
TxHash common.Hash `json:"transactionsRoot" gencodec:"required"`
ReceiptHash common.Hash `json:"receiptsRoot" gencodec:"required"`
Bloom Bloom `json:"logsBloom" gencodec:"required"`
Difficulty *big.Int `json:"difficulty" gencodec:"required"`
Number *big.Int `json:"number" gencodec:"required"`
GasLimit uint64 `json:"gasLimit" gencodec:"required"`
GasUsed uint64 `json:"gasUsed" gencodec:"required"`
Time uint64 `json:"timestamp" gencodec:"required"`
Extra []byte `json:"extraData" gencodec:"required"`
MixDigest common.Hash `json:"mixHash"`
Nonce BlockNonce `json:"nonce"`
}
GasUsed 是这个区块中所有交易的汽油费之和
GasLimit 是这个区块中所有交易能够消耗的汽油的上限
GasLimit 不是定死的,每个 miner 可以在上一个 miner 的基础上上调或下调 1/1024
应该先挖矿还是应该先执行智能合约?
先执行
因为只有先执行智能合约,才能更新状态树,交易树,收据树。确定3棵 Merkle Tree 的根哈希值,才能往下尝试 nonce
对于没有挖到矿的 miner,它得不到任何奖励,相反,它还必须将发布到区块链上的交易都执行一遍
是否存在没有挖到矿的矿工恶意不执行区块链上发布的交易?从而危及区块链安全?
它如果不执行,就不能更新本地的3棵树,就无法继续沿着最长合法连向下挖
发布到区块链上的交易是不是都是成功执行的?有没有可能执行错误?
有可能存在执行错误的,执行错误的交易也要扣除 gas fee
go
type Receipt struct {
PostState []byte
Status uint64
CumulativeGasUsed uint64
Bloom types.Bloom
Logs []*types.Log
TxHash common.Hash
TxIndex uint64
ContractAddress common.Address
GasUsed uint64
ParsedLogs []Event
BlockHash common.Hash
BlockNumber uint64
From common.Address
To *common.Address
}
Solidity 支持多线程吗?
不支持。ETH 是交易驱动的状态机,对于状态机而言,状态的转移必须是确定性的。多线程很可能会导致执行结果的不确定性。同理,Solidity 也不支持产生"真正意义"下的随机数。同理,Solidity 也不能通过 system call 得到当前系统的信息,因为这些信息对于不同的节点来说是不同的,会影响状态的转移
智能合约可以获取的区块信息:
javascript
// 给定区块的哈希-仅对最近的 256 个区块有效而不包括当前区块
block.blockhash(uint blockNumber) returns (bytes32)
// 挖出当前区块的矿工的地址
block.coinbase: address
block.difficulty: uint
block.gaslimit: uint
block.number: uint
block.timestamp: uint
智能合约可以获得的调用信息:
javascript
// 完整的 calldata
msg.data: bytes
// 剩余 gas
msg.gas: uint
// 消息发送者(当前调用)
msg.sender: address
// calldata 的前4个字节
msg.sig: bytes4
// 随消息发送的 wei 的数量
msg.value: uint
now: uint
// 交易的 gas 价格
tx.gasprice
// 交易发起者(完整的调用链)
tx.origin
msg.sender
与 tx.origin
的区别在于,前者只是针对当前调用而言,后者针对的是完整的调用链
对于 f1->f2
的调用,sender 是 C1,origin 是 A
地址类型
3种发送 ETH 的方式:
- address::transfer(uint256 amount)
- address::send(uint256 amount) returns (bool)
- address::call.value(uint 256 amount)()
简单拍卖
javascript
contract SimpleAuctionV1 {
// 受益人
adress public baneficiary;
// 结束时间
uint public auctionEnd;
// 最高出价人
address public highestBidder;
// 所有出价
mapping(address => uint) bits;
// 竞拍者
address[] bidders;
// 结束表示
bool ended;
event HighestBidIncreased(address bidder, uint amount);
event AuctionEnded(address winner, uint amount);
constructor(uint _biddingTime,adddress _beneficiary) public {
baneficiary = _beneficiary;
auctionEnd = _biddingTime + now;
// ...
}
// 竞拍出价 payable
function bid() public payable{
// 拍卖未结束
require(now <= auctionEnd);
// 有效出价必须大于最高价
require(bids[msg.sender] + msg.value) > bids[highestBidder]);
// 如果此人之前未出价,则加入到竞拍者中
if(!bids[msg.sender]==uint(0)){
bidders.push(msg.sender);
}
// 更新最高价
highestBidder = msg.sender;
bids[msg.sender] += msg.value;
// 发送消息
emit HighestBidIncreased(msg.sender,bids[msg.sender]);
}
function auctionEnd() public{
// 拍卖时间结束
require(now > auctionEnd);
// 活动未完成,此未提现
require(!ended);
// 转账给受益人
baneficiary.transfer(bids[highestBidder]);
// 退钱
for(uint i = 0; i < bidders.length; i++){
address bidder = bidders[i];
if (bidder == highestBidder) continue;
bidder.transfer(bids[bidder]);
}
//活动完成
ended = true;
}
}
漏洞1:
javascript
contract hackV1{
function hackBid(address addr) payable public{
SimpleAuctionV1 s = SimpleAuctionV1(addr);
s.bid.value(msg.value);
}
}
hacker通过调用hackBid函数,把合约地址转换为合约实例,间接调用竞拍函数bid
当循环退款时,当处理到转账,会调用fallback函数,但上述攻击合约并没有声明fallback
那么transfer失败,发生整体回滚(本地的 MPT)。这意味着v1的合约用户上的货币无法转出
由投标者自己取回出价
javascript
function widthdraw() public returns (bool){
// check 校验
require(now > auctionEnd);
require(msg.sender != highestBidder);
require(bids[msg.sender] > 0);
uint amount = bids[msg.sender];
if (msg.sender.call.value(amount)){
bids[msg.sender] =0;
return true;
}
return false;
}
event Pay2Beneficiary(address winner, uint amount);
function pay2Beneficiary() public returns (bool){
require(now > auctionEnd);
// 有钱可以支付
require(bids[highestBidder] > 0);
uint amount = bids[hithestBidder];
// 清零
bids[highestBidder] =0;
emit pay2Beneficiary(highestBidder,amount);
if (!highestBidder.send.value(amount)){
bids[highestBidder] = amount;
return false;
}
return true;
}
上面的代码仍然存在漏洞:
javascript
contract hackV2{
uint stack = 0;
function hackBid(address addr) payable public{
SimpleAuctionV1 s = SimpleAuctionV1(addr);
s.bid.value(msg.value);
}
function hancWidthdraw(address addr) public payable{
SimpleAuctionV1(addr).widthdraw();
}
function() public payable(){
stack += 2;
if(msg.sender.balance >= msg.value && msg.gas > 6000 && stack < 500){
SimpleAuctionV1(addr).widthdraw();
}
}
}
当黑客调用withdraw时,会自动执行黑客合约的fallback,如果条件满足,会发起递归提现。
解决方案:先将余额清零即可
javascript
function widthdraw() public returns (bool){
// check 校验
require(now > auctionEnd);
require(msg.sender != highestBidder);
require(bids[msg.sender] > 0);
uint amount = bids[msg.sender];
// 先清零
bids[msg.sender] = 0;
// 调用sender,或tranfer
if (!msg.sender.send(amount)){
bids[msg.sender] = amount;
return true;
}
return false;
}
ETH The DAO
DAO := Decentralized Autonomous Organization
DAC := Decentralized Autonomous Corporation
The DAO 是一家 DAO 组织,行为模式类似于众筹基金:参与人向 The DAO 转入 ETH,换取代币。每一笔投资由所有参与人投票决定
split DAO
若想从 The DAO 中取出资金,需要按照拆分的原则,将自己的账户单独拆分出去处理。但是 The DAO 的 splitDAO 的实现是有重大 bug 的
javascript
function splitDAO(uint _proposalID, address _newCurator)
noEther onlyTokenholders returns (bool _success) {
// ...
Transfer(msg.sender, 0, balances[msg.sender]);
withdrawRewardFor(msg.sender); // 这里可能导致递归调用
totalSupply -= balances[msg.sender];
balances[msg.sender] = 0;
paidOut[msg.sender] = 0;
return ture;
}
withdrawRewardFor
可能会导致上一节提到的递归调用,导致重入攻击。黑客利用这个漏洞转走了大量 ETH
面对这个漏洞,社区分裂成2派:
- 回滚链上的交易,不让钱转入黑客账户
- 不回滚交易,因为黑客的行为并没有非法,他只是利用了 ETH 中的这个 bug(feature),一旦开了这个口子,后面就无法统一了
但是回滚黑客的交易就意味着,必须要回滚黑客交易所在链之后的所有交易
ETH 进行了一次升级:凡是与 The DAO 相关的账户,不允许任何交易。这形成了一个软分叉(增加了约束条件,旧节点认可新节点,但是新节点不认可旧节点)
但是这次升级又引入了新的 bug:与 The DAO 相关的账户被认定为非法账户,对于其中的交易不予执行,但是没有收取 gas fee,这就造成了 hacker 大量的 ddos 攻击
为了解决这个 bug,ETH 进行了硬分叉,对于 The DAO 账户中的钱,无条件地强行转入到另一个合约账户(不用签名),而这个合约账户的唯一功能就是退钱。这种无条件明显是一种中心化的强权,导致社区出现了分裂:
- ETH:新的链,继承了 ETH 的符号
- ETC:Ethereum Classic,旧的链,经典以太坊
两条链通过 ChainID 区分
ETH 反思
Is smart contract really smart?
智能合约并没有任何 AI 相关的内容,从这个意义上来讲它应该属于自动合约
Irrevocability is a double edged sword
一方面不可篡改性确保了信息的正确性
另一个方面,智能合约一旦出现漏洞,想对其修复是很困难的:冻结账户很困难;智能合约一旦发布到区块上,无法阻止对其的调用
Nothing is irrevocable
Is solidity the right programming language?
是否应该采用一些函数式编程语言,便于做形式化验证
或者说,solidity 作为一个图灵完备的语言,是否表达能力过强了
为了对抗智能合约中的这些 bug,可以采用一些模版来生成智能合约,或者创立一些专门编写智能合约的组织机构
Many eyeball fallacy(谬误)
What does decentralization mean?
去中心化并不是全自动化,让机器决定一切
去中心化并不是说已经制定的规则就不能再修改了,而是说对规则的修改要通过去中心化的方式来进行
分叉恰好是去中心化的体现(民主)
decentralized != distributed
去中心化的系统一定是分布式的,但反之不成立
一般来说设计分布式系统是为了加速,但是区块链是基于 state machine 的,采用分布式设计的目的是为了增强容错
state machine 设计一般是用于 mission critical application
ETH Beauty Chain
IPO := Initial Public Offering,首次公开募股
ICO := Initial Coin Offering
javascript
function batchTransfer(address[] _receivers, uint256 _value) public returns (bool) {
uint cnt = _receivers.length;
uint256 amount = uint256(cnt) * _value;
require(cnt > 0 && cnt <= 20);
require(_value > 0 && balances[msg.sender] >= amount);
balances[msg.sender] = balances[msg.sender].sub(amount);
for (uint i = 0; i < cnt; i++) {
balances[_receivers[i]] = balances[_receivers[i]].add(_value);
emit Transfer(msg.sender, _receivers[i], _value);
}
return true;
}
上述合约代码中,存在漏洞的代码为uint256 amount = uint256(cnt) * _value;
计算转出总额度amount未使用SafeMath也未对溢出进行检查,直接将转账地址数量乘以转账额度
如果输入极大的_value
,那么amount计算结果就可能产生溢出,导致代币增发
如果 _receviers.length === 2, _value == 0b100000...
,那么 amount
就会溢出为 0,从而在不扣除本账户的情况下,向各个账户都转入了一大笔代币
解决方案:使用 SafeMath 库
总结
区块链的目的是要在互不信任的实体之间建立共识